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.