Merge pull request #7591 from dotCooCoo/fix/swatch-edit-gradient-aspect

Swatches: a replacement gradient or pattern is stored the way a new swatch stores it
This commit is contained in:
BFlat
2026-11-14 21:50:09 +07:00
committed by GitHub
3 changed files with 151 additions and 15 deletions
+37 -11
View File
@@ -19,7 +19,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<CommandSpec> {
"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<Value> {
})),
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::<Result<Vec<_>>>()?;
// A name applies to a single new swatch.
@@ -220,6 +216,27 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result<Value> {
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<Vec<String>> {
let mut seen: HashSet<String> = HashSet::new();
@@ -866,12 +883,21 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result<Value> {
}
// 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,
+108
View File
@@ -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();
+6 -4
View File
@@ -892,13 +892,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