From 50bf521d455e2e8e8abdb8ac43f61a67783bc237 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 04:38:29 -0800 Subject: [PATCH] Swatches: a new swatch holds no link or placement from the paint it is made from swatch.new stored a gradient or pattern as it was given. A gradient swatch made from a gradient swatch by name, from a fill linked to one, or from a gradient that names one (the Gradient panel's add to swatches) kept the link to that swatch, so copying art that uses the new swatch brought the other swatch along too. A pattern swatch made from a current fill picked up from art with transformed tiles kept that transform, and art the swatch was applied to took it in place of its own. The new swatch now drops the gradient's link and stores the pattern untransformed. A gradient swatch keeps its aspect as before. --- crates/engine/src/cmd/swatch.rs | 8 +++-- crates/engine/src/tests/clipboard.rs | 48 ++++++++++++++++++++++++++++ crates/engine/src/tests/pattern.rs | 26 +++++++++++++++ docs/mcp.md | 3 +- 4 files changed, 82 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index f19861685..2421441b9 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. 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 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 ), @@ -187,7 +187,11 @@ 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")), - other => Ok(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) => 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)), }) .collect::>>()?; // A name applies to a single new swatch. diff --git a/crates/engine/src/tests/clipboard.rs b/crates/engine/src/tests/clipboard.rs index 79b336df0..24d6d387d 100644 --- a/crates/engine/src/tests/clipboard.rs +++ b/crates/engine/src/tests/clipboard.rs @@ -278,6 +278,54 @@ fn gradient_swatches_come_along_with_their_stop_links() { assert_eq!(sw.gradient, g.gradient, "the added gradient swatch links its stop to the added Sky too"); } +/// A gradient swatch made from another one (by name, from a fill linked to it, or from a gradient +/// that names it, placed or not) holds the gradient alone, so copying art that uses it brings only +/// that swatch. A placed gradient keeps its aspect, and the swatch still fits each object it is +/// applied to. +#[test] +fn a_gradient_swatch_made_from_another_holds_no_link_to_it() { + let mut s = session(); + let glow = json!({"kind": "radial", "stops": [{"offset": 0, "color": "#3080ff"}, {"offset": 1, "color": "#ffffff"}]}); + s.execute("swatch.new", &json!({"name": "Glow", "gradient": glow})).unwrap(); + let r = rect(&mut s, 0.0, 0.0, 50.0, 50.0); + s.execute("paint.setFill", &json!({"ids": [r.0], "swatch": "Glow"})).unwrap(); + let Paint::Gradient(applied) = fill(&s, r) else { panic!("a gradient fill") }; + assert_eq!(applied.swatch.as_deref(), Some("Glow")); + let mut by_gradient = vectorcraft_tools::params::gradient_params(&applied); + by_gradient["swatch"] = json!("Glow"); + let mut placed = by_gradient.clone(); + placed["start"] = json!([25, 25]); + placed["end"] = json!([50, 25]); + placed["aspect"] = json!(50); + let made = [ + ("By Name", json!({"name": "By Name", "swatch": "Glow"})), + ("From Fill", json!({"name": "From Fill"})), + ("From Gradient", json!({"name": "From Gradient", "gradient": by_gradient})), + ("Placed", json!({"name": "Placed", "gradient": placed})), + ]; + let mut links = vec![]; + for (name, p) in &made { + s.execute("swatch.new", p).unwrap(); + let Paint::Gradient(sw) = doc(&s).swatch(name).unwrap().paint.clone() else { panic!("{name}: a gradient swatch") }; + assert_eq!(sw.gradient, applied.gradient, "{name}: Glow's gradient"); + links.push(sw.swatch.clone()); + } + assert_eq!(links, [None, None, None, None], "no swatch links to Glow"); + let Paint::Gradient(sw) = doc(&s).swatch("Placed").unwrap().paint.clone() else { panic!("a gradient swatch") }; + assert!(sw.geom.is_some_and(|g| (g.aspect - 0.5).abs() < 1e-9), "the placed gradient keeps its aspect: {:?}", sw.geom); + for (name, _) in &made { + let t = rect(&mut s, 100.0, 0.0, 100.0, 60.0); + s.execute("paint.setFill", &json!({"ids": [t.0], "swatch": name})).unwrap(); + if *name == "Placed" { + let Paint::Gradient(g) = fill(&s, t) else { panic!("a gradient fill") }; + let geom = g.geom.unwrap(); + assert!(geom.start.distance(Point::new(150.0, 30.0)) < 1e-9 && (geom.aspect - 0.5).abs() < 1e-9, "{geom:?}"); + } + copy(&mut s, &[t]); + assert_eq!(s.clipboard.swatches.iter().map(|w| w.name.as_str()).collect::>(), [*name], "Glow stays behind"); + } +} + #[test] fn lab_spot_colours_show_as_the_target_documents_spot_option_says() { let mut s = session(); diff --git a/crates/engine/src/tests/pattern.rs b/crates/engine/src/tests/pattern.rs index 544dcce96..c36d84fd9 100644 --- a/crates/engine/src/tests/pattern.rs +++ b/crates/engine/src/tests/pattern.rs @@ -979,6 +979,32 @@ fn picking_another_pattern_swatch_keeps_the_pattern_transform() { assert_eq!(xf.as_coeffs(), [2.0, 0.0, 0.0, 2.0, 15.0, 10.0]); } +/// A pattern swatch made from the current fill holds the pattern untransformed: a fill picked up +/// from art whose tiles were moved does not carry that placement to the art the swatch is applied +/// to, which keeps its own (#1970). +#[test] +fn a_pattern_swatch_made_from_a_transformed_fill_holds_the_pattern_untransformed() { + let mut s = session(); + make_dots(&mut s); + let src = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + s.execute("paint.setFill", &json!({"ids": [src.0], "swatch": "Dots"})).unwrap(); + s.execute("pattern.transform", &json!({"ids": [src.0], "matrix": [2, 0, 0, 2, 15, 10]})).unwrap(); + sel(&mut s, &[]); + s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap(); + let Paint::Pattern { xf, .. } = &s.paint.fill else { panic!("the picked-up pattern fill") }; + assert_eq!(xf.as_coeffs(), [2.0, 0.0, 0.0, 2.0, 15.0, 10.0], "the current fill has the tiles' placement"); + s.execute("swatch.new", &json!({"name": "Picked"})).unwrap(); + let Paint::Pattern { pattern, xf } = doc(&s).swatch("Picked").unwrap().paint.clone() else { panic!("a pattern swatch") }; + assert_eq!((pattern.as_str(), xf.as_coeffs()), ("Dots", [1.0, 0.0, 0.0, 1.0, 0.0, 0.0])); + let dst = rect(&mut s, 300.0, 100.0, 50.0, 50.0); + s.execute("paint.setFill", &json!({"ids": [dst.0], "color": "#00ff00"})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [dst.0], "swatch": "Dots"})).unwrap(); + s.execute("pattern.transform", &json!({"ids": [dst.0], "matrix": [3, 0, 0, 3, 0, 0]})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [dst.0], "swatch": "Picked"})).unwrap(); + let Paint::Pattern { xf, .. } = node(&s, dst).appearance.fill_paint() else { panic!("a pattern fill") }; + assert_eq!(xf.as_coeffs(), [3.0, 0.0, 0.0, 3.0, 0.0, 0.0], "the art keeps its own placement"); +} + #[test] fn inner_glow_on_a_pattern_fill_stays_inside_the_object_on_the_threaded_renderer() { // #2426: the glow's pixmap was drawn with the pattern's paint transform, so it landed diff --git a/docs/mcp.md b/docs/mcp.md index c5fb6926b..3f62f7b95 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -597,7 +597,8 @@ global or spot swatch follow its colour and name, as one undo step, in the art, designs, pattern tiles, graphic styles and the art of brushes); `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; `swatch.newGroup {fromArtwork: true, toGlobal?, includeTints?}` makes a colour group of the selected +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 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