From be9be8c1bda4614bd1c6cb7bff8e5f5901a108e1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 03:59:02 -0800 Subject: [PATCH] Swatches: a replacement gradient or pattern is stored as a new swatch stores it swatch.edit stored a replacement gradient without its placement, so a radial gradient with an aspect, Alt-dropped on a gradient swatch or given as `paint`, lost that aspect, while swatch.new keeps it and applies the swatch with it. A gradient given an aspect without `start` and `end` lost the aspect in both commands. A replacement pattern kept the tile transform of the swatch it came from, while swatch.new stores a pattern untransformed, and `paint: {pattern: name}`, which an Alt-drop of a pattern fill sends, was refused. swatch.edit and swatch.new now store a gradient or pattern through one helper: a gradient keeps its aspect, with or without a vector, and holds no swatch link, and a pattern is untransformed. swatch.edit takes a pattern by name, as swatch.new does. --- crates/engine/src/cmd/swatch.rs | 48 +++++++++--- crates/engine/src/tests/swatchlib.rs | 108 +++++++++++++++++++++++++++ docs/mcp.md | 10 ++- 3 files changed, 151 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index 3b98cbd9a6..3388b8344b 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -19,7 +19,7 @@ pub fn specs() -> Vec { "New Swatch", ["Window", "Swatches"], None, - "{name?, color? | colors?: [colour] (one swatch each, in one undo step; #rrggbbaa and #rgba are refused) | swatch? | gradient? | pattern?: name (default: the current fill), tint?: 0..100 (% of the global or spot `swatch`, or of the one the current fill links to), mode?: \"gray\"|\"rgb\"|\"hsb\"|\"lab\"|\"cmyk\"|\"web\" (convert the colour; lab: CIE L*a*b*, how spot colours are usually defined), global?, spot? (a spot colour, always global), group?: colour group name (solid colours only; default: ungrouped), none?: true (the None paint), paint?: paint.setFill params, keepModel?: bool (as paint.setFill)} save a colour, gradient or pattern as a swatch. A tint of a global or spot colour (below 100%, without mode, global or spot) becomes a tint swatch, \"Name 40%\", linked to its base: it follows edits to the base and applies as that tint of it. A gradient swatch holds no link to the swatch its gradient came from, and a pattern swatch holds the pattern untransformed. Names are unique (\"Sky 2\"); a colour's default name is its values (\"C=10 M=20 Y=30 K=0\", \"R=255 G=128 B=0\", \"Gray K=40\", \"L=55 a=60 b=40\") → {name, names: [every new swatch]}", + "{name?, color? | colors?: [colour] (one swatch each, in one undo step; #rrggbbaa and #rgba are refused) | swatch? | gradient? | pattern?: name (default: the current fill), tint?: 0..100 (% of the global or spot `swatch`, or of the one the current fill links to), mode?: \"gray\"|\"rgb\"|\"hsb\"|\"lab\"|\"cmyk\"|\"web\" (convert the colour; lab: CIE L*a*b*, how spot colours are usually defined), global?, spot? (a spot colour, always global), group?: colour group name (solid colours only; default: ungrouped), none?: true (the None paint), paint?: paint.setFill params, keepModel?: bool (as paint.setFill)} save a colour, gradient or pattern as a swatch. A tint of a global or spot colour (below 100%, without mode, global or spot) becomes a tint swatch, \"Name 40%\", linked to its base: it follows edits to the base and applies as that tint of it. A gradient swatch keeps the gradient's aspect (with or without start/end) and holds no link to the swatch its gradient came from, and a pattern swatch holds the pattern untransformed. Names are unique (\"Sky 2\"); a colour's default name is its values (\"C=10 M=20 Y=30 K=0\", \"R=255 G=128 B=0\", \"Gray K=40\", \"L=55 a=60 b=40\") → {name, names: [every new swatch]}", has_doc, swatch_new ), @@ -48,7 +48,7 @@ pub fn specs() -> Vec { "Swatch Options", ["Window", "Swatches"], None, - "{name, newName?, color? (e.g. {l, a, b} for a Lab colour; #rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill; the swatch's own color and a color given with mode keep their model), mode?: \"gray\"|\"rgb\"|\"hsb\"|\"lab\"|\"cmyk\"|\"web\" (convert the colour), global?, spot? (spot colours are always global), paint?: paint.setFill params ({color}, {gradient}, {swatch} or {pattern}) replacing the swatch's colour; gradient or pattern with one of the same kind} edit a swatch in any colour group, as one undo step. Fills, strokes, text, gradient stops and tint swatches linked to a global swatch take its new colour (at their own tint) and name; turning Global off unlinks them (they keep their colour). Colour, mode and spot apply to solid colours only; a tint swatch only takes a new name (edit its base) → {name, relinked: paints changed}", + "{name, newName?, color? (e.g. {l, a, b} for a Lab colour; #rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill; the swatch's own color and a color given with mode keep their model), mode?: \"gray\"|\"rgb\"|\"hsb\"|\"lab\"|\"cmyk\"|\"web\" (convert the colour), global?, spot? (spot colours are always global), paint?: paint.setFill params ({color}, {gradient} or {swatch}) or {pattern: name} replacing the swatch's colour; gradient or pattern with one of the same kind (stored as swatch.new stores it: a gradient keeps its aspect with or without start/end and holds no swatch link; a pattern is untransformed)} edit a swatch in any colour group, as one undo step. Fills, strokes, text, gradient stops and tint swatches linked to a global swatch take its new colour (at their own tint) and name; turning Global off unlinks them (they keep their colour). Colour, mode and spot apply to solid colours only; a tint swatch only takes a new name (edit its base) → {name, relinked: paints changed}", has_doc, swatch_edit ), @@ -187,11 +187,7 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result { })), Paint::None => Err(bad(C, "a swatch needs a colour, gradient or pattern")), _ if spot || mode.is_some() || group.is_some() => Err(bad(C, "spot, mode and group apply to solid colours only")), - // The swatch holds the gradient itself, not a link to the swatch it may have come from. - // It keeps `geom`, which holds the aspect the swatch is applied with. - Paint::Gradient(g) => Ok(Paint::Gradient(Box::new(vectorcraft_color::GradientPaint { swatch: None, ..*g }))), - // The swatch holds the pattern untransformed, not the placement of the art it came from. - Paint::Pattern { pattern, .. } => Ok(pattern_paint(&pattern)), + paint @ (Paint::Gradient(_) | Paint::Pattern { .. }) => Ok(stored_paint(paint, p)), }) .collect::>>()?; // A name applies to a single new swatch. @@ -220,6 +216,27 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result { Ok(json!({ "name": names[0], "names": names })) } +/// A gradient or pattern `paint`, parsed from the paint params `p`, as a new or edited swatch +/// stores it. +fn stored_paint(paint: Paint, p: &Value) -> Paint { + match paint { + // A gradient given an `aspect` but no vector is placed on a box so that the swatch keeps + // the aspect, as `paint.setFill` places it on each object. + Paint::Gradient(ref g) if g.geom.is_none() && g.freeform.is_none() => { + match super::gradient::place_paint(&paint, p, Some(vectorcraft_geom::Rect::new(0.0, 0.0, 100.0, 100.0))) { + Paint::Gradient(g) => Paint::Gradient(Box::new(vectorcraft_color::GradientPaint { swatch: None, ..*g })), + other => other, + } + } + // The swatch holds the gradient itself, not a link to the swatch it may have come from. + // It keeps `geom`, which holds the aspect the swatch is applied with. + Paint::Gradient(g) => Paint::Gradient(Box::new(vectorcraft_color::GradientPaint { swatch: None, ..*g })), + // The swatch holds the pattern untransformed, not the placement of the art it came from. + Paint::Pattern { pattern, .. } => pattern_paint(&pattern), + other => other, + } +} + /// The `names` list and `name` parameters, without repeats; at least one is required. pub(super) fn names_param(p: &Value, cmd: &str) -> Result> { let mut seen: HashSet = HashSet::new(); @@ -866,12 +883,21 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result { } // A replacement paint of the swatch's kind (a dropped colour or edited gradient). let paint = match p.get("paint").filter(|v| !v.is_null()) { - Some(v) => match (if given("mode") { paint_from(s, &keeping_model(v))? } else { paint_from(s, v)? }, &sw.paint) { + Some(v) => match ( + // A pattern by name, as `swatch.new` takes it. + match str_param(v, "pattern") { + Some(pat) if d.pattern(pat).is_some() => Some(pattern_paint(pat)), + Some(pat) => return Err(bad(C, format!("no pattern `{pat}`"))), + None if given("mode") => paint_from(s, &keeping_model(v))?, + None => paint_from(s, v)?, + }, + &sw.paint, + ) { (Some(Paint::Solid { color, .. }), Paint::Solid { .. }) => Some(Paint::solid(color)), - (Some(Paint::Gradient(g)), Paint::Gradient(_)) => { - Some(Paint::Gradient(Box::new(vectorcraft_color::GradientPaint { geom: None, swatch: None, ..*g }))) + // Stored as `swatch.new` stores it. + (Some(new @ Paint::Gradient(_)), Paint::Gradient(_)) | (Some(new @ Paint::Pattern { .. }), Paint::Pattern { .. }) => { + Some(stored_paint(new, v)) } - (Some(new @ Paint::Pattern { .. }), Paint::Pattern { .. }) => Some(new), _ => return Err(bad(C, format!("`{name}` takes a paint of its own kind (colour, gradient or pattern)"))), }, None => None, diff --git a/crates/engine/src/tests/swatchlib.rs b/crates/engine/src/tests/swatchlib.rs index 445ce38ea7..dbf32eba7c 100644 --- a/crates/engine/src/tests/swatchlib.rs +++ b/crates/engine/src/tests/swatchlib.rs @@ -636,6 +636,114 @@ fn gradient_libraries_are_listed_and_gradient_swatches_rename_and_take_new_gradi assert_eq!(doc(&s).swatch("Red").unwrap().paint.color().map(|c| c.to_hex()), Some("#00ff00".into())); } +/// A radial gradient from red to blue, with `extra` keys. +fn radial_gradient(extra: Value) -> Value { + let mut g = json!({"kind": "radial", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}); + if let (Some(g), Some(extra)) = (g.as_object_mut(), extra.as_object()) { + g.extend(extra.clone()); + } + g +} + +/// The gradient swatch `name` keeps an aspect of 50%, holds no swatch link, and applies with that +/// aspect, fitted to the object it is applied to. +fn assert_swatch_applies_with_half_aspect(s: &mut Session, name: &str) { + let Paint::Gradient(sw) = &doc(s).swatch(name).unwrap().paint else { panic!("{name}: a gradient swatch") }; + assert!(sw.geom.is_some_and(|g| (g.aspect - 0.5).abs() < 1e-9), "{name} keeps the aspect: {:?}", sw.geom); + assert_eq!(sw.swatch, None, "{name} holds no link to the swatch its gradient came from"); + let r = run(s, "shape.rectangle", json!({"x": 100, "y": 0, "width": 100, "height": 60})); + let id = r["id"].as_u64().unwrap(); + run(s, "paint.setFill", json!({"ids": [id], "swatch": name})); + let fill = doc(s).node(vectorcraft_doc::NodeId(id)).unwrap().appearance.fill_paint(); + let Paint::Gradient(g) = fill else { panic!("{name}: a gradient fill") }; + let geom = g.geom.unwrap(); + assert!( + geom.start.distance(vectorcraft_geom::Point::new(150.0, 30.0)) < 1e-9 && (geom.aspect - 0.5).abs() < 1e-9, + "{name} fits the object with its aspect: {geom:?}" + ); +} + +/// An edited gradient with a vector and an aspect that replaces a gradient swatch keeps its aspect, +/// as a new swatch made from it does. +#[test] +fn a_gradient_swatch_takes_an_edited_gradient_with_its_aspect() { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "Edited", "gradient": radial_gradient(json!({}))})); + let placed = radial_gradient(json!({"start": [25, 25], "end": [50, 25], "aspect": 50})); + run(&mut s, "swatch.edit", json!({"name": "Edited", "paint": {"gradient": placed}})); + assert_swatch_applies_with_half_aspect(&mut s, "Edited"); +} + +/// A gradient swatch Alt-dropped on another gradient swatch gives it its gradient with its aspect. +#[test] +fn a_gradient_swatch_takes_a_dropped_swatch_with_its_aspect() { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "Dropped", "gradient": radial_gradient(json!({}))})); + let placed = radial_gradient(json!({"start": [25, 25], "end": [50, 25], "aspect": 50})); + run(&mut s, "swatch.new", json!({"name": "Oval", "gradient": placed})); + run(&mut s, "swatch.edit", json!({"name": "Dropped", "paint": {"swatch": "Oval"}})); + assert_swatch_applies_with_half_aspect(&mut s, "Dropped"); +} + +/// A gradient given an aspect but no vector keeps the aspect in a new swatch and in a replacement, +/// as `paint.setFill` keeps it on the object. +#[test] +fn a_gradient_without_a_vector_keeps_its_aspect_in_a_swatch() { + let mut s = session(); + let oval = radial_gradient(json!({"aspect": 50})); + run(&mut s, "swatch.new", json!({"name": "New", "gradient": oval})); + assert_swatch_applies_with_half_aspect(&mut s, "New"); + run(&mut s, "swatch.new", json!({"name": "Edited", "gradient": radial_gradient(json!({}))})); + run(&mut s, "swatch.edit", json!({"name": "Edited", "paint": {"gradient": oval}})); + assert_swatch_applies_with_half_aspect(&mut s, "Edited"); + // Without an aspect the swatch stays unplaced. + run(&mut s, "swatch.new", json!({"name": "Round", "gradient": radial_gradient(json!({}))})); + let Paint::Gradient(round) = &doc(&s).swatch("Round").unwrap().paint else { panic!("a gradient swatch") }; + assert_eq!(round.geom, None); +} + +/// A pattern that replaces a pattern swatch is stored untransformed, as a new swatch stores it, so +/// art the swatch is applied to keeps its own tile placement. +#[test] +fn a_pattern_swatch_takes_a_new_pattern_untransformed() { + let mut s = session(); + for (name, color) in [("Dots", "#ff0000"), ("Bars", "#0000ff")] { + let r = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})); + run(&mut s, "paint.setFill", json!({"ids": [r["id"]], "color": color})); + run(&mut s, "select.set", json!({"ids": [r["id"]]})); + run(&mut s, "object.pattern.make", json!({"name": name, "width": 20, "height": 20})); + run(&mut s, "object.pattern.done", json!({})); + } + // A pattern swatch holding a tile transform, as documents saved by older versions can. + let moved = vectorcraft_geom::Affine::new([2.0, 0.0, 0.0, 2.0, 15.0, 10.0]); + let st = s.doc_mut().unwrap(); + let Some(Paint::Pattern { xf, .. }) = std::sync::Arc::make_mut(&mut st.doc).swatch_mut("Bars").map(|w| &mut w.paint) else { + panic!("the Bars pattern swatch") + }; + *xf = moved; + run(&mut s, "swatch.edit", json!({"name": "Dots", "paint": {"swatch": "Bars"}})); + assert_eq!(doc(&s).swatch("Dots").unwrap().paint, vectorcraft_doc::pattern::pattern_paint("Bars")); +} + +/// A pattern swatch takes a pattern given by name, as an Alt-drop of a pattern fill sends it, and +/// an unknown pattern is an error that leaves the swatch as it was. +#[test] +fn a_pattern_swatch_takes_a_pattern_by_name() { + let mut s = session(); + for (name, color) in [("Dots", "#ff0000"), ("Bars", "#0000ff")] { + let r = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})); + run(&mut s, "paint.setFill", json!({"ids": [r["id"]], "color": color})); + run(&mut s, "select.set", json!({"ids": [r["id"]]})); + run(&mut s, "object.pattern.make", json!({"name": name, "width": 20, "height": 20})); + run(&mut s, "object.pattern.done", json!({})); + } + run(&mut s, "swatch.edit", json!({"name": "Dots", "paint": {"pattern": "Bars"}})); + assert_eq!(doc(&s).swatch("Dots").unwrap().paint, vectorcraft_doc::pattern::pattern_paint("Bars")); + let before = doc(&s).swatch("Dots").unwrap().paint.clone(); + assert!(s.execute("swatch.edit", &json!({"name": "Dots", "paint": {"pattern": "Nope"}})).is_err()); + assert_eq!(doc(&s).swatch("Dots").unwrap().paint, before); +} + #[test] fn organic_fades_add_with_their_method_midpoints_and_opacities_in_rgb_and_cmyk() { let lib = vectorcraft_color::libraries::builtin_library("organic-fades").unwrap(); diff --git a/docs/mcp.md b/docs/mcp.md index ca77311c58..fd6f17526c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -876,13 +876,15 @@ Errors (unknown tool, bad arguments, a disabled or failing command) come back as `isError: true` and a message. The model can read the message and retry. Swatches go through `run_command`: `swatch.list` lists every swatch with its colour group, kind and colour; -`swatch.edit {name, newName?, color?, mode?, global?, spot?}` edits one (fills, strokes and text linked to a +`swatch.edit {name, newName?, color?, mode?, global?, spot?, paint?}` edits one (fills, strokes and text linked to a global or spot swatch follow its colour and name, as one undo step, in the art, opacity masks, symbols, graph -designs, pattern tiles, graphic styles and the art of brushes); `swatch.delete {names, unlink?}` deletes +designs, pattern tiles, graphic styles and the art of brushes). A replacement colour, gradient or pattern given as +`paint` (`{color}`, `{gradient}`, `{swatch}` or `{pattern: name}`) is stored as `swatch.new` stores it; +`swatch.delete {names, unlink?}` deletes swatches and colour groups in one undo step (art using a deleted global swatch keeps its colour, unlinked). `swatch.new {color?, mode?, spot?, global?, group?}` saves a colour (into a colour group with `group`), gradient -or pattern (a gradient swatch holds no link to the swatch its gradient came from, and a pattern swatch holds the -pattern untransformed); `swatch.newGroup {fromArtwork: true, toGlobal?, includeTints?}` makes a colour group of the selected +or pattern (a gradient swatch keeps the gradient's aspect, with or without `start` and `end`, and holds no link to +the swatch its gradient came from, and a pattern swatch holds the pattern untransformed); `swatch.newGroup {fromArtwork: true, toGlobal?, includeTints?}` makes a colour group of the selected art's colours (by default as global swatches the art links to). `swatch.move {names, to?, group?}` reorders swatches or moves them into or out of a colour group (as dragging them in the Swatches panel does); give colour group names instead to reorder the groups. Dropping a swatch on art