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