diff --git a/crates/engine/src/cmd/buildcmds/trace.rs b/crates/engine/src/cmd/buildcmds/trace.rs index c1e66d27c..2eaed8861 100644 --- a/crates/engine/src/cmd/buildcmds/trace.rs +++ b/crates/engine/src/cmd/buildcmds/trace.rs @@ -1,3 +1,5 @@ +use vectorcraft_color::Swatch; + use super::{ Appearance, AppearanceItem, Arc, Beside, Color, Document, EngineError, LineCap, LineJoin, LiveShape, Node, NodeId, NodeKind, Paint, Point, Result, Session, StrokeLayer, TRACE_NAME, TraceView, Value, bad, fill_only, ids_json, json, root_ids, shape_node, str_param, switch_or, tr, @@ -58,41 +60,59 @@ fn trace_params(c: &str, p: &Value) -> Result { Ok(params) } +/// Document swatch `w`'s solid color with the paint a trace fills with it: a global or spot color, +/// or a tint of one, linked to its swatch as `swatch_solid` (paint.rs) links it; any other color +/// unlinked. The color is the one the paint shows (a Lab spot color's CMYK equivalent when the Spot +/// Colors options use CMYK values). +fn trace_swatch_paint(d: &Document, w: &Swatch) -> Option<(Color, Paint)> { + let linked = d.swatch_link(&w.name).and_then(|(base, tint)| d.tint_paint(&base, tint)); + let color = linked.as_ref().and_then(Paint::color).or_else(|| w.paint.color())?; + Some((color, linked.unwrap_or_else(|| Paint::solid(color)))) +} + /// Document Library with `colorGroup`: the solid colors of the active document's color group -/// `name` (so named exactly, else ignoring case). Errors name command `c`. -/// ([`trace_params`] has checked that the library is the document's swatches.) -fn trace_group_colors(c: &str, s: &Session, name: &str) -> Result> { - let groups = &s.doc()?.doc.swatch_groups; +/// `name` (so named exactly, else ignoring case), each with its paint ([`trace_swatch_paint`]). +/// Errors name command `c`. ([`trace_params`] has checked that the library is the document's +/// swatches.) +fn trace_group_colors(c: &str, s: &Session, name: &str) -> Result> { + let d = &s.doc()?.doc; + let groups = &d.swatch_groups; let lower = name.to_lowercase(); let group = groups .iter() .find(|g| g.name == name) .or_else(|| groups.iter().find(|g| g.name.to_lowercase() == lower)) .ok_or_else(|| bad(c, format!("no color group `{name}` in the document (see swatch.list)")))?; - Ok(group.swatches.iter().filter_map(|w| w.paint.color()).collect()) + Ok(group.swatches.iter().filter_map(|w| trace_swatch_paint(d, w)).collect()) } /// Document Library in Color mode: look the library's colors (or its color group's) up into -/// `params.swatches` → each one as traced (sRGB) with the color to fill with. Errors name command `c`. -fn trace_library(c: &str, s: &Session, params: &mut tr::TraceParams) -> Result> { +/// `params.swatches` → each one as traced (sRGB) with the paint to fill with. The document's +/// global and spot colors fill linked to their swatches ([`trace_swatch_paint`]); a swatch +/// library's colors fill unlinked. Errors name command `c`. +fn trace_library(c: &str, s: &Session, params: &mut tr::TraceParams) -> Result> { if params.mode != tr::Mode::Color || params.palette != tr::Palette::DocumentLibrary { return Ok(vec![]); } let group = params.color_group.as_deref().filter(|g| !g.is_empty()); let key = group.unwrap_or(params.library.as_str()); - let colors = match group { + let missing = || bad(c, format!("no swatch library `{key}` (see swatch.library.list; \"document\": the document's swatches)")); + let colors: Vec<(Color, Paint)> = match group { Some(name) => trace_group_colors(c, s, name)?, - None => crate::cmd::swatchlib::library_colors(s, key) - .ok_or_else(|| bad(c, format!("no swatch library `{key}` (see swatch.library.list; \"document\": the document's swatches)")))?, + None if key == crate::cmd::swatchlib::DOCUMENT_SWATCHES => { + let d = &s.active().ok_or_else(missing)?.doc; + d.swatches_iter().filter_map(|w| trace_swatch_paint(d, w)).collect() + } + None => crate::cmd::swatchlib::library_colors(s, key).ok_or_else(missing)?.into_iter().map(|c| (c, Paint::solid(c))).collect(), }; if colors.is_empty() { return Err(bad(c, format!("`{key}` has no colours"))); } - let library: Vec<([u8; 3], Color)> = colors + let library: Vec<([u8; 3], Paint)> = colors .into_iter() - .map(|c| { + .map(|(c, paint)| { let [r, g, b, _] = c.to_rgba8(1.0); - ([r, g, b], c) + ([r, g, b], paint) }) .collect(); params.swatches = library.iter().map(|l| l.0).collect(); @@ -260,10 +280,14 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result Appearance { diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index 75a0a9ea8..6cbb63119 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -3047,6 +3047,124 @@ fn image_trace_document_library_uses_a_color_group() { trace(&mut s, json!({"mode": "grayscale", "colorGroup": ""})).unwrap(); } +#[test] +fn image_trace_document_library_links_fills_to_global_swatches() { + let mut s = session(); + // A global color, a tint of another global color and a process color. + s.execute("swatch.new", &json!({"name": "Ink", "color": "#c82828", "global": true})).unwrap(); + s.execute("swatch.new", &json!({"name": "Sky", "color": "#2846c8", "global": true})).unwrap(); + s.execute("swatch.new", &json!({"swatch": "Sky", "tint": 50})).unwrap(); + s.execute("swatch.new", &json!({"name": "Leaf", "color": "#28a03c"})).unwrap(); + let hex_of = |s: &mut Session, name: &str| { + let list = s.execute("swatch.list", &json!({})).unwrap(); + list["swatches"].as_array().unwrap().iter().find(|w| w["name"] == name).unwrap()["hex"].as_str().unwrap().to_lowercase() + }; + let tint = hex_of(&mut s, "Sky 50%"); + let [tr, tg, tb] = [1, 3, 5].map(|i| u8::from_str_radix(&tint[i..i + 2], 16).unwrap()); + let r = vectorcraft_trace::Raster::from_fn(60, 60, |x, y| match (x < 30, y < 30) { + (true, _) => [200, 40, 40, 255], + (false, true) => [40, 160, 60, 255], + (false, false) => [tr, tg, tb, 255], + }); + let img = add_image(&mut s, &r, Affine::IDENTITY); + let params = json!({"mode": "color", "palette": "documentLibrary", "library": "document", "colors": 3}); + // Each fill as (its color, the swatch it links to, its tint), one per color. + let fills = |s: &Session, id: &Value| { + let mut v: Vec<(String, Option, f32)> = node(s, NodeId(id.as_u64().unwrap())) + .children() + .unwrap() + .iter() + .filter_map(|n| match n.appearance.fill_paint() { + Paint::Solid { color, swatch, tint } => { + let [r, g, b, _] = color.to_rgba8(1.0); + Some((format!("#{r:02x}{g:02x}{b:02x}"), swatch, tint)) + } + _ => None, + }) + .collect(); + v.sort_by(|a, b| a.0.cmp(&b.0)); + v.dedup(); + v + }; + let fill = |hex: &str, swatch: Option<&str>, tint: f32| (hex.to_string(), swatch.map(String::from), tint); + let res = s.execute("imageTrace.makeAndExpand", &json!({"params": params})).unwrap(); + let got = fills(&s, &res["id"]); + assert_eq!(got.len(), 3, "{got:?}"); + assert!(got.contains(&fill("#c82828", Some("Ink"), 1.0)), "{got:?}"); + assert!(got.contains(&fill(&tint, Some("Sky"), 0.5)), "{got:?}"); + assert!(got.contains(&fill("#28a03c", None, 1.0)), "{got:?}"); + // Editing the global swatches recolors the trace: the tint at its own tint. + s.execute("swatch.edit", &json!({"name": "Ink", "color": "#ff8800"})).unwrap(); + s.execute("swatch.edit", &json!({"name": "Sky", "color": "#000000"})).unwrap(); + let tint = hex_of(&mut s, "Sky 50%"); + let got = fills(&s, &res["id"]); + assert!(got.contains(&fill("#ff8800", Some("Ink"), 1.0)), "{got:?}"); + assert!(got.contains(&fill(&tint, Some("Sky"), 0.5)), "{got:?}"); + assert!(got.contains(&fill("#28a03c", None, 1.0)), "{got:?}"); + for _ in 0..3 { + s.execute("edit.undo", &json!({})).unwrap(); + } + // A color group's global swatches link too. + s.execute("swatch.newGroup", &json!({"name": "Brand", "swatches": ["Ink", "Leaf"]})).unwrap(); + let mut group = params.clone(); + group["colorGroup"] = json!("Brand"); + let res = s.execute("imageTrace.makeAndExpand", &json!({"id": img.0, "params": group})).unwrap(); + let got = fills(&s, &res["id"]); + assert!(got.contains(&fill("#c82828", Some("Ink"), 1.0)) && got.contains(&fill("#28a03c", None, 1.0)), "{got:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + // A swatch library's colors stay unlinked. + let mut web = params.clone(); + web["library"] = json!("web-safe-216"); + let res = s.execute("imageTrace.makeAndExpand", &json!({"id": img.0, "params": web})).unwrap(); + let got = fills(&s, &res["id"]); + assert!(!got.is_empty() && got.iter().all(|f| f.1.is_none()), "{got:?}"); +} + +#[test] +fn image_trace_document_library_matches_a_lab_spot_by_its_cmyk_equivalent() { + let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let mut s = session(); + // A Lab color outside the CMYK gamut, whose CMYK equivalent shows another color. + let rgb = |c: Color| { + let [r, g, b, _] = c.to_rgba8(1.0); + [r, g, b] + }; + let cms = vectorcraft_color::cms::active(); + let off = |lab: Color| { + let cmyk = cms.convert(&lab, vectorcraft_color::cms::Model::Cmyk, cms.settings().intent); + rgb(lab).iter().zip(rgb(cmyk)).map(|(a, b)| u32::from(a.abs_diff(b))).sum::() + }; + let lab = [(30.0, 60.0, -100.0), (60.0, -80.0, 60.0), (55.0, 60.0, 40.0), (90.0, -40.0, 100.0)] + .map(|(l, a, b)| Color::Lab { l, a, b }) + .into_iter() + .max_by_key(|c| off(*c)) + .unwrap(); + assert!(off(lab) >= 30, "{lab:?} is off by {}", off(lab)); + let Color::Lab { l, a, b } = lab else { panic!("{lab:?}") }; + // A spot color of it shown by its CMYK equivalent (Spot Colors options with CMYK values). + s.execute("swatch.new", &json!({"name": "Lab Ink", "color": {"l": l, "a": a, "b": b}, "spot": true})).unwrap(); + s.execute("swatch.spotOptions", &json!({"useLab": false})).unwrap(); + let shown = s.doc().unwrap().doc.global_color("Lab Ink").unwrap(); + let [kr, kg, kb] = rgb(shown); + let [lr, lg, lb] = rgb(lab); + // A process color between the two, nearer the color the spot shows. + let near = |k: u8, l: u8| (f32::from(k) + (f32::from(l) - f32::from(k)) * 0.3).round() as u8; + let [pr, pg, pb] = [near(kr, lr), near(kg, lg), near(kb, lb)]; + s.execute("swatch.new", &json!({"name": "Near", "color": format!("#{pr:02x}{pg:02x}{pb:02x}")})).unwrap(); + let process = Color::rgb8(pr, pg, pb); + s.execute("swatch.new", &json!({"name": "Leaf", "color": "#28a03c"})).unwrap(); + // Art in the color the spot shows traces to the spot. + let r = vectorcraft_trace::Raster::from_fn(60, 60, |x, _| if x < 30 { [kr, kg, kb, 255] } else { [40, 160, 60, 255] }); + add_image(&mut s, &r, Affine::IDENTITY); + let params = json!({"mode": "color", "palette": "documentLibrary", "library": "document", "colors": 3}); + let res = s.execute("imageTrace.makeAndExpand", &json!({"params": params})).unwrap(); + let g = node(&s, NodeId(res["id"].as_u64().unwrap())); + let got: Vec = g.children().unwrap().iter().map(|n| n.appearance.fill_paint()).collect(); + let spot = Paint::Solid { color: shown, swatch: Some("Lab Ink".into()), tint: 1.0 }; + assert!(got.contains(&spot), "{got:?}"); + assert!(!got.iter().any(|p| p.color() == Some(process)), "{got:?}"); +} + #[test] fn image_trace_create_strokes_makes_stroked_centre_lines() { let mut s = session(); diff --git a/docs/image-trace-adjustments.md b/docs/image-trace-adjustments.md index 4dc6199c4..0c2b1ae3b 100644 --- a/docs/image-trace-adjustments.md +++ b/docs/image-trace-adjustments.md @@ -46,6 +46,11 @@ most `colors` of its colors are used. A missing group and a group with no solid mode, palette or library in the panel clears the group. A group the active document does not have shows as All and traces as All. +**Document Library linked colors.** With Library › Document Swatches, with or without a color group, +a traced fill or stroke whose color comes from a global or spot swatch is linked to that swatch, and +one whose color comes from a tint swatch is linked to its base at the tint. Editing the swatch +recolors the trace. Colors from process swatches and from swatch libraries are not linked. + **Document Library** traces with the library's colors as they are. Each pixel takes the library color nearest to it in lightness and hue, with a difference in hue or chroma counted three times as much as one in lightness, so a gray or a muted brown takes a black, white or other neutral diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9f9a97dfb..2e0b27418 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -835,6 +835,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Disable, Enable, Unlink and Link Opacity Mask and Opacity Mask Options (`transparency.setOpacityMask`) record no undo step when every mask they act on already has the flags they set. - **Image Trace, thin closed outlines:** generated `fit::snap_area_tests` verify nonzero interiors after straightening, opposite closed-boundary winding and Noise controls; see [quality notes](image-trace-quality.md#thin-closed-outlines-with-snap-curves-to-lines). Scores and hours unchanged; open-piece fitting is unchanged. - **Image Trace, Document Library color groups:** with Document Swatches, the panel's Colors menu and `"colorGroup"` trace with one color group's colors instead of every swatch, as the public panel documentation describes Document Library; code-drawn tests in `crates/engine` and the panel. +- **Image Trace, Document Library linked colors:** with Document Swatches, traced fills and strokes from global and spot swatches (and tints of them) are linked to their swatches, so editing a swatch recolors the trace; process colors and swatch-library colors stay unlinked; a code-drawn test in `crates/engine`. - **Image Trace, flattened slivers:** in a Color, Grayscale or Black and White trace with Fills, a traced region whose outline straightens onto one line (a single pixel, or a one-pixel row along an edge, its pieces shared with the regions beside it) is left out instead of becoming a selectable path with no area, which paints nothing. Snap Curves To Lines straightens more outlines, so it leaves out more of these. Without Fills, the region's 1 px stroke draws it, and it is kept. Code-drawn tests in `crates/trace`. - **Image Trace, Auto Color rare colours:** the Automatic palette keeps colours too rare for its area count when they are distinct (Oklab) and drawn as coherent regions, at most six, protected from mixed-colour merging and Noise; code-drawn stem, highlight, low-contrast and scattered-noise tests (see [quality notes](image-trace-quality.md#auto-color-keeps-rare-colours)). - **Image Trace, Document Library keeps neutral tones neutral:** each pixel takes the library color nearest in Oklab with differences in hue and chroma counted three times, so a gray or a muted brown traces as a neutral library color instead of a saturated one of similar lightness; a code-drawn test of gray and brown patches with a black, light and blue library in `crates/trace`.