Merge pull request #7806 from dotCooCoo/fix/merging-a-spot-swatch-into-another-drops-the-old

Swatches: merging a swatch drops the process plates of the gradient stops linked to it
This commit is contained in:
BFlat
2026-11-15 17:30:20 +07:00
committed by GitHub
4 changed files with 100 additions and 4 deletions
+28 -3
View File
@@ -93,7 +93,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Merge Swatches",
["Window", "Swatches"],
None,
"{names: [two or more solid-colour swatches], name?: the merged swatch's name (one swatch kept)} keep the first (its name and colour) and delete the others; paints linked to them take its colour and link to it (or unlink when it isn't global), as one undo step → {name, merged: [deleted names], relinked}",
"{names: [two or more solid-colour swatches], name?: the merged swatch's name (one swatch kept)} keep the first (its name and colour) and delete the others; paints linked to them take its colour and link to it (or unlink when it isn't global), and gradient stops linked to them drop their process plates, as one undo step → {name, merged: [deleted names], relinked}",
has_doc,
swatch_merge
),
@@ -688,6 +688,11 @@ impl Relink {
/// batch, and a canceled batch preview, put back the paints the batch started with. A change is
/// recorded with the step just made, so Undo and Redo put these paints back with the document.
pub(super) fn map_default_paints(s: &mut Session, f: &mut LinkFn) -> Result<()> {
map_default_paints_by(s, &mut |p| p.map_links(f))
}
/// [`map_default_paints`] with `f` applied to each whole paint; `f` returns true when it changed it.
fn map_default_paints_by(s: &mut Session, f: &mut dyn FnMut(&mut Paint) -> bool) -> Result<()> {
if s.in_interaction() && s.batch_stash.is_none() {
return Ok(());
}
@@ -699,7 +704,7 @@ pub(super) fn map_default_paints(s: &mut Session, f: &mut LinkFn) -> Result<()>
});
let mut changed = false;
for p in [&mut pd.fill, &mut pd.stroke].into_iter().chain(template) {
changed |= p.map_links(f);
changed |= f(p);
}
if changed {
super::colormgmt::record_new_art_colors(s, start)?;
@@ -1124,17 +1129,37 @@ fn swatch_merge(s: &mut Session, p: &Value) -> Result<Value> {
*l = link.clone();
true
};
// A stop linked to a merged swatch loses its process plates, which printed with that ink.
let mut unplate = |p: &Paint| {
let mut q = p.clone();
drop_stop_plates(&mut q, &merged_names).then_some(q)
};
let colorized_ok = super::paint::check_colorized(d).is_ok();
let relinked = s.edit("Merge Swatches", |d, _| {
take_swatches(d, merged, |_| true);
map_all_paints(d, &mut unplate);
let relinked = map_swatch_links(d, &mut relink);
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))?;
Ok(relinked)
})?;
map_default_paints(s, &mut relink)?;
map_default_paints_by(s, &mut |p| {
let unplated = drop_stop_plates(p, &merged_names);
p.map_links(&mut relink) | unplated
})?;
Ok(json!({"name": keep, "merged": merged, "relinked": relinked}))
}
/// Clear the process plates of `p`'s gradient stops linked to a swatch in `names`. True when any
/// stop had plates.
fn drop_stop_plates(p: &mut Paint, names: &HashSet<&str>) -> bool {
let Paint::Gradient(g) = p else { return false };
g.gradient
.stops
.iter_mut()
.filter(|st| st.swatch.as_deref().is_some_and(|l| names.contains(l)))
.fold(false, |ch, st| st.plates.take().is_some() | ch)
}
fn swatch_ungroup(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "swatch.ungroup";
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?.to_string();
+69
View File
@@ -231,6 +231,75 @@ fn deleting_a_spot_swatch_takes_the_process_plates_of_the_stops_it_unlinks() {
assert_eq!(gradient(&s, id).gradient.stops[1], linked, "undo brings back the link and the plates");
}
/// The stops of the gradients the merge test puts in place: the art's fill, gradient swatch "Glow",
/// graphic style "Style", the art of brush "Brush" and the fill new art takes.
fn merge_test_stops(s: &Session, id: NodeId) -> Vec<(&'static str, Vec<GradientStop>)> {
let d = doc(s);
let stops = |p: Paint| match p {
Paint::Gradient(g) => g.gradient.stops,
p => panic!("not a gradient: {p:?}"),
};
let style = d.graphic_style("Style").unwrap().appearance.items.iter().find_map(|it| match it {
vectorcraft_doc::AppearanceItem::Fill(f) => Some(f.paint.clone()),
vectorcraft_doc::AppearanceItem::Stroke(_) => None,
});
let mut brush = None;
for n in vectorcraft_brush::find(d, "Brush").unwrap().art() {
n.walk(&mut |m| {
if brush.is_none() && matches!(m.appearance.fill_paint(), Paint::Gradient(_)) {
brush = Some(m.appearance.fill_paint());
}
});
}
vec![
("art", stops(d.node(id).unwrap().appearance.fill_paint())),
("gradient swatch", stops(d.swatch("Glow").unwrap().paint.clone())),
("graphic style", stops(style.unwrap())),
("brush art", stops(brush.unwrap())),
("new art", stops(s.paint.fill.clone())),
]
}
#[test]
fn merging_swatches_takes_the_process_plates_of_the_stops_it_relinks() {
// Kept swatch "Keep": spot, global process, and process that isn't global.
for (spot, global) in [(true, true), (false, true), (false, false)] {
let (mut s, id) = setup();
run(&mut s, "swatch.new", json!({"name": "Keep", "color": "#00ff00", "spot": spot, "global": global}));
run(&mut s, "swatch.new", json!({"name": "Other", "color": {"c": 1, "m": 0, "y": 0, "k": 0}, "spot": true}));
// Ink at 50 % over a 30 % cyan plate, and Other over its own 20 % magenta plate, which the
// merge leaves alone.
let plated = json!({"stops": [
{"offset": 0, "swatch": "Ink", "tint": 50, "plates": [0.3, 0, 0, 0]},
{"offset": 1, "swatch": "Other", "plates": [0, 0.2, 0, 0]},
]});
run(&mut s, "paint.editGradient", plated.clone());
run(&mut s, "swatch.new", json!({"name": "Glow", "gradient": plated}));
run(&mut s, "graphicStyle.new", json!({"name": "Style"}));
run(&mut s, "brush.new", json!({"type": "art", "name": "Brush"}));
s.paint.fill = doc(&s).node(id).unwrap().appearance.fill_paint();
let before = merge_test_stops(&s, id);
let kept = doc(&s).linked_color(doc(&s).swatch("Keep").unwrap().paint.color().unwrap(), spot);
assert!(before.iter().all(|(_, st)| st[0].plates == Some([0.3, 0.0, 0.0, 0.0])), "{before:?}");
run(&mut s, "swatch.merge", json!({"names": ["Keep", "Ink"]}));
let after = merge_test_stops(&s, id);
// The stop shows its tint of the kept color, unlinked when the kept swatch isn't global.
let (link, tint, color) = if global { (Some("Keep"), 0.5, kept.tinted(0.5)) } else { (None, 1.0, kept.tinted(0.5)) };
for (place, st) in &after {
let what = format!("spot {spot}, global {global}, {place}: {st:?}");
assert_eq!((st[0].swatch.as_deref(), st[0].tint, st[0].plates), (link, tint, None), "{what}");
assert_eq!(st[0].color, color, "{what}");
assert_eq!((st[1].swatch.as_deref(), st[1].plates), (Some("Other"), Some([0.0, 0.2, 0.0, 0.0])), "{what}");
}
run(&mut s, "edit.undo", json!({}));
assert_eq!(merge_test_stops(&s, id), before, "undo brings back the links and the plates");
run(&mut s, "edit.redo", json!({}));
assert_eq!(merge_test_stops(&s, id), after, "redo merges again");
}
}
#[test]
fn radial_spot_gradients_mixed_with_other_inks_export_as_device_n_shadings() {
let (mut s, _) = setup();
+2 -1
View File
@@ -964,7 +964,8 @@ them in the Swatches panel does); give colour group names instead to reorder the
in the app runs `paint.setFill` (or `paint.setStroke`, the active proxy) with `ids: [the object under the pointer]`.
The Swatches panel menu's commands: `swatch.addUsedColors {selection?, global?}`, `swatch.unused` (a query: the
names Select All Unused selects; a spot swatch counts as used while an image placed from a DCS file has a plate of
its ink), `swatch.merge {names}` (the first is kept), `swatch.ungroup {name}` and
its ink), `swatch.merge {names}` (the first is kept; gradient stops linked to the others drop their process
plates), `swatch.ungroup {name}` and
`swatch.sortByKind`.
`brush.move {name | names, to?, type?}` reorders brushes among the brushes of their type, as dragging one onto
another of its type in the Brushes panel does. `to` is an index among the brushes of that type in `brush.list`
+1
View File
@@ -1637,6 +1637,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Stroke visibility (#1253):** adding paint to a previously unpainted zero-width stroke restores the existing 1 pt default (`PaintDefaults::default`, `Appearance::set_stroke`), for the selected appearance item, the new-art defaults and Swap Fill/Stroke. Positive widths are kept; recoloring a painted zero-width stroke keeps its explicit zero. A real Eyedropper-to-new-shape sequence reproduces the zero-width inheritance. Five engine regressions cover creation, defaults, targeted items, preserved widths and one-step Undo/Redo (`crates/engine/tests/it/stroke_visibility.rs`).
- **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles, both ends of a vector drawn on the art and the start of a clicked one snap as other drawn points do (path segments, construction guides and View › Snap to Glyph's guides included) and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh.
A gradient stop that loses its link to a spot swatch (the swatch deleted, a recolor or adjustment that unlinks it, a library gradient, or a library character style linked to a swatch the document lacks) keeps the color it shows at full tint and drops the process plates it printed with that ink, as a stop given a plain color does; this is VectorCraft's own rule. Evidence: `tests::mapping_links_drops_the_process_plates_of_unlinked_stops` in `crates/color`, `tests::linked_stops::deleting_a_spot_swatch_takes_the_process_plates_of_the_stops_it_unlinks` and `tests::library::library_stops_unlinked_from_their_swatch_keep_no_process_plates` in `crates/engine`.
Merge Swatches (`swatch.merge`) also drops the process plates of the gradient stops linked to a swatch it merges, whether the kept swatch is spot, global process or process that isn't global, in the art, gradient swatches, graphic styles, the art of brushes and the fill and stroke for new art; a stop linked to another swatch keeps its plates, and Undo puts the links and plates back. This is VectorCraft's own rule. Evidence: `tests::linked_stops::merging_swatches_takes_the_process_plates_of_the_stops_it_relinks` in `crates/engine`.
- The Gradient panel's Delete Stop sits at the right end of the ramp, level with it, so a panel too short to show the stop fields still shows it; it deletes the selected stop (never below two), as dragging a stop down off the ramp does (#5403).
- Conic gradients, VectorCraft's own gradient type: the stops sweep once around the center from the vector, clockwise on the page (a negative aspect, or a reflection, turns the sweep the other way), with a Conic button in the Gradient panel's Type row and the Gradient tool's extent ellipse and aspect handle; they follow transforms, Expand into wedges or a mesh, open from `.af` files, and export to PDF as a function-based shading that PDF import reads back as a conic gradient; SVG, EPS and legacy `.ai` files get solid wedges, and CSS a `conic-gradient`.
- Diamond gradients, VectorCraft's own gradient type: the stops run from the center out to a square with a corner at the end of the vector (the aspect stretches it into a rhombus), with a Diamond button in the Gradient panel's Type row and the Gradient tool's square and aspect handle; they follow transforms, Expand into nested squares or a mesh, and export to PDF, SVG, EPS and legacy `.ai` as four linear gradients, one for each quarter of the square (an image in PDF and SVG for translucent stops, a dithered fill, the Perceptual method other than spot color shadings in PDF, or a square with no area), and on type characters in SVG as a radial gradient; PDF import reads a function-based shading whose color is the same along each square about its origin as a diamond gradient.