From 736abd811d042484d434302d359fe94331784b43 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 15 Oct 2026 09:21:42 +0200 Subject: [PATCH] paint.setFill / setStroke: span "selection" places one gradient across the targets (#2127) A gradient given to several objects fits each one's own bounds. With span: "selection", a linear or radial gradient without its own vector is placed once on the union of the targets' bounds, at its angle and aspect, and every target gets that vector in document coordinates, so the ramp runs on across them. A solid colour, a swatch, a freeform gradient or an explicit start/end with it is refused before anything changes; the next object drawn still fits the gradient to itself. --- crates/engine/src/cmd/paint.rs | 50 ++++++++++++++++++++++++++++- crates/engine/src/tests_gradspan.rs | 3 +- docs/mcp.md | 3 ++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index dd7f05092..2eeadf8dc 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\"} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only", has_doc, |s, p| set_paint(s, p, true) ), @@ -381,6 +381,7 @@ fn apply_to_proxy(s: &mut Session, p: &Value, paint: Paint) -> Result { /// remember the paint. fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result { let cmd = if fill { "paint.setFill" } else { "paint.setStroke" }; + let span = spans_targets(p, &paint).map_err(|e| bad(cmd, e))?; // Characters selected with the Type tool take the paint, not the whole type object (#1063). if p.get("ids").is_none() && p.get("item").is_none() @@ -415,6 +416,7 @@ fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result Result std::result::Result { + match p.get("span") { + None | Some(Value::Null) => return Ok(false), + Some(Value::String(v)) if v == "object" => return Ok(false), + Some(Value::String(v)) if v == "selection" => {} + Some(v) => return Err(format!("span must be \"object\" or \"selection\", got {v}")), + } + let Paint::Gradient(g) = paint else { return Err("span: \"selection\" places a gradient: give `gradient`".into()) }; + if p.get("swatch").is_some() { + return Err("span: \"selection\" places a `gradient`, not a swatch".into()); + } + if g.gradient.kind == vectorcraft_color::GradientKind::Freeform || g.freeform.is_some() { + return Err("span: \"selection\" places a linear or radial gradient, not a freeform one".into()); + } + if g.geom.is_some() { + return Err( + "span: \"selection\" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates)".into() + ); + } + Ok(true) +} + +/// `paint` placed once on the union of the bounds of `ids` (each one's fill box, or the box its +/// stroke paints), at the gradient's angle and aspect: every target gets the same vector in +/// document coordinates, so the ramp runs on across them (#2127). +fn placed_across(d: &Document, ids: &[NodeId], paint: Paint, p: &Value, fill: bool) -> Paint { + let union = ids + .iter() + .filter_map(|id| d.node(*id)) + .filter_map(|n| item_paint_bounds(n, None, fill).or_else(|| n.geometric_bounds())) + .filter(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite())) + .reduce(|a, b| a.union(b)); + let (Some(union), Paint::Gradient(g)) = (union, &paint) else { return paint }; + // An `aspect` places a radial gradient on the box already; otherwise fit it there. + match place_paint(&paint, p, Some(union)) { + Paint::Gradient(placed) if placed.geom.is_some() => Paint::Gradient(placed), + _ => { + let geom = vectorcraft_color::GradientGeom::fit(g.gradient.kind, union, g.angle); + Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..(**g).clone() })) + } + } +} + /// The mesh point the active tool has focused (its `meshPoint` option: the Mesh tool's or Direct /// Selection's point last clicked). fn focused_mesh_point(s: &Session) -> Option<(NodeId, usize)> { diff --git a/crates/engine/src/tests_gradspan.rs b/crates/engine/src/tests_gradspan.rs index eed185b3d..59f29a361 100644 --- a/crates/engine/src/tests_gradspan.rs +++ b/crates/engine/src/tests_gradspan.rs @@ -99,7 +99,8 @@ fn the_selection_is_the_default_target_and_angle_and_radial_are_kept() { assert!(near(geom.start, Point::new(150.0, 0.0)) && near(geom.end, Point::new(150.0, 200.0)), "{geom:?}"); assert_eq!(fill_gradient(&s, a).geom, Some(geom)); // Radial: centred on the union, radius half its larger side, aspect kept. - s.execute("paint.setFill", &json!({"gradient": {"kind": "radial", "aspect": 50, "stops": black_to_white()["stops"]}, "span": "selection"})).unwrap(); + s.execute("paint.setFill", &json!({"gradient": {"kind": "radial", "aspect": 50, "stops": black_to_white()["stops"]}, "span": "selection"})) + .unwrap(); let geom = fill_gradient(&s, a).geom.unwrap(); assert!(near(geom.start, Point::new(150.0, 100.0)), "{geom:?}"); assert!(((geom.end - geom.start).hypot() - 150.0).abs() < 1e-6, "{geom:?}"); diff --git a/docs/mcp.md b/docs/mcp.md index ac706ade3..f97517d58 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -278,6 +278,9 @@ explicit `ids`. Paint values (`fill`, `stroke`) accept `"#rrggbb"`, `"none"`, `[r,g,b]` (0..1), `{"c","m","y","k"}`, `{"gray"}`, or a full `paint.setFill` params object (`{"gradient": …}`, `{"swatch": "name"}`). +A gradient given to several objects fits each one's own bounds; `paint.setFill` / `paint.setStroke` with +`span: "selection"` place a linear or radial gradient once on the union of the targets' bounds instead, so one +ramp runs across them. For `draw_shape` and `draw_path`, omitted paint fields inherit the current [new-art appearance](#new-art). For a filled shape or path without an outline, pass an explicit `fill` and `stroke: "none"`. Lines start with no fill and a visible stroke.