diff --git a/crates/brush/src/colorize.rs b/crates/brush/src/colorize.rs index d8ba2af1..e563fbc2 100644 --- a/crates/brush/src/colorize.rs +++ b/crates/brush/src/colorize.rs @@ -37,9 +37,8 @@ fn map_color(c: &Color, mode: Colorization, key: &Color) -> Color { } fn map_paint(p: &mut Paint, mode: Colorization, key: &Color) { - if let Paint::Solid { color, swatch } = p { - *color = map_color(color, mode, key); - *swatch = None; + if let Paint::Solid { color, .. } = p { + *p = Paint::solid(map_color(color, mode, key)); } } @@ -83,9 +82,8 @@ pub fn tint_node(n: &mut Node, color: &Color, amount: f32) { AppearanceItem::Fill(f) => &mut f.paint, AppearanceItem::Stroke(s) => &mut s.paint, }; - if let Paint::Solid { color: c, swatch } = p { - *c = c.lerp(color, amount); - *swatch = None; + if let Paint::Solid { color: c, .. } = p { + *p = Paint::solid(c.lerp(color, amount)); } } if let vectorcraft_doc::NodeKind::Text(t) = &mut n.kind { diff --git a/crates/color/src/freeform.rs b/crates/color/src/freeform.rs index 96ea9c39..4b54e8b8 100644 --- a/crates/color/src/freeform.rs +++ b/crates/color/src/freeform.rs @@ -144,7 +144,7 @@ impl Freeform { /// swatch chips, exports without freeform shading and re-seeding on other art use. pub fn stops(&self) -> Vec { let n = self.points.len(); - let stop = |offset: f32, p: &FreeformPoint| GradientStop { offset, color: p.color, opacity: p.opacity, midpoint: 0.5 }; + let stop = |offset: f32, p: &FreeformPoint| GradientStop { opacity: p.opacity, ..GradientStop::new(offset, p.color) }; match n { 0 => Gradient::default().stops, 1 => vec![stop(0.0, &self.points[0]), stop(1.0, &self.points[0])], diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index 57921796..d7c96ff3 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -31,7 +31,7 @@ impl GradientKind { } } -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct GradientStop { /// 0..=1 along the gradient. pub offset: f32, @@ -41,6 +41,27 @@ pub struct GradientStop { /// Midpoint to the next stop, 0.13..=0.87 (Illustrator's diamond), default 0.5. #[serde(default = "half")] pub midpoint: f32, + /// The global or spot swatch this stop's colour is linked to (edits to the swatch recolour it). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub swatch: Option, + /// Tint of the linked swatch (0..1; 1 without a link), as on a solid paint. + #[serde(default = "crate::full_tint", skip_serializing_if = "crate::is_full_tint")] + pub tint: f32, +} + +impl GradientStop { + /// An opaque, unlinked stop of `color` at `offset` with a centred midpoint. + pub fn new(offset: f32, color: Color) -> Self { + Self { offset, color, opacity: 1.0, midpoint: 0.5, swatch: None, tint: 1.0 } + } + /// Give the stop `color`, linked to `link` (a global swatch and tint) or unlinked. + pub fn set_color(&mut self, color: Color, link: Option<(String, f32)>) { + self.color = color; + (self.swatch, self.tint) = match link { + Some((n, t)) => (Some(n), t), + None => (None, 1.0), + }; + } } fn one() -> f32 { @@ -59,13 +80,7 @@ pub struct Gradient { impl Default for Gradient { /// Illustrator's default "White, Black" gradient. fn default() -> Self { - Self { - kind: GradientKind::Linear, - stops: vec![ - GradientStop { offset: 0.0, color: Color::WHITE, opacity: 1.0, midpoint: 0.5 }, - GradientStop { offset: 1.0, color: Color::BLACK, opacity: 1.0, midpoint: 0.5 }, - ], - } + Self { kind: GradientKind::Linear, stops: vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)] } } } @@ -137,7 +152,7 @@ pub fn insert_stop(g: &Gradient, offset: f32) -> (Vec, usize) { let offset = offset.clamp(0.0, 1.0); let (color, opacity) = g.sample(offset); let mut stops = g.stops.clone(); - let i = place_stop(&mut stops, GradientStop { offset, color, opacity, midpoint: 0.5 }); + let i = place_stop(&mut stops, GradientStop { opacity, ..GradientStop::new(offset, color) }); (stops, i) } @@ -166,19 +181,22 @@ pub fn move_stop(stops: &[GradientStop], i: usize, offset: f32) -> (Vec (Vec, usize) { let mut v = stops.to_vec(); - let Some(&s) = v.get(i) else { return (v, i) }; + let Some(s) = v.get(i).cloned() else { return (v, i) }; let ni = place_stop(&mut v, GradientStop { offset: offset.clamp(0.0, 1.0), ..s }); (v, ni) } -/// Swap the colours of stops `a` and `b` (Alt-dropping one stop on another); offsets, opacities -/// and midpoints stay. Out-of-range indices leave the stops unchanged. +/// Swap the colours of stops `a` and `b` (Alt-dropping one stop on another), with their swatch +/// links and tints; offsets, opacities and midpoints stay. Out-of-range indices leave the stops +/// unchanged. pub fn swap_stop_colors(stops: &[GradientStop], a: usize, b: usize) -> Vec { let mut v = stops.to_vec(); - if a < v.len() && b < v.len() { - let c = v[a].color; - v[a].color = v[b].color; - v[b].color = c; + if a < v.len() && b < v.len() && a != b { + let (lo, hi) = v.split_at_mut(a.max(b)); + let (x, y) = (&mut lo[a.min(b)], &mut hi[0]); + std::mem::swap(&mut x.color, &mut y.color); + std::mem::swap(&mut x.swatch, &mut y.swatch); + std::mem::swap(&mut x.tint, &mut y.tint); } v } @@ -517,7 +535,7 @@ mod tests { fn g3() -> Gradient { let mut g = Gradient::default(); - g.stops.insert(1, GradientStop { offset: 0.5, color: Color::rgb(1.0, 0.0, 0.0), opacity: 1.0, midpoint: 0.5 }); + g.stops.insert(1, GradientStop::new(0.5, Color::rgb(1.0, 0.0, 0.0))); g } diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index e84a9650..f4b5090f 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -240,6 +240,22 @@ impl Color { } } } + /// Tint `t` (0..1) of this colour, the colour a tint of a global or spot swatch shows: CMYK and + /// Gray inks scale by `t`, RGB mixes toward white (`t` = 0 is paper white, 1 the colour itself). + pub fn tinted(self, t: f32) -> Color { + let t = t.clamp(0.0, 1.0); + if t == 1.0 { + return self; + } + match self { + Color::Cmyk { c, m, y, k } => Color::Cmyk { c: c * t, m: m * t, y: y * t, k: k * t }, + Color::Gray { k } => Color::Gray { k: k * t }, + Color::Rgb { r, g, b } => { + let w = |v: f32| v + (1.0 - v) * (1.0 - t); + Color::Rgb { r: w(r), g: w(g), b: w(b) } + } + } + } pub fn model_name(&self) -> &'static str { match self { Color::Rgb { .. } => "RGB", @@ -262,10 +278,15 @@ pub enum Paint { #[default] None, Solid { + /// The colour shown: with a link, the swatch's colour at `tint` ([`Color::tinted`]). color: Color, /// Name of the global swatch this colour is linked to (edits to the swatch update it). #[serde(default, skip_serializing_if = "Option::is_none")] swatch: Option, + /// Tint of the linked swatch (0..1; 1 without a link): a spot tint prints this much of its + /// plate, and swatch edits keep it. + #[serde(default = "full_tint", skip_serializing_if = "is_full_tint")] + tint: f32, }, Gradient(Box), Pattern { @@ -281,9 +302,22 @@ fn is_identity(a: &kurbo::Affine) -> bool { *a == kurbo::Affine::IDENTITY } +pub(crate) fn full_tint() -> f32 { + 1.0 +} + +pub(crate) fn is_full_tint(t: &f32) -> bool { + *t == 1.0 +} + +/// A tint as a whole percentage ("40" for 0.4). +pub fn tint_percent(t: f32) -> f32 { + (t.clamp(0.0, 1.0) * 100.0).round() +} + impl Paint { pub fn solid(c: Color) -> Self { - Paint::Solid { color: c, swatch: None } + Paint::Solid { color: c, swatch: None, tint: 1.0 } } pub fn is_none(&self) -> bool { matches!(self, Paint::None) @@ -297,12 +331,30 @@ impl Paint { pub fn label(&self) -> String { match self { Paint::None => "None".into(), - Paint::Solid { color, swatch: Some(n) } => format!("{n} ({})", color.to_hex()), + Paint::Solid { color, swatch: Some(n), tint } if *tint < 1.0 => format!("{n} {}% ({})", tint_percent(*tint), color.to_hex()), + Paint::Solid { color, swatch: Some(n), .. } => format!("{n} ({})", color.to_hex()), Paint::Solid { color, .. } => color.to_hex(), Paint::Gradient(g) => format!("{} gradient", g.gradient.kind.label()), Paint::Pattern { pattern, .. } => format!("pattern {pattern}"), } } + /// Apply `f` to the colours of this paint that can link to a swatch (a solid colour, each + /// gradient stop) with their link and tint; `f` returns true when it changed them. A colour + /// left without a link gets tint 1. Returns whether anything changed. + pub fn map_links(&mut self, f: &mut dyn FnMut(&mut Color, &mut Option, &mut f32) -> bool) -> bool { + let mut one = |c: &mut Color, link: &mut Option, tint: &mut f32| { + let changed = f(c, link, tint); + if link.is_none() { + *tint = 1.0; + } + changed + }; + match self { + Paint::Solid { color, swatch, tint } => one(color, swatch, tint), + Paint::Gradient(g) => g.gradient.stops.iter_mut().fold(false, |ch, s| one(&mut s.color, &mut s.swatch, &mut s.tint) | ch), + _ => false, + } + } } #[cfg(test)] @@ -376,6 +428,53 @@ mod tests { assert_eq!(keep_model(red, Color::rgb(0.0, 0.5, 1.0)), Color::rgb(0.0, 0.5, 1.0)); } + #[test] + fn tints_scale_inks_and_mix_rgb_toward_white() { + let close = |a: Color, b: Color| { + let v = |c: Color| match c { + Color::Cmyk { c, m, y, k } => vec![c, m, y, k], + Color::Rgb { r, g, b } => vec![r, g, b], + Color::Gray { k } => vec![k], + }; + a.model() == b.model() && v(a).iter().zip(v(b)).all(|(x, y)| (x - y).abs() < 1e-6) + }; + assert!(close(Color::cmyk(0.5, 1.0, 0.0, 0.2).tinted(0.4), Color::cmyk(0.2, 0.4, 0.0, 0.08))); + assert!(close(Color::gray(0.5).tinted(0.5), Color::gray(0.25))); + assert!(close(Color::rgb(1.0, 0.0, 0.5).tinted(0.4), Color::rgb(1.0, 0.6, 0.8)), "RGB mixes toward white"); + assert_eq!(Color::rgb(0.2, 0.4, 0.6).tinted(1.0), Color::rgb(0.2, 0.4, 0.6)); + assert_eq!(Color::cmyk(0.3, 0.3, 0.3, 0.3).tinted(0.0), Color::cmyk(0.0, 0.0, 0.0, 0.0)); + assert_eq!(Color::rgb(0.0, 0.0, 0.0).tinted(-1.0).to_hex(), "#ffffff", "clamped"); + assert_eq!(tint_percent(0.404), 40.0); + } + + #[test] + fn tints_serialize_only_below_full_and_read_old_files() { + let full = serde_json::to_string(&Paint::Solid { color: Color::gray(1.0), swatch: Some("Ink".into()), tint: 1.0 }).unwrap(); + assert!(!full.contains("tint"), "{full}"); + let old: Paint = serde_json::from_str(r#"{"type":"solid","color":{"model":"gray","k":1.0},"swatch":"Ink"}"#).unwrap(); + assert_eq!(old, Paint::Solid { color: Color::gray(1.0), swatch: Some("Ink".into()), tint: 1.0 }); + let t = Paint::Solid { color: Color::gray(0.4), swatch: Some("Ink".into()), tint: 0.4 }; + let s = serde_json::to_string(&t).unwrap(); + assert!(s.contains("\"tint\":0.4"), "{s}"); + assert_eq!(serde_json::from_str::(&s).unwrap(), t); + assert_eq!(t.label(), "Ink 40% (#999999)"); + } + + #[test] + fn mapping_links_resets_the_tint_of_unlinked_colours() { + let mut p = Paint::Solid { color: Color::gray(0.4), swatch: Some("Ink".into()), tint: 0.4 }; + assert!(p.map_links(&mut |_, l, _| l.take().is_some())); + assert_eq!(p, Paint::solid(Color::gray(0.4))); + let mut g = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + g.map_links(&mut |_, l, t| { + *l = Some("Ink".into()); + *t = 0.5; + true + }); + let Paint::Gradient(g) = g else { unreachable!() }; + assert!(g.gradient.stops.iter().all(|s| s.swatch.as_deref() == Some("Ink") && s.tint == 0.5)); + } + #[test] fn paint_serde() { let p = Paint::solid(Color::rgb8(10, 20, 30)); diff --git a/crates/color/src/libraries.rs b/crates/color/src/libraries.rs index df745b1d..effe3124 100644 --- a/crates/color/src/libraries.rs +++ b/crates/color/src/libraries.rs @@ -170,7 +170,7 @@ fn group(name: &str, swatches: Vec) -> SwatchGroup { /// A gradient swatch through `stops` of (offset, colour, opacity). pub(crate) fn gradient(name: &str, kind: GradientKind, stops: &[(f32, Color, f32)]) -> Swatch { - let stops = stops.iter().map(|&(offset, color, opacity)| GradientStop { offset, color, opacity, midpoint: 0.5 }).collect(); + let stops = stops.iter().map(|&(offset, color, opacity)| GradientStop { opacity, ..GradientStop::new(offset, color) }).collect(); Swatch { name: name.into(), paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind, stops }))), global: false, spot: false } } diff --git a/crates/color/src/palette_io.rs b/crates/color/src/palette_io.rs index 160383c0..9d75bc8b 100644 --- a/crates/color/src/palette_io.rs +++ b/crates/color/src/palette_io.rs @@ -241,8 +241,8 @@ mod tests { let grad = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops: vec![ - GradientStop { offset: 0.0, color: Color::cmyk(0.1, 0.2, 0.3, 0.0), opacity: 1.0, midpoint: 0.4 }, - GradientStop { offset: 1.0, color: Color::rgb8(255, 0, 0), opacity: 0.5, midpoint: 0.5 }, + GradientStop { midpoint: 0.4, ..GradientStop::new(0.0, Color::cmyk(0.1, 0.2, 0.3, 0.0)) }, + GradientStop { opacity: 0.5, ..GradientStop::new(1.0, Color::rgb8(255, 0, 0)) }, ], }))); SwatchLibrary { diff --git a/crates/color/src/swatch.rs b/crates/color/src/swatch.rs index e87b5555..ad78b5e2 100644 --- a/crates/color/src/swatch.rs +++ b/crates/color/src/swatch.rs @@ -17,6 +17,16 @@ pub struct Swatch { pub spot: bool, } +impl Swatch { + /// A tint swatch's base swatch and tint: its colour links to another (global) swatch. + pub fn tint_of(&self) -> Option<(&str, f32)> { + match &self.paint { + Paint::Solid { swatch: Some(base), tint, .. } => Some((base, *tint)), + _ => None, + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct SwatchGroup { pub name: String, @@ -101,10 +111,7 @@ fn base_swatches() -> (Vec, Vec) { name: name.into(), paint: Paint::Gradient(Box::new(crate::GradientPaint::new(Gradient { kind, - stops: vec![ - GradientStop { offset: 0.0, color: Color::from_hex(a).unwrap(), opacity: 1.0, midpoint: 0.5 }, - GradientStop { offset: 1.0, color: Color::from_hex(b).unwrap(), opacity: 1.0, midpoint: 0.5 }, - ], + stops: vec![GradientStop::new(0.0, Color::from_hex(a).unwrap()), GradientStop::new(1.0, Color::from_hex(b).unwrap())], }))), global: false, spot: false, diff --git a/crates/doc/src/overprint.rs b/crates/doc/src/overprint.rs index 25f4487d..2f0f2c75 100644 --- a/crates/doc/src/overprint.rs +++ b/crates/doc/src/overprint.rs @@ -48,7 +48,7 @@ impl OverprintBlack { /// [`Self::spot`]), or a gradient whose every stop is. fn black(&self, p: &Paint, spots: &[String]) -> bool { match p { - Paint::Solid { color, swatch } => { + Paint::Solid { color, swatch, .. } => { (self.spot || !swatch.as_ref().is_some_and(|s| spots.contains(s))) && is_black_ink(color, self.min_k, self.rich) } Paint::Gradient(g) => !g.gradient.stops.is_empty() && g.gradient.stops.iter().all(|s| is_black_ink(&s.color, self.min_k, self.rich)), @@ -159,7 +159,7 @@ mod tests { assert!(!n.appearance.fill().unwrap().overprint, "a gradient with a white stop is not black"); // Spot-linked blacks only with `spot`; type runs too. - let spot = Paint::Solid { color: Color::cmyk(0.0, 0.0, 0.0, 1.0), swatch: Some("Ink".into()) }; + let spot = Paint::Solid { color: Color::cmyk(0.0, 0.0, 0.0, 1.0), swatch: Some("Ink".into()), tint: 1.0 }; let style = CharStyle { fill: spot, stroke: k, stroke_width: 1.0, ..CharStyle::default() }; let mut t = Node::new(NodeId(2), NodeKind::Text(Box::new(TextObject::point(Point::ZERO, "Hi", style)))); OverprintBlack::default().apply(&mut t, &["Ink".to_string()]); diff --git a/crates/doc/src/swatches.rs b/crates/doc/src/swatches.rs index 4e99240b..17626f59 100644 --- a/crates/doc/src/swatches.rs +++ b/crates/doc/src/swatches.rs @@ -1,13 +1,15 @@ //! Swatch lookups across colour groups and the walks over the paints that link to swatches. //! //! A swatch lives either in `Document::swatches` or in one of `Document::swatch_groups`; every -//! lookup here covers both. Solid paints link to a global swatch by name (`Paint::Solid.swatch`); -//! [`Document::map_solid_paints`] rewrites those links (and colours) wherever art can hold them, -//! copying only the nodes that change. +//! lookup here covers both. Solid paints and gradient stops link to a global swatch by name, at a +//! tint (`Paint::Solid.swatch` / `.tint`, `GradientStop.swatch` / `.tint`); a tint swatch is a +//! swatch whose own paint links to its base that way. [`Document::map_solid_paints`] rewrites +//! those links (and colours) wherever art and swatches hold them, copying only the nodes that +//! change. use std::sync::Arc; -use vectorcraft_color::{Color, Paint, Swatch}; +use vectorcraft_color::{Color, Paint, Swatch, tint_percent}; use crate::{AppearanceItem, Document, Node, NodeId, NodeKind}; @@ -25,6 +27,29 @@ impl Document { pub fn swatch_mut(&mut self, name: &str) -> Option<&mut Swatch> { self.swatches_iter_mut().find(|s| s.name == name) } + /// The colour of global (or spot) swatch `name`, which its tints scale; `None` when `name` + /// isn't a global solid colour of its own (a tint swatch isn't). + pub fn global_color(&self, name: &str) -> Option { + match &self.swatch(name).filter(|w| w.global)?.paint { + Paint::Solid { color, swatch: None, .. } => Some(*color), + _ => None, + } + } + /// The link a colour taken from swatch `name` gets: a global solid colour links to itself at + /// 100 %, a tint swatch to its base at its tint; `None` for other swatches. + pub fn swatch_link(&self, name: &str) -> Option<(String, f32)> { + let w = self.swatch(name)?; + match (w.tint_of(), &w.paint) { + (Some((base, tint)), _) => Some((base.to_string(), tint)), + (None, Paint::Solid { .. }) if w.global => Some((name.to_string(), 1.0)), + _ => None, + } + } + /// Tint `tint` (0..1) of global swatch `name` as a solid paint linked to it. + pub fn tint_paint(&self, name: &str, tint: f32) -> Option { + let tint = tint.clamp(0.0, 1.0); + Some(Paint::Solid { color: self.global_color(name)?.tinted(tint), swatch: Some(name.to_string()), tint }) + } /// Index of the colour group holding swatch `name` (`None` when ungrouped or missing). pub fn swatch_group_of(&self, name: &str) -> Option { self.swatch_groups.iter().position(|g| g.swatches.iter().any(|s| s.name == name)) @@ -49,6 +74,7 @@ impl Document { /// "New Pattern Swatch 1"… pub fn new_swatch_name(&self, paint: &Paint) -> String { let base = match paint { + Paint::Solid { swatch: Some(n), tint, .. } if *tint < 1.0 => return self.free_swatch_name(&format!("{n} {}%", tint_percent(*tint))), Paint::Solid { color, .. } => return self.free_swatch_name(&color_name(*color)), Paint::Gradient(_) => "New Gradient Swatch", _ => "New Pattern Swatch", @@ -66,11 +92,12 @@ impl Document { Some(list.remove(i)) } - /// Visit every solid paint (fills, strokes and text runs) in the art, symbol definitions, - /// pattern tiles and graphic styles. `f(colour, link)` may change both and returns true when it - /// did. Only nodes holding a changed paint (and the paths to them) are copied. Returns the - /// number of changed paints. - pub fn map_solid_paints(&mut self, f: &mut dyn FnMut(&mut Color, &mut Option) -> bool) -> usize { + /// Visit every colour that can link to a swatch — solid paints and gradient stops of fills, + /// strokes and text runs — in the art, symbol definitions, pattern tiles, graphic styles and the + /// swatches themselves (tint swatches, gradient swatches' stops). `f(colour, link, tint)` may + /// change them and returns true when it did ([`Paint::map_links`]). Only nodes holding a changed + /// paint (and the paths to them) are copied. Returns the number of changed paints. + pub fn map_solid_paints(&mut self, f: &mut LinkFn) -> usize { let mut n = map_trees(&mut self.layers, f); for s in &mut self.symbols { n += map_tree(&mut s.art, f); @@ -80,15 +107,18 @@ impl Document { } for gs in &mut self.graphic_styles { for it in &mut gs.appearance.items { - n += usize::from(map_solid(item_paint_mut(it), f)); + n += usize::from(item_paint_mut(it).map_links(f)); } } + for w in self.swatches_iter_mut() { + n += usize::from(w.paint.map_links(f)); + } n } - /// [`Document::map_solid_paints`] limited to the subtrees of `ids` (each paint visited once even - /// when an id is inside another). - pub fn map_solid_paints_in(&mut self, ids: &[NodeId], f: &mut dyn FnMut(&mut Color, &mut Option) -> bool) -> usize { + /// [`Document::map_solid_paints`] limited to the art in the subtrees of `ids` (each paint + /// visited once even when an id is inside another). + pub fn map_solid_paints_in(&mut self, ids: &[NodeId], f: &mut LinkFn) -> usize { let roots: Vec = ids.iter().copied().filter(|id| !self.ancestry(*id).is_some_and(|a| a[..a.len() - 1].iter().any(|p| ids.contains(p)))).collect(); let mut n = 0; @@ -135,14 +165,18 @@ pub fn color_name(c: Color) -> String { } } -/// Every colour in the subtree of `n` with the swatch it links to: solid fills, strokes and text -/// runs (with their link), then gradient stops and mesh points (unlinked), in paint order. +/// What [`Document::map_solid_paints`] applies to each linkable colour: `(colour, link, tint)`, +/// true when it changed them. +pub type LinkFn<'a> = dyn FnMut(&mut Color, &mut Option, &mut f32) -> bool + 'a; + +/// Every colour in the subtree of `n` with the swatch it links to: solid fills, strokes, text runs +/// and gradient stops (with their link), then mesh points (unlinked), in paint order. pub fn node_colors(n: &Node, f: &mut dyn FnMut(&Color, Option<&str>)) { n.walk(&mut |m| { for p in node_paints(m) { match p { - Paint::Solid { color, swatch } => f(color, swatch.as_deref()), - Paint::Gradient(g) => g.gradient.stops.iter().for_each(|s| f(&s.color, None)), + Paint::Solid { color, swatch, .. } => f(color, swatch.as_deref()), + Paint::Gradient(g) => g.gradient.stops.iter().for_each(|s| f(&s.color, s.swatch.as_deref())), _ => {} } } @@ -184,24 +218,17 @@ fn node_paints_mut(n: &mut Node) -> impl Iterator { n.appearance.items.iter_mut().map(item_paint_mut).chain(runs.iter_mut().flat_map(|r| [&mut r.style.fill, &mut r.style.stroke])) } -/// Apply `f` to a solid paint; true when it changed. -fn map_solid(p: &mut Paint, f: &mut dyn FnMut(&mut Color, &mut Option) -> bool) -> bool { - match p { - Paint::Solid { color, swatch } => f(color, swatch), - _ => false, - } -} - -/// `n` with `f` applied to its subtree's solid paints, or `None` when nothing changed (then nothing -/// was copied). Also returns the number of changed paints. -fn map_node(n: &Node, f: &mut dyn FnMut(&mut Color, &mut Option) -> bool) -> Option<(Node, usize)> { - // Try each own paint on a scratch copy first, so unchanged nodes are never cloned. - let mut changes: Vec<(usize, Color, Option)> = vec![]; +/// `n` with `f` applied to its subtree's linkable colours, or `None` when nothing changed (then +/// nothing was copied). Also returns the number of changed paints. +fn map_node(n: &Node, f: &mut LinkFn) -> Option<(Node, usize)> { + // Try each own solid or gradient paint on a scratch copy first, so unchanged nodes are never + // cloned. + let mut changes: Vec<(usize, Paint)> = vec![]; for (i, p) in node_paints(n).enumerate() { - if let Paint::Solid { color, swatch } = p { - let (mut c, mut s) = (*color, swatch.clone()); - if f(&mut c, &mut s) { - changes.push((i, c, s)); + if matches!(p, Paint::Solid { .. } | Paint::Gradient(_)) { + let mut q = p.clone(); + if q.map_links(f) { + changes.push((i, q)); } } } @@ -212,10 +239,9 @@ fn map_node(n: &Node, f: &mut dyn FnMut(&mut Color, &mut Option) -> bool let mut changes = changes.into_iter().peekable(); for (i, p) in node_paints_mut(&mut m).enumerate() { if changes.peek().is_some_and(|c| c.0 == i) - && let (Some((_, c, s)), Paint::Solid { color, swatch }) = (changes.next(), p) + && let Some((_, q)) = changes.next() { - *color = c; - *swatch = s; + *p = q; } } out = Some(m); @@ -234,7 +260,7 @@ fn map_node(n: &Node, f: &mut dyn FnMut(&mut Color, &mut Option) -> bool out.map(|m| (m, count)) } -fn map_tree(n: &mut Arc, f: &mut dyn FnMut(&mut Color, &mut Option) -> bool) -> usize { +fn map_tree(n: &mut Arc, f: &mut LinkFn) -> usize { match map_node(n, f) { Some((new, count)) => { *n = Arc::new(new); @@ -244,7 +270,7 @@ fn map_tree(n: &mut Arc, f: &mut dyn FnMut(&mut Color, &mut Option } } -fn map_trees(v: &mut [Arc], f: &mut dyn FnMut(&mut Color, &mut Option) -> bool) -> usize { +fn map_trees(v: &mut [Arc], f: &mut LinkFn) -> usize { v.iter_mut().map(|n| map_tree(n, f)).sum() } @@ -257,7 +283,7 @@ mod tests { use crate::Appearance; fn linked(color: Color, name: &str) -> Paint { - Paint::Solid { color, swatch: Some(name.into()) } + Paint::Solid { color, swatch: Some(name.into()), tint: 1.0 } } /// A document with a grouped global swatch "Brand", a rectangle filled with it and an unlinked one. @@ -309,7 +335,7 @@ mod tests { let (mut d, a, b) = doc(); let before = d.clone(); let blue = Color::rgb(0.0, 0.0, 1.0); - let n = d.map_solid_paints(&mut |c, s| { + let n = d.map_solid_paints(&mut |c, s, _| { if s.as_deref() != Some("Brand") { return false; } @@ -323,7 +349,7 @@ mod tests { assert!(Arc::ptr_eq(&unchanged(&d), &unchanged(&before)), "the unlinked rectangle is shared, not copied"); // Nothing to change: the tree is untouched. let again = d.clone(); - assert_eq!(d.map_solid_paints(&mut |_, _| false), 0); + assert_eq!(d.map_solid_paints(&mut |_, _, _| false), 0); assert!(Arc::ptr_eq(&d.layers[0], &again.layers[0])); } @@ -332,7 +358,7 @@ mod tests { let (mut d, a, b) = doc(); let layer = d.layers[0].id; let mut seen = 0; - let n = d.map_solid_paints_in(&[layer, a], &mut |_, s| { + let n = d.map_solid_paints_in(&[layer, a], &mut |_, s, _| { seen += 1; s.take().is_some() }); diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index bbe9f403..0feb39ce 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -2,7 +2,7 @@ //! three Blend commands. They recolour the selected objects and everything inside them through the //! shared colour visitor [`Recolor`]: fills and strokes (solid colours, gradient stops and text //! runs), gradient meshes, embedded images and pattern fills. Colours linked to a global swatch are -//! unlinked when they change. +//! unlinked when they change; Adjust Color Balance's Global mode shifts their tints instead. use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -75,7 +75,7 @@ pub fn specs() -> Vec { "Adjust Color Balance…", ["Edit", "Edit Colors"], None, - "{mode?: \"rgb\"|\"cmyk\"|\"gray\"|\"global\" (default: from the channels given), r?, g?, b? | c?, m?, y?, k? | gray?: -100..100 (% added per channel, default 0), convert?: false (true: the results stay in the adjusted model; false: each colour keeps its own), fill?, stroke?, includeImages?, includePatterns?, ids?} reaching what edit.colors.invert does → {changed}. Global mode (tints of global and spot colours) isn't available yet", + "{mode?: \"rgb\"|\"cmyk\"|\"gray\"|\"global\" (default: from the channels given), r?, g?, b? | c?, m?, y?, k? | gray? | tint?: -100..100 (% added per channel, default 0; `tint` is global mode: it shifts the tints of colours and gradient stops linked to global and spot swatches, which stay linked, and leaves other colours and images alone), convert?: false (true: the results stay in the adjusted model; false: each colour keeps its own), fill?, stroke?, includeImages?, includePatterns?, ids?} reaching what edit.colors.invert does → {changed}", has_selection, adjust_balance ), @@ -131,34 +131,37 @@ pub(crate) fn to_gray(c: Color) -> Color { c.in_model(Model::Gray) } -/// Apply `f` to a paint; returns whether anything changed. -pub(crate) fn map_paint(p: &mut Paint, f: &dyn Fn(Color) -> Color) -> bool { - match p { - Paint::Solid { color, swatch } => { - let n = f(*color); - if n != *color { - *color = n; - *swatch = None; - return true; - } - false - } - Paint::Gradient(g) => { - let mut ch = false; - for st in &mut g.gradient.stops { - let n = f(st.color); - if n != st.color { - st.color = n; - ch = true; - } - } - if ch { - g.swatch = None; - } - ch - } - _ => false, +/// What a recolouring pass applies to a colour that can link to a swatch (a solid colour or a +/// gradient stop): `(colour, link, tint)`, true when it changed them. +pub(crate) type LinkMap<'a> = dyn Fn(&mut Color, &mut Option, &mut f32) -> bool + 'a; + +/// Apply `f` to the solid colour or gradient stops of a paint ([`Paint::map_links`]); a gradient +/// that changes is no longer its gradient swatch's. Returns whether anything changed. +fn map_links(p: &mut Paint, f: &LinkMap) -> bool { + let changed = p.map_links(&mut |c, l, t| f(c, l, t)); + if changed && let Paint::Gradient(g) = p { + g.swatch = None; } + changed +} + +/// Apply `f` to a paint's colours; a colour that changes loses its swatch link. Returns whether +/// anything changed. +pub(crate) fn map_paint(p: &mut Paint, f: &dyn Fn(Color) -> Color) -> bool { + map_links(p, &|c, link, _| { + let n = f(*c); + if n == *c { + return false; + } + *c = n; + *link = None; + true + }) +} + +/// The identity colour map (a pass that only maps linked colours). +fn same(c: Color) -> Color { + c } /// What a recolouring pass covers: fills (with the points of gradient meshes), strokes, the pixels @@ -191,6 +194,8 @@ impl Scope { /// change are kept as copies. pub(crate) struct Recolor<'a> { f: &'a dyn Fn(Color) -> Color, + /// Maps solid colours and gradient stops with their link and tint in place of `f`. + links: Option<&'a LinkMap<'a>>, scope: Scope, /// Image key → the key of its recoloured copy (`None`: unchanged). images: HashMap>, @@ -203,7 +208,13 @@ pub(crate) struct Recolor<'a> { impl<'a> Recolor<'a> { pub(crate) fn new(scope: Scope, f: &'a dyn Fn(Color) -> Color) -> Self { - Self { f, scope, images: HashMap::new(), patterns: HashMap::new(), new_images: vec![], new_patterns: vec![], changed: 0 } + Self { f, links: None, scope, images: HashMap::new(), patterns: HashMap::new(), new_images: vec![], new_patterns: vec![], changed: 0 } + } + + /// A pass that maps only solid colours and gradient stops, with their swatch link and tint + /// (`links`); meshes and images keep their colours. + pub(crate) fn links(scope: Scope, links: &'a LinkMap<'a>) -> Self { + Self { links: Some(links), ..Self::new(Scope { images: false, ..scope }, &same) } } /// Recolour the objects `ids` and everything inside them (each once), add the new images and @@ -294,7 +305,10 @@ impl<'a> Recolor<'a> { fn paint(&mut self, d: &Document, p: &mut Paint) { let changed = match p { Paint::Pattern { pattern, .. } if self.scope.patterns => self.pattern(d, pattern).map(|new| *pattern = new).is_some(), - _ => map_paint(p, self.f), + _ => match self.links { + Some(f) => map_links(p, f), + None => map_paint(p, self.f), + }, }; self.changed += changed as usize; } @@ -348,12 +362,16 @@ impl<'a> Recolor<'a> { } pub(crate) fn recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> Color) -> Result { + run_pass(s, p, label, Recolor::new(Scope::of(p), f)) +} + +/// Run `pass` on the `ids` param's objects (default: the selection) as one undo step. +fn run_pass(s: &mut Session, p: &Value, label: &str, pass: Recolor) -> Result { let ids = match ids_param(p, "ids") { Some(v) => v, None => selected_roots(s)?, }; - let scope = Scope::of(p); - let n = s.edit(label, |d, _| Ok(Recolor::new(scope, f).run(d, &ids)))?; + let n = s.edit(label, |d, _| Ok(pass.run(d, &ids)))?; Ok(json!({ "changed": n })) } @@ -376,7 +394,8 @@ fn adjust_balance(s: &mut Session, p: &Value) -> Result { None if given(&["c", "m", "y", "k"]) => "cmyk".into(), None if given(&["gray"]) => "gray".into(), None if given(&["r", "g", "b"]) => "rgb".into(), - None => return Err(bad(C, "give a mode or r/g/b, c/m/y/k or gray adjustments")), + None if given(&["tint"]) => "global".into(), + None => return Err(bad(C, "give a mode or r/g/b, c/m/y/k, gray or tint adjustments")), }; let g = |k: &str| (f64_or(p, k, 0.0).clamp(-100.0, 100.0) / 100.0) as f32; let cl = |v: f32| v.clamp(0.0, 1.0); @@ -403,7 +422,20 @@ fn adjust_balance(s: &mut Session, p: &Value) -> Result { }) } "global" => { - return Err(bad(C, "mode `global` shifts the tints of global and spot colours, which aren't supported yet: use rgb, cmyk or gray")); + // Each linked colour moves to the shifted tint of its global swatch's current colour. + let d = &s.doc()?.doc; + let bases: HashMap = d.swatches_iter().filter_map(|w| Some((w.name.clone(), d.global_color(&w.name)?))).collect(); + let dt = g("tint"); + let shift = move |c: &mut Color, link: &mut Option, tint: &mut f32| { + let Some(base) = link.as_ref().and_then(|l| bases.get(l)) else { return false }; + let t = cl(*tint + dt); + if t == *tint { + return false; + } + (*tint, *c) = (t, base.tinted(t)); + true + }; + return run_pass(s, p, "Adjust Colors", Recolor::links(Scope::of(p), &shift)); } other => return Err(bad(C, format!("unknown mode `{other}` (rgb|cmyk|gray|global)"))), }; diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index a71a404a..28267cee 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -268,6 +268,15 @@ fn convert_mode(s: &mut Session, p: &Value) -> Result { _ => {} } } + // Tints are their converted swatch's colour at their tint (exact ink percentages). + let bases: Vec<(String, Color)> = d.swatches_iter().filter_map(|w| Some((w.name.clone(), d.global_color(&w.name)?))).collect(); + d.map_solid_paints(&mut |c, link, tint| match bases.iter().find(|(n, _)| Some(n) == link.as_ref()) { + Some((_, base)) if *tint < 1.0 => { + *c = base.tinted(*tint); + true + } + _ => false, + }); Ok(()) })?; Ok(json!({ "changed": changed })) diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 4087c9fd..af14a2df 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -218,7 +218,7 @@ fn flare(s: &mut Session, p: &Value) -> Result { seed ^= seed << 17; (seed >> 11) as f64 / (1u64 << 53) as f64 }; - let stop = |offset: f32, opacity: f32| GradientStop { offset, color: vectorcraft_color::Color::WHITE, opacity, midpoint: 0.5 }; + let stop = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, vectorcraft_color::Color::WHITE) }; let radial = |centre: Point, radius: f64, stops: Vec| { let mut g = GradientPaint::new(Gradient { kind: GradientKind::Radial, stops }); g.geom = Some(GradientGeom { start: centre, end: centre + vectorcraft_geom::Vec2::new(radius, 0.0), aspect: 1.0 }); diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index b42474ef..329acde8 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -124,6 +124,8 @@ pub(crate) fn parse_stops(v: &Value) -> Parsed> { color, opacity: opacity.clamp(0.0, 1.0) as f32, midpoint: num("midpoint")?.unwrap_or(0.5).clamp(0.13, 0.87) as f32, + swatch: None, + tint: 1.0, }) }) .collect::>>()?; diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 727abefe..d1a3fde7 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -2,7 +2,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Color, Paint}; -use vectorcraft_doc::{Appearance, CharStyle, Node, NodeId, NodeKind}; +use vectorcraft_doc::{Appearance, CharStyle, Document, Node, NodeId, NodeKind}; use super::appearance::{ItemTarget, appearance_targets, edit_items, item_target}; use super::gradient::{item_paint_bounds, place_paint, place_run_paint, run_paint_mut, run_stroke_weight, unplaced}; @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name (a gradient swatch fits each object, keeping its aspect), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself)", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch fits each object, keeping its aspect), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself)", has_doc, |s, p| set_paint(s, p, true) ), @@ -304,19 +304,18 @@ pub(crate) fn paint_from(s: &Session, p: &Value) -> Result> { return Ok(Some(Paint::None)); } if let Some(name) = str_param(p, "swatch") { - let st = s.doc()?; + let d = &s.doc()?.doc; // A pattern definition works as its swatch even without a swatch entry. - if st.doc.swatch(name).is_none() && st.doc.pattern(name).is_some() { + if d.swatch(name).is_none() && d.pattern(name).is_some() { return Ok(Some(vectorcraft_doc::pattern::pattern_paint(name))); } - let sw = st.doc.swatch(name).ok_or_else(|| EngineError::Other(format!("no swatch `{name}`")))?; - let mut paint = sw.paint.clone(); - if sw.global - && let Paint::Solid { swatch, .. } = &mut paint - { - *swatch = Some(name.to_string()); - } - return Ok(Some(paint)); + let sw = d.swatch(name).ok_or_else(|| EngineError::Other(format!("no swatch `{name}`")))?; + let tint = tint_param(p, "paint")?; + return match &sw.paint { + Paint::Solid { .. } => swatch_solid(d, name, tint).map(Some).map_err(|e| bad("paint", e)), + _ if tint.is_some() => Err(bad("paint", "`tint` applies to global and spot colours")), + other => Ok(Some(other.clone())), + }; } if let Some(g) = p.get("gradient") { let mut gp = super::gradient::parse_gradient(g).map_err(|e| bad("paint", e))?; @@ -330,8 +329,35 @@ pub(crate) fn paint_from(s: &Session, p: &Value) -> Result> { Ok(None) } +/// The `tint` param (a percentage) as 0..1. +pub(crate) fn tint_param(p: &Value, cmd: &str) -> Result> { + p.get("tint") + .filter(|v| !v.is_null()) + .map(|v| v.as_f64().map(|t| (t / 100.0).clamp(0.0, 1.0) as f32).ok_or_else(|| bad(cmd, "`tint` must be a number (%)"))) + .transpose() +} + +/// The solid paint colour swatch `name` applies at `tint` (0..1): a global or spot colour links to +/// itself (default 100%), a tint swatch to its base (default: its own tint), a process colour is +/// just its colour (it has no tints). +pub(crate) fn swatch_solid(d: &Document, name: &str, tint: Option) -> std::result::Result { + let sw = d.swatch(name).ok_or_else(|| format!("no swatch `{name}`"))?; + if sw.paint.color().is_none() { + return Err(format!("`{name}` isn't a colour swatch")); + } + match (d.swatch_link(name), tint) { + // A tint swatch whose base is gone keeps its colour. + (Some((base, own)), _) => Ok(d.tint_paint(&base, tint.unwrap_or(own)).unwrap_or_else(|| sw.paint.clone())), + (None, None) => Ok(sw.paint.clone()), + (None, Some(_)) => Err(format!("`{name}` is a process colour: tints apply to global and spot colours")), + } +} + fn set_paint(s: &mut Session, p: &Value, fill: bool) -> Result { let cmd = if fill { "paint.setFill" } else { "paint.setStroke" }; + if p.get("tint").is_some() && p.get("swatch").is_none() { + return Err(bad(cmd, "`tint` goes with a global or spot `swatch`")); + } let paint = paint_from(s, p)?.ok_or_else(|| bad(cmd, "give color, none, swatch or gradient"))?; apply_paint(s, p, paint, fill) } diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index d1a556a6..effd970e 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -1,9 +1,9 @@ //! The Swatches panel: new, delete and duplicate swatches, colour groups and sorting. use serde_json::{Value, json}; -use vectorcraft_color::{Color, Paint, Swatch, SwatchGroup}; +use vectorcraft_color::{Color, Paint, Swatch, SwatchGroup, tint_percent}; use vectorcraft_doc::pattern::pattern_paint; -use vectorcraft_doc::swatches::{color_name, node_colors}; +use vectorcraft_doc::swatches::{LinkFn, color_name, node_colors}; use vectorcraft_doc::{Document, NodeId}; use super::paint::paint_from; @@ -17,7 +17,7 @@ pub fn specs() -> Vec { "New Swatch", ["Window", "Swatches"], None, - "{name?, color? | colors?: [colour] (one swatch each, in one undo step) | swatch? | gradient? | pattern?: name (default: the current fill), mode?: \"gray\"|\"rgb\"|\"hsb\"|\"cmyk\"|\"web\" (convert the colour), global?, spot? (a spot colour, always global), group?: colour group name (solid colours only; default: ungrouped)} save a colour, gradient or pattern as a swatch. 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\") → {name, names: [every new swatch]}", + "{name?, color? | colors?: [colour] (one swatch each, in one undo step) | 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\"|\"cmyk\"|\"web\" (convert the colour), global?, spot? (a spot colour, always global), group?: colour group name (solid colours only; default: ungrouped)} 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\") → {name, names: [every new swatch]}", has_doc, swatch_new ), @@ -46,7 +46,7 @@ pub fn specs() -> Vec { "Swatch Options", ["Window", "Swatches"], None, - "{name, newName?, color?, mode?: \"gray\"|\"rgb\"|\"hsb\"|\"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 and text linked to a global swatch take its new colour and name; turning Global off unlinks them (they keep their colour). Colour, mode and spot apply to solid colours only → {name, relinked: paints changed}", + "{name, newName?, color?, mode?: \"gray\"|\"rgb\"|\"hsb\"|\"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}", has_doc, swatch_edit ), @@ -55,7 +55,7 @@ pub fn specs() -> Vec { "Swatches", [], None, - "{group?: name (only that colour group's swatches)} → {swatches: [{name, kind: \"none\"|\"color\"|\"gradient\"|\"pattern\", group, global, spot, color?, hex?, gradient?, pattern?}], groups: [{name, swatches: [names]}]}", + "{group?: name (only that colour group's swatches)} → {swatches: [{name, kind: \"none\"|\"color\"|\"gradient\"|\"pattern\", group, global, spot, color?, hex?, tintOf?: base swatch of a tint swatch, tint?: its %, gradient?, pattern?}], groups: [{name, swatches: [names]}]}", has_doc, swatch_list ), @@ -129,7 +129,17 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result { } (None, Some(name)) if s.doc()?.doc.pattern(name).is_some() => vec![pattern_paint(name)], (None, Some(name)) => return Err(bad(C, format!("no pattern `{name}`"))), - (None, None) => vec![paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone())], + (None, None) => { + let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone()); + // `tint` alone: that tint of the global colour the current fill links to. + match (super::paint::tint_param(p, C)?, &paint) { + (Some(t), Paint::Solid { swatch: Some(l), .. }) if p.get("swatch").is_none() => { + vec![s.doc()?.doc.tint_paint(l, t).ok_or_else(|| bad(C, format!("`{l}` isn't a global or spot colour")))?] + } + (Some(_), _) if p.get("swatch").is_none() => return Err(bad(C, "`tint` needs a global or spot colour")), + _ => vec![paint], + } + } }; let spot = bool_or(p, "spot", false); let global = spot || bool_or(p, "global", false); @@ -138,6 +148,8 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result { let paints = paints .into_iter() .map(|paint| match paint { + // A tint stays linked to its base: a tint swatch. + Paint::Solid { swatch: Some(_), tint, .. } if tint < 1.0 && mode.is_none() && !global => Ok(paint), // The swatch holds the colour itself, not a link to the swatch it may have come from. Paint::Solid { color, .. } => Ok(Paint::solid(match mode { Some(m) => convert_to_mode(color, m, C)?, @@ -157,7 +169,9 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result { Some(n) => d.free_swatch_name(n), None => d.new_swatch_name(&paint), }; - let swatch = Swatch { name: name.clone(), paint, global, spot }; + // A tint swatch's kind is its base's. + let tint = matches!(paint, Paint::Solid { swatch: Some(_), .. }); + let swatch = Swatch { name: name.clone(), paint, global: global && !tint, spot: spot && !tint }; match group { Some(g) => { d.swatch_groups.iter_mut().find(|x| x.name == g).ok_or_else(|| bad(C, format!("no colour group `{g}`")))?.swatches.push(swatch) @@ -204,7 +218,8 @@ fn swatch_delete(s: &mut Session, p: &Value) -> Result { if !bool_or(p, "unlink", true) { global.clear(); } - let mut unlink = |_: &mut Color, link: &mut Option| link.as_ref().is_some_and(|l| global.contains(l)) && link.take().is_some(); + let mut unlink = + |_: &mut Color, link: &mut Option, _: &mut f32| link.as_ref().is_some_and(|l| global.contains(l)) && link.take().is_some(); let unlinked = s.edit("Delete Swatch", |d, _| { d.swatch_groups.retain(|g| !names.contains(&g.name)); for n in &names { @@ -244,7 +259,7 @@ fn link_colors(d: &mut Document, ids: &[NodeId], links: &[(Color, String)]) -> u return 0; } let live: Vec = d.swatches_iter().map(|w| w.name.clone()).collect(); - d.map_solid_paints_in(ids, &mut |c, link| { + d.map_solid_paints_in(ids, &mut |c, link, _| { if link.as_ref().is_some_and(|l| live.contains(l)) { return false; } @@ -416,15 +431,15 @@ struct Relink { from: String, /// The swatch's name after the edit. to: String, - /// The new colour of a global swatch (linked paints take it). + /// The new colour of a global swatch (linked paints take it, at their tint). color: Option, /// false: drop the links (the swatch stopped being global); paints keep their colour. keep: bool, } impl Relink { - /// Apply to one paint; true when it changed. - fn apply(&self, c: &mut Color, link: &mut Option) -> bool { + /// Apply to one linked colour; true when it changed. + fn apply(&self, c: &mut Color, link: &mut Option, tint: &mut f32) -> bool { if link.as_deref() != Some(self.from.as_str()) { return false; } @@ -436,28 +451,27 @@ impl Relink { if renamed { *link = Some(self.to.clone()); } - let recolored = self.color.is_some_and(|n| n != *c); - if let Some(n) = self.color { + let new = self.color.map(|n| n.tinted(*tint)); + let recolored = new.is_some_and(|n| n != *c); + if let Some(n) = new { *c = n; } renamed || recolored } fn defaults(&self, s: &mut Session) { - map_default_paints(s, &mut |c, l| self.apply(c, l)); + map_default_paints(s, &mut |c, l, t| self.apply(c, l, t)); } } /// Apply a swatch-link rewrite to the default fill and stroke for new art, except during a live /// preview (Cancel rolls the document back, not the session). -pub(super) fn map_default_paints(s: &mut Session, f: &mut dyn FnMut(&mut Color, &mut Option) -> bool) { +pub(super) fn map_default_paints(s: &mut Session, f: &mut LinkFn) { if s.in_interaction() { return; } for p in [&mut s.paint.fill, &mut s.paint.stroke] { - if let Paint::Solid { color, swatch } = p { - f(color, swatch); - } + p.map_links(f); } } @@ -474,6 +488,11 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result { if solid.is_none() && (given("color") || given("mode") || bool_or(p, "spot", false)) { return Err(bad(C, "colour, mode and spot apply to solid-colour swatches only")); } + if let Some((base, _)) = sw.tint_of() + && ["color", "mode", "paint", "global", "spot"].iter().any(|k| given(k)) + { + return Err(bad(C, format!("`{name}` is a tint of `{base}`: edit `{base}` (a tint swatch only takes a new name)"))); + } // 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 (paint_from(s, v)?, &sw.paint) { @@ -510,7 +529,7 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result { } } } - Ok(d.map_solid_paints(&mut |c, l| relink.apply(c, l))) + Ok(d.map_solid_paints(&mut |c, l, t| relink.apply(c, l, t))) })?; relink.defaults(s); Ok(json!({"name": to, "relinked": relinked})) @@ -520,10 +539,14 @@ pub(super) fn swatch_json(sw: &Swatch, group: Option<&str>) -> Value { let mut v = json!({"name": sw.name, "group": group, "global": sw.global, "spot": sw.spot}); match &sw.paint { Paint::None => v["kind"] = json!("none"), - Paint::Solid { color, .. } => { + Paint::Solid { color, swatch, tint } => { v["kind"] = json!("color"); v["color"] = json!(color); v["hex"] = json!(color.to_hex()); + if let Some(base) = swatch { + v["tintOf"] = json!(base); + v["tint"] = json!(tint_percent(*tint)); + } } Paint::Gradient(g) => { v["kind"] = json!("gradient"); @@ -592,20 +615,28 @@ fn swatch_add_used(s: &mut Session, p: &Value) -> Result { fn swatch_unused(s: &mut Session, _: &Value) -> Result { let d = &s.doc()?.doc; - let (mut links, mut colors, mut gradients, mut patterns) = (vec![], vec![], vec![], vec![]); + // Links with their tints, so a tint swatch is used where its tint of its base is. + let (mut links, mut colors, mut gradients, mut patterns, mut gradient_links) = (vec![], vec![], vec![], vec![], vec![]); + let mut color = |c: &Color, link: Option<&String>, tint: f32| match link { + Some(l) if d.swatch(l).is_some() => links.push((l.clone(), tint)), + _ => colors.push(*c), + }; d.visit_paints(&mut |p| match p { - Paint::Solid { swatch: Some(l), .. } if d.swatch(l).is_some() => links.push(l.clone()), - Paint::Solid { color, .. } => colors.push(*color), + Paint::Solid { color: c, swatch, tint } => color(c, swatch.as_ref(), *tint), Paint::Gradient(g) => { - links.extend(g.swatch.clone()); - colors.extend(g.gradient.stops.iter().map(|s| s.color)); + g.gradient.stops.iter().for_each(|s| color(&s.color, s.swatch.as_ref(), s.tint)); + gradient_links.extend(g.swatch.clone()); gradients.push(g.gradient.clone()); } Paint::Pattern { pattern, .. } => patterns.push(pattern.clone()), Paint::None => {} }); let used = |w: &Swatch| { - links.contains(&w.name) + gradient_links.contains(&w.name) + || links.iter().any(|(l, t)| match w.tint_of() { + Some((base, tint)) => l == base && *t == tint, + None => *l == w.name, + }) || match &w.paint { Paint::None => true, Paint::Solid { color, .. } => colors.contains(color), @@ -632,11 +663,12 @@ fn swatch_merge(s: &mut Session, p: &Value) -> Result { } let kept = d.swatch(keep).ok_or_else(|| bad(C, format!("no swatch `{keep}`")))?; let (color, link) = (kept.paint.color().unwrap_or_default(), kept.global.then(|| keep.clone())); - let mut relink = |c: &mut Color, l: &mut Option| { + // Tints keep their tint (of the kept colour). + let mut relink = |c: &mut Color, l: &mut Option, t: &mut f32| { if !l.as_ref().is_some_and(|l| merged.contains(l)) { return false; } - *c = color; + *c = color.tinted(*t); *l = link.clone(); true }; diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index 3edfd240..f7fa9193 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -397,7 +397,7 @@ fn reset_defaults(s: &mut Session, p: &Value) -> Result { (d.swatches, d.swatch_groups) = (defaults, groups); added.extend(d.swatches_iter().filter(|w| !before.contains(&w.name)).map(|w| w.name.clone())); live.extend(d.swatches_iter().filter(|w| w.global).map(|w| w.name.clone())); - d.map_solid_paints(&mut |_, l| unlink_dead(&live, l)); + d.map_solid_paints(&mut |_, l, _| unlink_dead(&live, l)); return Ok(added); } for (i, w) in defaults.into_iter().enumerate() { @@ -427,7 +427,7 @@ fn reset_defaults(s: &mut Session, p: &Value) -> Result { Ok(added) })?; if replace { - map_default_paints(s, &mut |_, l| unlink_dead(&live, l)); + map_default_paints(s, &mut |_, l, _| unlink_dead(&live, l)); } Ok(json!({ "added": added })) } diff --git a/crates/engine/src/cmd/xform.rs b/crates/engine/src/cmd/xform.rs index 1fd28221..5b2a4412 100644 --- a/crates/engine/src/cmd/xform.rs +++ b/crates/engine/src/cmd/xform.rs @@ -355,7 +355,7 @@ fn sample_color(s: &mut Session, p: &Value) -> Result { }; let mut stops = g.gradient.stops; let n = stops.len(); - stops.get_mut(i).ok_or_else(|| bad(C, format!("no stop {i} (the gradient has {n})")))?.color = color; + stops.get_mut(i).ok_or_else(|| bad(C, format!("no stop {i} (the gradient has {n})")))?.set_color(color, None); ("paint.editGradient", json!({ "stops": vectorcraft_tools::params::stops_json(&stops), "stroke": stroke })) } }; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2915ed3a..41e5b1f3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -847,4 +847,6 @@ mod tests_textedit; #[cfg(test)] mod tests_tileedge; #[cfg(test)] +mod tests_tints; +#[cfg(test)] mod tests_xform; diff --git a/crates/engine/src/tests_colormgmt.rs b/crates/engine/src/tests_colormgmt.rs index 4b3f1b8c..076b4ca1 100644 --- a/crates/engine/src/tests_colormgmt.rs +++ b/crates/engine/src/tests_colormgmt.rs @@ -104,7 +104,7 @@ fn swatch_links_survive_mode_conversion() { let a = NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap()); s.execute("paint.setFill", &json!({"ids": [a.0], "swatch": "Brand"})).unwrap(); s.execute("object.convertDocumentColorMode", &json!({"mode": "cmyk"})).unwrap(); - let Paint::Solid { color, swatch } = fill_of(&s, a) else { panic!() }; + let Paint::Solid { color, swatch, .. } = fill_of(&s, a) else { panic!() }; assert_eq!(swatch.as_deref(), Some("Brand")); let sw = s.doc().unwrap().doc.swatches.iter().find(|w| w.name == "Brand").unwrap().paint.color().unwrap(); assert!(matches!(color, Color::Cmyk { .. })); diff --git a/crates/engine/src/tests_editcolors.rs b/crates/engine/src/tests_editcolors.rs index b0d60de2..e802bb22 100644 --- a/crates/engine/src/tests_editcolors.rs +++ b/crates/engine/src/tests_editcolors.rs @@ -52,21 +52,6 @@ fn adjust_balance_modes() { assert!(s.execute("edit.colors.adjustBalance", &json!({"mode": "lab"})).is_err()); } -#[test] -fn global_mode_needs_tints_and_says_so() { - let mut s = session(); - s.execute("swatch.new", &json!({"name": "Brand", "color": "#3366cc", "global": true})).unwrap(); - let a = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); - s.execute("paint.setFill", &json!({"ids": [a.0], "swatch": "Brand"})).unwrap(); - s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); - let before = s.doc().unwrap().doc.clone(); - let e = s.execute("edit.colors.adjustBalance", &json!({"mode": "global"})).unwrap_err().to_string(); - assert!(e.contains("global and spot"), "{e}"); - assert_eq!(*s.doc().unwrap().doc, *before, "nothing changed"); - let p = s.doc().unwrap().doc.node(a).unwrap().appearance.fill_paint(); - assert!(matches!(p, Paint::Solid { swatch: Some(ref n), .. } if n == "Brand"), "the link is kept"); -} - #[test] fn balance_previews_and_commits_one_step() { let mut s = session(); diff --git a/crates/engine/src/tests_gradient.rs b/crates/engine/src/tests_gradient.rs index 6fadaa41..ce660736 100644 --- a/crates/engine/src/tests_gradient.rs +++ b/crates/engine/src/tests_gradient.rs @@ -32,7 +32,7 @@ fn fill_gradient(s: &Session, id: NodeId) -> GradientPaint { /// A three-stop freeform gradient with a 0.3 midpoint, partial opacity and a placed vector. fn three_stops() -> GradientPaint { - let stop = |offset, color, opacity, midpoint| GradientStop { offset, color, opacity, midpoint }; + let stop = |offset, color, opacity, midpoint| GradientStop { opacity, midpoint, ..GradientStop::new(offset, color) }; let mut g = GradientPaint::new(Gradient { kind: GradientKind::Freeform, stops: vec![ diff --git a/crates/engine/src/tests_swatchcmds.rs b/crates/engine/src/tests_swatchcmds.rs index 96ce2936..85289bb4 100644 --- a/crates/engine/src/tests_swatchcmds.rs +++ b/crates/engine/src/tests_swatchcmds.rs @@ -107,7 +107,10 @@ fn add_used_colors_skips_colours_that_have_swatches() { run(&mut s, "select.set", json!({"ids": [d.0]})); let r = run(&mut s, "swatch.addUsedColors", json!({"selection": true, "global": true})); assert_eq!(r, json!({"added": ["R=171 G=205 B=239"], "linked": 1})); - assert_eq!(fill(&s, d), Paint::Solid { color: vectorcraft_color::Color::from_hex("#abcdef").unwrap(), swatch: Some("R=171 G=205 B=239".into()) }); + assert_eq!( + fill(&s, d), + Paint::Solid { color: vectorcraft_color::Color::from_hex("#abcdef").unwrap(), swatch: Some("R=171 G=205 B=239".into()), tint: 1.0 } + ); assert!(doc(&s).swatch("R=171 G=205 B=239").unwrap().global); } @@ -151,7 +154,7 @@ fn merge_keeps_the_first_and_relinks_the_others_art() { let r = run(&mut s, "swatch.merge", json!({"names": ["Red", "Orange"]})); assert_eq!(r, json!({"name": "Red", "merged": ["Orange"], "relinked": 1})); assert!(doc(&s).swatch("Orange").is_none()); - assert_eq!(fill(&s, b), Paint::Solid { color: red, swatch: Some("Red".into()) }); + assert_eq!(fill(&s, b), Paint::Solid { color: red, swatch: Some("Red".into()), tint: 1.0 }); assert_eq!(undo_len(&s), undo + 1); run(&mut s, "edit.undo", json!({})); assert_eq!(*doc(&s), before); diff --git a/crates/engine/src/tests_swatches.rs b/crates/engine/src/tests_swatches.rs index 2062cfae..644e44bd 100644 --- a/crates/engine/src/tests_swatches.rs +++ b/crates/engine/src/tests_swatches.rs @@ -128,7 +128,7 @@ fn fill_of(s: &Session, id: NodeId) -> Paint { } fn linked(hex: &str, name: &str) -> Paint { - Paint::Solid { color: Color::from_hex(hex).unwrap(), swatch: Some(name.into()) } + Paint::Solid { color: Color::from_hex(hex).unwrap(), swatch: Some(name.into()), tint: 1.0 } } #[test] @@ -310,7 +310,7 @@ fn tints_of_global_swatches_get_swatches_only_when_asked() { let (_, b) = art(&mut s); // A 50% tint of Brand on b's stroke. let tint = Color::from_hex("#95b7d8").unwrap(); - std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).map_solid_paints_in(&[b], &mut |c, l| { + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).map_solid_paints_in(&[b], &mut |c, l, _| { let hit = l.as_deref() == Some("Brand"); if hit { *c = tint; @@ -322,7 +322,11 @@ fn tints_of_global_swatches_get_swatches_only_when_asked() { run(&mut s, "edit.undo", json!({})); let with = run(&mut s, "swatch.newGroup", json!({"fromArtwork": true, "includeTints": true})); assert_eq!(with["swatches"], json!(["Brand", "R=255 G=0 B=0", "R=0 G=255 B=0", "R=149 G=183 B=216"])); - assert_eq!(doc(&s).node(b).unwrap().appearance.stroke_paint(), Paint::Solid { color: tint, swatch: Some("Brand".into()) }, "tints stay linked"); + assert_eq!( + doc(&s).node(b).unwrap().appearance.stroke_paint(), + Paint::Solid { color: tint, swatch: Some("Brand".into()), tint: 1.0 }, + "tints stay linked" + ); } #[test] diff --git a/crates/engine/src/tests_swatchlib.rs b/crates/engine/src/tests_swatchlib.rs index ae6e59bc..224c475d 100644 --- a/crates/engine/src/tests_swatchlib.rs +++ b/crates/engine/src/tests_swatchlib.rs @@ -121,7 +121,7 @@ fn reset_defaults_restores_missing_swatches_or_replaces_them() { let id = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5}))["id"].clone(); run(&mut s, "select.set", json!({"ids": [id]})); run(&mut s, "paint.setFill", json!({"swatch": "Mine"})); - assert_eq!(s.paint.fill, Paint::Solid { color: Color::from_hex("#abcdef").unwrap(), swatch: Some("Mine".into()) }); + assert_eq!(s.paint.fill, Paint::Solid { color: Color::from_hex("#abcdef").unwrap(), swatch: Some("Mine".into()), tint: 1.0 }); run(&mut s, "swatch.resetDefaults", json!({"replace": true})); let d = doc(&s); assert_eq!(d.swatches_iter().map(|w| w.name.clone()).collect::>(), defaults); diff --git a/crates/engine/src/tests_tints.rs b/crates/engine/src/tests_tints.rs new file mode 100644 index 00000000..f977e985 --- /dev/null +++ b/crates/engine/src/tests_tints.rs @@ -0,0 +1,182 @@ +//! Tints of global and spot colours: applying a swatch at a tint, tints following swatch edits, +//! tint swatches, spot tints on their plate and in PDF, Adjust Color Balance's Global mode. + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_render::proof; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +fn doc(s: &Session) -> &vectorcraft_doc::Document { + &s.doc().unwrap().doc +} + +/// A selected rectangle. +fn rect(s: &mut Session) -> NodeId { + let id = NodeId(run(s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 30}))["id"].as_u64().unwrap()); + run(s, "select.set", json!({"ids": [id.0]})); + id +} + +fn fill(s: &Session, id: NodeId) -> Paint { + doc(s).node(id).unwrap().appearance.fill_paint() +} + +const MAGENTA: Color = Color::Cmyk { c: 0.0, m: 1.0, y: 0.0, k: 0.2 }; + +/// A document with a spot swatch "Ink" (magenta + 20 % black) and a global process "Brand". +fn inks() -> Session { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "Ink", "color": MAGENTA, "spot": true})); + run(&mut s, "swatch.new", json!({"name": "Brand", "color": "#3366cc", "global": true})); + s +} + +fn linked(color: Color, name: &str, tint: f32) -> Paint { + Paint::Solid { color, swatch: Some(name.into()), tint } +} + +#[test] +fn a_swatch_applies_at_a_tint_and_the_tint_follows_swatch_edits() { + let mut s = inks(); + let a = rect(&mut s); + run(&mut s, "paint.setFill", json!({"swatch": "Brand", "tint": 40})); + let brand = Color::from_hex("#3366cc").unwrap(); + assert_eq!(fill(&s, a), linked(brand.tinted(0.4), "Brand", 0.4)); + let undo = s.doc().unwrap().history.undo.len(); + let r = run(&mut s, "swatch.edit", json!({"name": "Brand", "color": "#cc3300"})); + assert_eq!(r["relinked"], 1); + let red = Color::from_hex("#cc3300").unwrap(); + assert_eq!(fill(&s, a), linked(red.tinted(0.4), "Brand", 0.4), "the 40% tint follows the edit"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + // Renaming keeps the tint; deleting unlinks it and the art keeps its colour. + run(&mut s, "swatch.edit", json!({"name": "Brand", "newName": "Logo"})); + assert_eq!(fill(&s, a), linked(red.tinted(0.4), "Logo", 0.4)); + run(&mut s, "swatch.delete", json!({"name": "Logo"})); + assert_eq!(fill(&s, a), Paint::solid(red.tinted(0.4)), "unlinked: tint 100% of its own colour"); + // The proxies report the tint; process colours have none. + run(&mut s, "paint.setFill", json!({"swatch": "Ink", "tint": 25})); + assert_eq!(run(&mut s, "paint.proxies", json!({}))["fill"]["tint"], json!(0.25)); + assert!(s.execute("paint.setFill", &json!({"swatch": "Black", "tint": 50})).unwrap_err().to_string().contains("process")); + assert!(s.execute("paint.setFill", &json!({"color": "#ff0000", "tint": 50})).is_err(), "a tint needs a swatch"); +} + +#[test] +fn new_swatch_saves_a_tint_swatch_linked_to_its_base() { + let mut s = inks(); + let a = rect(&mut s); + run(&mut s, "paint.setFill", json!({"swatch": "Ink", "tint": 40})); + let r = run(&mut s, "swatch.new", json!({})); + assert_eq!(r["name"], "Ink 40%"); + let w = doc(&s).swatch("Ink 40%").unwrap(); + assert_eq!((w.tint_of(), w.global, w.spot), (Some(("Ink", 0.4)), false, false), "its kind is its base's"); + let listed = run(&mut s, "swatch.list", json!({})); + let row = listed["swatches"].as_array().unwrap().iter().find(|w| w["name"] == "Ink 40%").unwrap().clone(); + assert_eq!((row["tintOf"].clone(), row["tint"].clone()), (json!("Ink"), json!(40.0))); + // `tint` alone: that tint of the fill's swatch. + assert_eq!(run(&mut s, "swatch.new", json!({"tint": 70}))["name"], "Ink 70%"); + assert_eq!(run(&mut s, "swatch.new", json!({"swatch": "Brand", "tint": 10}))["name"], "Brand 10%"); + // Applying a tint swatch links to the base at that tint. + let b = rect(&mut s); + run(&mut s, "paint.setFill", json!({"swatch": "Ink 70%"})); + assert_eq!(fill(&s, b), linked(MAGENTA.tinted(0.7), "Ink", 0.7)); + // Editing the base recolours the tint swatch and the art; the tint swatch only takes a name. + run(&mut s, "swatch.edit", json!({"name": "Ink", "color": {"c": 1, "m": 0, "y": 0, "k": 0}})); + let cyan = Color::cmyk(1.0, 0.0, 0.0, 0.0); + assert_eq!(doc(&s).swatch("Ink 70%").unwrap().paint, linked(cyan.tinted(0.7), "Ink", 0.7)); + assert_eq!(fill(&s, a), linked(cyan.tinted(0.4), "Ink", 0.4)); + assert!(s.execute("swatch.edit", &json!({"name": "Ink 70%", "color": "#ff0000"})).is_err()); + run(&mut s, "swatch.edit", json!({"name": "Ink 70%", "newName": "Pale Ink"})); + // A tint swatch is used where its tint of its base is. + let unused = run(&mut s, "swatch.unused", json!({})); + let names: Vec<&str> = unused["names"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); + assert!(!names.contains(&"Pale Ink") && !names.contains(&"Ink 40%") && names.contains(&"Brand 10%"), "{names:?}"); + // A tint asked for in another mode, or as a global colour, is a process copy. + run(&mut s, "select.set", json!({"ids": [a.0]})); + let r = run(&mut s, "swatch.new", json!({"global": true})); + assert!(doc(&s).swatch(r["name"].as_str().unwrap()).unwrap().tint_of().is_none()); +} + +#[test] +fn spot_tints_print_their_percentage_of_the_plate() { + let mut s = inks(); + rect(&mut s); + run(&mut s, "paint.setFill", json!({"swatch": "Ink", "tint": 40})); + let d = doc(&s); + let c = vectorcraft_color::cms::active(); + let Paint::Solid { color, .. } = fill(&s, s.doc().unwrap().selection.objects[0]) else { panic!() }; + let ink = proof::inks(d, &c, &color, Some(("Ink", 0.4)), proof::Intent::RelativeColorimetric); + assert_eq!((ink.cmyk, ink.spot), ([0.0; 4], Some(("Ink".to_string(), 0.4)))); + // A process colour linked to a global swatch separates into process inks. + assert!(proof::inks(d, &c, &color, Some(("Brand", 0.4)), proof::Intent::RelativeColorimetric).spot.is_none()); + // PDF: the Separation value is the tint (102 / 255 = 0.4). + let bytes = vectorcraft_pdf::export(d, &vectorcraft_pdf::PdfOptions { compress: false, created: Some(0), ..Default::default() }).unwrap(); + let pdf = String::from_utf8_lossy(&bytes); + assert!(pdf.contains("/Separation/Ink/DeviceCMYK"), "a spot tint is a Separation"); + assert!(pdf.contains("cs 0.4 scn"), "the tint is the Separation value"); +} + +#[test] +fn global_mode_shifts_tints_of_linked_colours_only() { + let mut s = inks(); + let a = rect(&mut s); + run(&mut s, "paint.setFill", json!({"swatch": "Ink", "tint": 50})); + run(&mut s, "paint.setStroke", json!({"color": "#808080"})); + let b = rect(&mut s); + run(&mut s, "paint.setFill", json!({"swatch": "Brand"})); + run(&mut s, "select.set", json!({"ids": [a.0, b.0]})); + let undo = s.doc().unwrap().history.undo.len(); + let r = run(&mut s, "edit.colors.adjustBalance", json!({"tint": -30})); + assert_eq!(r["changed"], 2, "two linked fills; the grey stroke isn't global"); + let tint_of = |p: Paint| match p { + Paint::Solid { color, swatch, tint } => (color, swatch, tint), + _ => panic!("{p:?}"), + }; + let (color, swatch, tint) = tint_of(fill(&s, a)); + assert!((tint - 0.2).abs() < 1e-6 && swatch.as_deref() == Some("Ink") && color == MAGENTA.tinted(tint), "{tint}"); + let brand = Color::from_hex("#3366cc").unwrap(); + let (color, swatch, tint) = tint_of(fill(&s, b)); + assert!((tint - 0.7).abs() < 1e-6 && swatch.as_deref() == Some("Brand") && color == brand.tinted(tint), "{tint}"); + assert_eq!(doc(&s).node(a).unwrap().appearance.stroke_paint(), Paint::solid(Color::from_hex("#808080").unwrap())); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + // Clamped at 100%; nothing left to change leaves the document alone. + let r = run(&mut s, "edit.colors.adjustBalance", json!({"mode": "global", "tint": 100})); + assert_eq!(r["changed"], 2); + assert_eq!(fill(&s, a), linked(MAGENTA, "Ink", 1.0)); + let r = run(&mut s, "edit.colors.adjustBalance", json!({"mode": "global", "tint": 10})); + assert_eq!(r["changed"], 0); +} + +#[test] +fn tints_round_trip_through_the_native_format() { + let mut s = inks(); + rect(&mut s); + run(&mut s, "paint.setFill", json!({"swatch": "Ink", "tint": 35})); + run(&mut s, "swatch.new", json!({})); + let d = doc(&s).clone(); + let back = vectorcraft_format::load(&vectorcraft_format::save(&d, false)).unwrap(); + let id = s.doc().unwrap().selection.objects[0]; + assert_eq!(back.node(id).unwrap().appearance.fill_paint(), fill(&s, id)); + assert_eq!(back.swatch("Ink 35%"), d.swatch("Ink 35%")); +} + +#[test] +fn converting_the_document_colour_mode_keeps_tints_exact() { + let mut s = inks(); + let a = rect(&mut s); + run(&mut s, "paint.setFill", json!({"swatch": "Brand", "tint": 30})); + run(&mut s, "object.convertDocumentColorMode", json!({"mode": "cmyk"})); + let base = doc(&s).global_color("Brand").unwrap(); + assert!(matches!(base, Color::Cmyk { .. })); + assert_eq!(fill(&s, a), linked(base.tinted(0.3), "Brand", 0.3), "30 % of the converted swatch's inks"); +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index e456c8c7..46715647 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; +use krilla::color::separation::{Color as SepColor, SeparationColorant, SeparationSpace}; use krilla::color::{cmyk, luma, rgb}; use krilla::configure::{Archival, ConfigurationBuilder, PdfVersion}; use krilla::geom::{Path, PathBuilder, Size, Transform}; @@ -242,19 +243,22 @@ impl Exporter<'_> { color(c) } - /// A solid paint, as a Separation colour space when it's linked to a spot swatch (the - /// swatch's CMYK equivalent is the alternate space). - fn solid(&mut self, c: &Color, swatch: Option<&str>) -> krilla::color::Color { - use krilla::color::separation::{Color as SepColor, SeparationColorant, SeparationSpace}; - let spot = swatch.and_then(|n| self.doc.swatch(n).filter(|s| s.spot)).and_then(|s| s.paint.color().map(|sc| (s.name.clone(), sc))); - let Some((name, sc)) = spot else { return self.col(c) }; + /// The Separation colour space of spot swatch `name` (its CMYK equivalent is the alternate + /// space); `None` when `name` isn't a spot colour. + fn separation(&self, name: &str) -> Option { + let sw = self.doc.swatch(name).filter(|s| s.spot)?; let cms = vectorcraft_color::cms::active(); - let intent = cms.settings().intent; - let full = cms.to_cmyk(&sc, intent); - let total: f32 = full.iter().sum(); - let tint = if total <= 1e-4 { 1.0 } else { (cms.to_cmyk(c, intent).iter().sum::() / total).clamp(0.0, 1.0) }; + let full = cms.to_cmyk(&sw.paint.color()?, cms.settings().intent); let alt = krilla::color::RegularColor::Cmyk(cmyk::Color::new(q(full[0]), q(full[1]), q(full[2]), q(full[3]))); - SepColor::new(q(tint), SeparationSpace::new(SeparationColorant::Custom(name), alt)).into() + Some(SeparationSpace::new(SeparationColorant::Custom(sw.name.clone()), alt)) + } + + /// A solid paint, in the Separation colour space at its tint when it's linked to a spot swatch. + fn solid(&mut self, c: &Color, link: Option<&str>, tint: f32) -> krilla::color::Color { + match link.and_then(|n| self.separation(n)) { + Some(space) => SepColor::new(q(tint), space).into(), + None => self.col(c), + } } /// Warn when `effects` (an object's, a fill's or a stroke's) has a visible raster effect. @@ -275,7 +279,7 @@ impl Exporter<'_> { fn paint(&mut self, p: &Paint, bounds: Rect) -> Option { match p { Paint::None => None, - Paint::Solid { color: c, swatch } => Some(self.solid(c, swatch.as_deref()).into()), + Paint::Solid { color: c, swatch, tint } => Some(self.solid(c, swatch.as_deref(), *tint).into()), Paint::Gradient(g) => { let geom = g.resolve(bounds); let mut stops: Vec = Vec::new(); diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index af78fb9f..c962e7a6 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -397,7 +397,7 @@ fn shading_gradient(sp: &hayro_interpret::pattern::ShadingPattern) -> Option GradientStop { let (color, _) = sample(t).unwrap_or((Color::BLACK, 1.0)); - GradientStop { offset, color, opacity: 1.0, midpoint: 0.5 } + GradientStop::new(offset, color) }; let c = |x: f32, y: f32| m * Point::new(x as f64, y as f64); let (kind, geom, stops) = if *axial { diff --git a/crates/render/src/proof.rs b/crates/render/src/proof.rs index 4faa97cd..654c91a0 100644 --- a/crates/render/src/proof.rs +++ b/crates/render/src/proof.rs @@ -3,9 +3,10 @@ //! * **Proof Colors** post-processes the rendered frame: every pixel goes display sRGB → proof //! space (CMYK press or RGB/colour-blindness simulation) → display, through a cached 17³ LUT. //! * **Separations Preview** recolours the *document* before rendering: each paint colour is split -//! into process inks (colour-managed) plus a spot ink when it's linked to a spot swatch. With one -//! plate visible the plate renders as greyscale ink coverage (black = 100%); with several, the -//! visible inks are composited through the proof profile, spots multiplied on top. +//! into process inks (colour-managed), or a spot ink at the colour's tint when it's linked to a +//! spot swatch (solid colours and gradient stops alike). With one plate visible the plate +//! renders as greyscale ink coverage (black = 100%); with several, the visible inks are +//! composited through the proof profile, spots multiplied on top. //! Placed raster images are not separated (limitation). //! * **Overprint Preview** (and Separations Preview, which implies it): fills and strokes that //! overprint ([`vectorcraft_doc::FillLayer::overprint`], characters' too) are drawn with @@ -72,26 +73,26 @@ fn spot_swatch<'a>(doc: &'a Document, name: &str) -> Option<&'a Color> { }) } -/// Separate one colour into inks. Colours linked to a spot swatch print on that plate only, with -/// the tint given by the colour's ink total relative to the swatch's. -pub fn inks(doc: &Document, c: &Cms, color: &Color, swatch: Option<&str>, intent: Intent) -> Inks { - if let Some(name) = swatch - && let Some(sc) = spot_swatch(doc, name) +/// A colour's swatch link and tint (`(swatch name, tint 0..1)`), as the colour visitors pass it. +pub type Link<'a> = Option<(&'a str, f32)>; + +/// Separate one colour into inks. A colour linked to a spot swatch prints on that plate only, at +/// its tint. +pub fn inks(doc: &Document, c: &Cms, color: &Color, link: Link, intent: Intent) -> Inks { + if let Some((name, tint)) = link + && spot_swatch(doc, name).is_some() { - let full: f32 = c.to_cmyk(sc, intent).iter().sum(); - let this: f32 = c.to_cmyk(color, intent).iter().sum(); - let tint = if full <= 1e-4 { 1.0 } else { (this / full).clamp(0.0, 1.0) }; - return Inks { cmyk: [0.0; 4], spot: Some((name.to_string(), tint)) }; + return Inks { cmyk: [0.0; 4], spot: Some((name.to_string(), tint.clamp(0.0, 1.0))) }; } Inks { cmyk: c.to_cmyk(color, intent), spot: None } } -fn map_paint(p: &mut Paint, f: &mut dyn FnMut(&Color, Option<&str>) -> Color) { +fn map_paint(p: &mut Paint, f: &mut dyn FnMut(&Color, Link) -> Color) { match p { - Paint::Solid { color, swatch } => *color = f(color, swatch.as_deref()), + Paint::Solid { color, swatch, tint } => *color = f(color, swatch.as_deref().map(|s| (s, *tint))), Paint::Gradient(g) => { for s in &mut g.gradient.stops { - s.color = f(&s.color, None); + s.color = f(&s.color, s.swatch.as_deref().map(|n| (n, s.tint))); } } _ => {} @@ -99,8 +100,8 @@ fn map_paint(p: &mut Paint, f: &mut dyn FnMut(&Color, Option<&str>) -> Color) { } /// Apply `f` to every colour of a node tree (fills, strokes, gradient stops, text runs, mesh -/// points), keeping swatch links. -pub fn map_node_colors(n: &mut Node, f: &mut dyn FnMut(&Color, Option<&str>) -> Color) { +/// points) given its swatch link and tint, keeping the links. +pub fn map_node_colors(n: &mut Node, f: &mut dyn FnMut(&Color, Link) -> Color) { for it in &mut n.appearance.items { match it { AppearanceItem::Fill(l) => map_paint(&mut l.paint, f), @@ -129,7 +130,7 @@ pub fn map_node_colors(n: &mut Node, f: &mut dyn FnMut(&Color, Option<&str>) -> } /// Apply `f` to every colour in the document's art and symbol definitions. -pub fn map_document_colors(doc: &mut Document, f: &mut dyn FnMut(&Color, Option<&str>) -> Color) { +pub fn map_document_colors(doc: &mut Document, f: &mut dyn FnMut(&Color, Link) -> Color) { for l in &mut doc.layers { map_node_colors(Arc::make_mut(l), f); } @@ -162,8 +163,8 @@ fn multiply_overprints(a: &mut Arc) { } } -fn plate_color(doc: &Document, c: &Cms, proof: &ProofSetup, visible: &[String], color: &Color, swatch: Option<&str>) -> Color { - let ink = inks(doc, c, color, swatch, proof.intent); +fn plate_color(doc: &Document, c: &Cms, proof: &ProofSetup, visible: &[String], color: &Color, link: Link) -> Color { + let ink = inks(doc, c, color, link, proof.intent); let rgb = if visible.len() == 1 { let name = &visible[0]; let v = match PROCESS_PLATES.iter().position(|p| p == name) { diff --git a/crates/render/tests/golden.rs b/crates/render/tests/golden.rs index 7cd2645a..74f3066b 100644 --- a/crates/render/tests/golden.rs +++ b/crates/render/tests/golden.rs @@ -15,7 +15,7 @@ fn render(d: &Document) -> Image { } fn stops(a: Color, b: Color) -> Vec { - vec![GradientStop { offset: 0.0, color: a, opacity: 1.0, midpoint: 0.5 }, GradientStop { offset: 1.0, color: b, opacity: 1.0, midpoint: 0.5 }] + vec![GradientStop::new(0.0, a), GradientStop::new(1.0, b)] } fn gradient(kind: GradientKind, a: Color, b: Color, angle: f64) -> Paint { diff --git a/crates/render/tests/stroke_geometry.rs b/crates/render/tests/stroke_geometry.rs index 8eae3b8c..4176ca5a 100644 --- a/crates/render/tests/stroke_geometry.rs +++ b/crates/render/tests/stroke_geometry.rs @@ -154,10 +154,7 @@ fn line_and_head_take_the_opacity_once() { #[test] fn a_gradient_runs_on_into_the_head() { - let stops = vec![ - GradientStop { offset: 0.0, color: Color::rgb(1.0, 0.0, 0.0), opacity: 1.0, midpoint: 0.5 }, - GradientStop { offset: 1.0, color: Color::rgb(0.0, 0.0, 1.0), opacity: 1.0, midpoint: 0.5 }, - ]; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }))); let d = arrow_doc(Arrowhead::Square, ArrowAlign::Tip, 1.0, |s| s.paint = paint); let h = head_of(&d); diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index ba79008b..dfeea2c6 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -280,7 +280,7 @@ impl Importer { .iter() .map(|s| { let c = s.color(); - GradientStop { offset: s.offset().get(), color: Color::rgb8(c.red, c.green, c.blue), opacity: s.opacity().get(), midpoint: 0.5 } + GradientStop { opacity: s.opacity().get(), ..GradientStop::new(s.offset().get(), Color::rgb8(c.red, c.green, c.blue)) } }) .collect::>() }; diff --git a/crates/ui-egui/src/dialogs/color_balance.rs b/crates/ui-egui/src/dialogs/color_balance.rs index b99a3523..93004107 100644 --- a/crates/ui-egui/src/dialogs/color_balance.rs +++ b/crates/ui-egui/src/dialogs/color_balance.rs @@ -1,9 +1,10 @@ //! Edit → Edit Colors → Adjust Color Balance: the colour mode (Gray, RGB, CMYK or Global), a slider -//! per channel (−100..100 %), Convert, Fill and Stroke, previewed live on the canvas; OK keeps the -//! preview as one undo step (`edit.colors.adjustBalance`). +//! per channel (−100..100 %; Global has one, Tint, for the tints of global and spot colours), +//! Convert, Fill and Stroke, previewed live on the canvas; OK keeps the preview as one undo step +//! (`edit.colors.adjustBalance`). //! //! Fields: `mode` (`gray`, `rgb`, `cmyk` or `global`), the channels `r`, `g`, `b`, `c`, `m`, `y`, -//! `k`, `gray`, then `convert`, `fill`, `stroke` and `preview`. +//! `k`, `gray`, `tint`, then `convert`, `fill`, `stroke` and `preview`. use serde_json::{Value, json}; use vectorcraft_color::Color; @@ -33,13 +34,16 @@ const MODES: [Mode; 4] = [ Mode { id: "gray", label: "Grayscale", channels: &[("gray", "Black")] }, Mode { id: "rgb", label: "RGB", channels: &[("r", "Red"), ("g", "Green"), ("b", "Blue")] }, Mode { id: "cmyk", label: "CMYK", channels: &[("c", "Cyan"), ("m", "Magenta"), ("y", "Yellow"), ("k", "Black")] }, - Mode { id: "global", label: "Global", channels: &[] }, + Mode { id: "global", label: "Global", channels: &[("tint", "Tint")] }, ]; +/// Global mode: it shifts the tints of linked colours, which keep their model (no Convert). +const GLOBAL: &str = "global"; + /// Open the dialog with every channel at 0, adjusting fills and strokes. pub fn open(app: &mut VectorcraftApp) { let fields = json!({ - "mode": "rgb", "r": 0, "g": 0, "b": 0, "c": 0, "m": 0, "y": 0, "k": 0, "gray": 0, + "mode": "rgb", "r": 0, "g": 0, "b": 0, "c": 0, "m": 0, "y": 0, "k": 0, "gray": 0, "tint": 0, "convert": false, "fill": true, "stroke": true, "preview": true, }); app.ui.dialog = Some(Dialog::new(KIND, fields)); @@ -85,16 +89,16 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { d.fields.insert("mode".into(), json!(MODES[i].id)); } ui.add_space(12.0); - if widgets::check(ui, "Convert", d.bool("convert"), !m.channels.is_empty()) { + if widgets::check(ui, "Convert", d.bool("convert"), m.id != GLOBAL) { d.fields.insert("convert".into(), json!(!d.bool("convert"))); } }); ui.add_space(8.0); - let channels = mode(d).channels; - if channels.is_empty() { - widgets::dim_label(ui, "Global shifts the tints of global and spot colors (not available yet)."); + let m = mode(d); + if m.id == GLOBAL { + widgets::dim_label(ui, "Shifts the tints of global and spot colors; other colors stay."); } - for (k, label) in channels { + for (k, label) in m.channels { form::slider(ui, d, k, label, -100.0..=100.0, "%", &|x| track(k, x)); } ui.add_space(8.0); @@ -166,15 +170,22 @@ mod tests { } #[test] - fn global_mode_reports_why_and_stays_open() { + fn global_mode_shifts_the_tints_of_global_colours() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); - app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap(); + app.run("swatch.new", json!({"name": "Ink", "color": {"c": 0, "m": 1, "y": 0, "k": 0}, "spot": true})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + app.run("paint.setFill", json!({"swatch": "Ink", "tint": 50})).unwrap(); app.run("ui.colorBalanceDialog", json!({})).unwrap(); - app.ui.dialog.as_mut().unwrap().fields.insert("mode".into(), json!("global")); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("mode".into(), json!("global")); + d.fields.insert("tint".into(), json!(-20)); frame(&mut app); - let e = super::super::confirm(&mut app).unwrap_err(); - assert!(e.contains("global"), "{e}"); - assert!(app.ui.dialog.is_some()); + assert_eq!(params(app.ui.dialog.as_ref().unwrap())["tint"], json!(-20.0)); + super::super::confirm(&mut app).unwrap(); + let n = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone(); + let vectorcraft_color::Paint::Solid { swatch, tint, .. } = n.appearance.fill_paint() else { panic!() }; + assert_eq!(swatch.as_deref(), Some("Ink"), "the colour stays linked"); + assert!((tint - 0.3).abs() < 1e-6, "{tint}"); } } diff --git a/crates/ui-egui/src/dialogs/gradient_stop.rs b/crates/ui-egui/src/dialogs/gradient_stop.rs index 01d0ce0d..2026a1a5 100644 --- a/crates/ui-egui/src/dialogs/gradient_stop.rs +++ b/crates/ui-egui/src/dialogs/gradient_stop.rs @@ -84,7 +84,7 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { } ui.add_space(6.0); ui.horizontal(|ui| { - let s = stops[i]; + let s = &stops[i]; widgets::dim_label(ui, "Opacity:"); if let Some(v) = widgets::plain_field(ui, "stop-pop-op", s.opacity as f64 * 100.0, "%", 0, 54.0) { let mut v2 = stops.clone(); diff --git a/crates/ui-egui/src/dialogs/new_swatch.rs b/crates/ui-egui/src/dialogs/new_swatch.rs index 3adfea6a..3af0608e 100644 --- a/crates/ui-egui/src/dialogs/new_swatch.rs +++ b/crates/ui-egui/src/dialogs/new_swatch.rs @@ -1,10 +1,10 @@ //! New Swatch: the Swatch Options fields (name, colour type, Global, mode, sliders and hex) -//! prefilled from the active fill or stroke; a gradient or pattern only takes a name. OK runs -//! `swatch.new`. +//! prefilled from the active fill or stroke; a gradient, a pattern or a tint of a global colour +//! (saved as a tint swatch, "Name 40%") only takes a name. OK runs `swatch.new`. //! //! Fields: `name`, `spot`, `global`, `mode` and `color` as in Swatch Options, `group` (the colour -//! group the colour goes into; empty for none), `__paint` (the `swatch.new` paint of a gradient or -//! pattern; absent for a colour) and `__auto` (the default name, which follows the colour until +//! group the colour goes into; empty for none), `__paint` (the `swatch.new` paint of a gradient, +//! pattern or tint; absent for a colour) and `__auto` (the default name, which follows the colour until //! the name is edited). use serde_json::{Value, json}; @@ -26,6 +26,9 @@ pub fn open(app: &mut VectorcraftApp, spot: bool, group: Option<&str>) -> Result let paint = crate::panels::active_paint(app); let name = app.session.active().ok_or("no document open")?.doc.new_swatch_name(&paint); let mut fields = match &paint { + Paint::Solid { swatch: Some(_), tint, .. } if *tint < 1.0 => { + json!({"__paint": crate::panels::paint_params(&paint), "group": group.unwrap_or_default()}) + } Paint::Solid { color, .. } => { json!({"color": color, "mode": mode_id(Mode::of(color)), "spot": spot, "global": spot, "group": group.unwrap_or_default()}) } @@ -82,4 +85,18 @@ mod tests { let d = Dialog::new(KIND, json!({"name": "Dots", "__paint": {"pattern": "Dots"}, "group": ""})); assert_eq!(params(&d), json!({"name": "Dots", "pattern": "Dots"})); } + + #[test] + fn a_tint_opens_as_a_named_tint_swatch() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("swatch.new", json!({"name": "Ink", "color": "#cc0066", "spot": true})).unwrap(); + app.run("paint.setFill", json!({"swatch": "Ink", "tint": 40})).unwrap(); + open(&mut app, false, None).unwrap(); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!(params(&d), json!({"name": "Ink 40%", "swatch": "Ink", "tint": 40.0})); + run_and_close(&mut app, "swatch.new", params(&d)).unwrap(); + let w = app.session.doc().unwrap().doc.swatch("Ink 40%").cloned().unwrap(); + assert_eq!(w.tint_of(), Some(("Ink", 0.4))); + } } diff --git a/crates/ui-egui/src/dialogs/swatch_options.rs b/crates/ui-egui/src/dialogs/swatch_options.rs index dcd9f93e..1823e228 100644 --- a/crates/ui-egui/src/dialogs/swatch_options.rs +++ b/crates/ui-egui/src/dialogs/swatch_options.rs @@ -53,10 +53,18 @@ fn set_color(d: &mut Dialog, c: Color) { d.fields.insert("color".into(), json!(c)); } -/// Open the editor of swatch `name`: Swatch Options for a colour or a gradient (its name only), -/// pattern editing for a pattern. +/// Open the editor of swatch `name`: Swatch Options for a colour (a tint swatch's base) or a +/// gradient (its name only), pattern editing for a pattern. pub fn open(app: &mut VectorcraftApp, name: &str) -> Result { - let sw = app.session.active().and_then(|st| st.doc.swatch(name).cloned()).ok_or_else(|| format!("no swatch `{name}`"))?; + let d = app.session.active().map(|st| &st.doc); + let sw = d.and_then(|d| d.swatch(name)).ok_or_else(|| format!("no swatch `{name}`"))?; + if let Some((base, _)) = sw.tint_of() + && d.is_some_and(|d| d.swatch(base).is_some()) + { + let base = base.to_string(); + return open(app, &base); + } + let sw = sw.clone(); match &sw.paint { Paint::Solid { color, .. } => { let fields = json!({ diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index adb056c4..82a8d376 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -211,7 +211,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.colorBalanceDialog", "Adjust Color Balance…", "", - "{} open Adjust Colors for the selection (dialog `colorBalance`, fields `mode` gray|rgb|cmyk|global, channels r g b / c m y k / gray −100..100, `convert`, `fill`, `stroke`, `preview`): previews live, OK runs edit.colors.adjustBalance as one undo step", + "{} open Adjust Colors for the selection (dialog `colorBalance`, fields `mode` gray|rgb|cmyk|global, channels r g b / c m y k / gray / tint (global) −100..100, `convert`, `fill`, `stroke`, `preview`): previews live, OK runs edit.colors.adjustBalance as one undo step", ), ( "ui.saturateDialog", diff --git a/crates/ui-egui/src/panels/color.rs b/crates/ui-egui/src/panels/color.rs index 0c2f3bb9..07cfee6d 100644 --- a/crates/ui-egui/src/panels/color.rs +++ b/crates/ui-egui/src/panels/color.rs @@ -1,7 +1,9 @@ //! Color panel: Grayscale / RGB / HSB / CMYK / Web Safe RGB sliders with colour-gradient tracks, //! value fields, hex field, None/Black/White chips, spectrum ramp and the Fill/Stroke proxy. //! When the active paint is a gradient, the sliders edit the Gradient panel's selected stop (on a -//! freeform gradient, its selected point). +//! freeform gradient, its selected point). A colour (or stop) linked to a global or spot swatch +//! shows Tint mode instead: the swatch's chip and name and a T slider (0–100 % of the swatch); +//! picking a mode from the menu makes it a process colour. //! //! The mode follows the colour's own model (the last mode picked comes back for colours in its //! model). Alt-clicking the spectrum or a chip paints the inactive proxy; Shift-clicking the @@ -259,27 +261,53 @@ fn to32(c: &Color) -> Color32 { super::c32(c) } -/// What the panel is editing: the active proxy's solid colour, a selected gradient stop or a -/// selected freeform point. +/// The global or spot swatch a colour is a tint of (Tint mode). +struct Tint { + swatch: String, + spot: bool, + /// 0..1. + tint: f32, + /// The swatch's colour (100 %). + base: Color, +} + +impl Tint { + /// The tint `link` (a swatch name) and `tint` show: `None` unless the swatch is a global colour. + fn of(app: &VectorcraftApp, link: Option<&str>, tint: f32) -> Option { + let name = link?; + let d = &app.session.active()?.doc; + let spot = d.swatch(name)?.spot; + Some(Self { swatch: name.to_string(), spot, tint, base: d.global_color(name)? }) + } +} + +/// What the panel is editing: the active proxy's solid colour or a selected gradient stop (with +/// the global swatch it is a tint of), or a selected freeform point. enum Target { - Paint(Option), - Stop { paint: Paint, index: usize, color: Color }, + Paint(Option, Option), + Stop { paint: Paint, index: usize, color: Color, tint: Option }, Point { index: usize, color: Color }, } impl Target { fn color(&self) -> Option { match self { - Target::Paint(c) => *c, + Target::Paint(c, _) => *c, Target::Stop { color, .. } | Target::Point { color, .. } => Some(*color), } } + fn tint(&self) -> Option<&Tint> { + match self { + Target::Paint(_, t) | Target::Stop { tint: t, .. } => t.as_ref(), + Target::Point { .. } => None, + } + } } /// The active proxy's colour or gradient stop; no colour for None, patterns and a "?" proxy. fn target(app: &VectorcraftApp, ui: &Ui) -> Target { if super::active_mixed(app, ui.ctx()) { - return Target::Paint(None); + return Target::Paint(None, None); } let p = active_paint(app); match &p { @@ -291,11 +319,11 @@ fn target(app: &VectorcraftApp, ui: &Ui) -> Target { } Paint::Gradient(g) => { let i = app.session.selected_stop().unwrap_or(0).min(g.gradient.stops.len().saturating_sub(1)); - let color = g.gradient.stops.get(i).map(|s| s.color).unwrap_or(Color::BLACK); - Target::Stop { paint: p.clone(), index: i, color } + let (color, tint) = g.gradient.stops.get(i).map_or((Color::BLACK, None), |s| (s.color, Tint::of(app, s.swatch.as_deref(), s.tint))); + Target::Stop { paint: p.clone(), index: i, color, tint } } - Paint::Solid { color, .. } => Target::Paint(Some(*color)), - _ => Target::Paint(None), + Paint::Solid { color, swatch, tint } => Target::Paint(Some(*color), Tint::of(app, swatch.as_deref(), *tint)), + _ => Target::Paint(None, None), } } @@ -306,7 +334,7 @@ fn apply(app: &mut VectorcraftApp, tgt: &Target, c: Color, phase: Live, behind: Target::Stop { paint: Paint::Gradient(g), index, .. } if !behind => { let mut stops = g.gradient.stops.clone(); if let Some(s) = stops.get_mut(*index) { - s.color = c; + s.set_color(c, None); } super::gradient::set_stops(app, &stops, None, phase); } @@ -322,17 +350,59 @@ fn apply(app: &mut VectorcraftApp, tgt: &Target, c: Color, phase: Live, behind: } } -/// Switch the panel to `mode`, converting the colour to its model. +/// Set the target's tint of its swatch to `t` (0..1), keeping the link (the T slider). +fn apply_tint(app: &mut VectorcraftApp, tgt: &Target, tint: &Tint, t: f32, phase: Live) { + match tgt { + Target::Stop { paint: Paint::Gradient(g), index, .. } => { + let mut stops = g.gradient.stops.clone(); + if let Some(s) = stops.get_mut(*index) { + s.set_color(tint.base.tinted(t), Some((tint.swatch.clone(), t))); + } + super::gradient::set_stops(app, &stops, None, phase); + } + _ => { + let cmd = super::proxy_cmd(app, false); + live_run(app, "Color", cmd, json!({"swatch": tint.swatch, "tint": t * 100.0}), phase); + } + } +} + +/// Switch the panel to `mode`, converting the colour to its model (a tint becomes a process +/// colour). fn set_mode(app: &mut VectorcraftApp, ctx: &egui::Context, tgt: &Target, mode: Mode) { set_pstate(ctx, "color-mode", Some(mode)); if let Some(c) = tgt.color() { let conv = convert_to(mode, &c); - if conv != c { + if conv != c || tgt.tint().is_some() { apply(app, tgt, conv, Live::Released, false); } } } +/// Tint mode's rows: the swatch's chip and name, and the T slider and field. Returns a new tint +/// (0..1) and its phase. +fn tint_rows(ui: &mut Ui, tint: &Tint, slider_w: f32, field_w: f32) -> Option<(f32, Live)> { + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); + widgets::swatch_tile(ui, r, &Paint::solid(tint.base), false, false); + super::swatches::global_mark(ui, r, tint.spot); + ui.label(egui::RichText::new(&tint.swatch).size(12.5).color(t.text)); + }); + ui.horizontal(|ui| { + ui.add_sized(vec2(12.0, 22.0), egui::Label::new(egui::RichText::new("T").size(12.5).color(t.text))); + let track = |x: f32| to32(&tint.base.tinted(x)); + let (nv, phase) = widgets::color_slider(ui, ("color-slider", "tint"), tint.tint, slider_w, &track); + let field = widgets::plain_field(ui, ("color-field", "tint"), (tint.tint * 100.0).round() as f64, "%", 0, field_w); + match (nv, field) { + (Some(v), _) => Some(((v * 100.0).round() / 100.0, phase)), + (None, Some(v)) => Some(((v as f32 / 100.0).clamp(0.0, 1.0), Live::Released)), + _ => None, + } + }) + .inner +} + /// What happened on the spectrum ramp. enum SpectrumHit { Pick(Color, Live), @@ -391,6 +461,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // Alt held for the inactive proxy (`behind`). let mut new: Option<(Color, Live, Vec)> = None; let mut behind: Option<(Color, Live)> = None; + let mut tinted: Option<(f32, Live)> = None; let gamut = color.filter(|_| matches!(mode, Mode::Rgb | Mode::Hsb)).and_then(|c| gamut_fix(app, ui.ctx(), "color-gamut", &c)); ui.horizontal(|ui| { // Left column: proxy, out-of-gamut and out-of-web warnings. @@ -412,6 +483,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let shift = ui.input(|i| i.modifiers.shift); let field_w = 48.0; let slider_w = (ui.available_width() - field_w - 24.0).max(60.0); + if let Some(tint) = tgt.tint() { + tinted = tint_rows(ui, tint, slider_w, field_w); + return; + } let enabled = color.is_some(); for (i, lbl) in mode.labels().iter().enumerate() { ui.horizontal(|ui| { @@ -489,6 +564,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if let Some((c, phase)) = behind { apply(app, &tgt, c, phase, true); } + if let (Some((t, phase)), Some(tint)) = (tinted, tgt.tint()) { + apply_tint(app, &tgt, tint, t, phase); + } if let Some((c, phase, comps2)) = new { let key = (c.to_hex(), mode as u8); set_pstate(ui.ctx(), "color-comps", Some((key, comps2))); @@ -504,9 +582,10 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { ui.separator(); let tgt = target(app, ui); let color = tgt.color(); + // Tint mode checks no colour mode: picking one makes the tint a process colour. let cur = display_mode(pstate(ui.ctx(), "color-mode"), color.as_ref()); for (m, label) in Mode::ALL { - if menu_item(ui, label, true, m == cur) { + if menu_item(ui, label, true, m == cur && tgt.tint().is_none()) { set_mode(app, ui.ctx(), &tgt, m); } } @@ -531,7 +610,12 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if menu_item(ui, "Create New Swatch…", color.is_some(), false) && let Some(c) = color { - app.run("swatch.new", json!({"color": color_json(&c)})).ok(); + // A tint saves a tint swatch ("Name 40%"). + let p = match tgt.tint() { + Some(t) => json!({"swatch": t.swatch, "tint": t.tint * 100.0}), + None => json!({"color": color_json(&c)}), + }; + app.run("swatch.new", p).ok(); } if menu_item(ui, "Copy Color Value (Hex)", color.is_some(), false) && let Some(c) = color @@ -691,6 +775,54 @@ mod tests { assert!(d.bool("stroke")); } + #[test] + fn a_global_colour_shows_tint_mode_and_the_t_slider_sets_its_tint() { + let mut app = app(); + let ctx = egui::Context::default(); + app.run("swatch.new", json!({"name": "Ink", "color": {"c": 0, "m": 1, "y": 0, "k": 0}, "spot": true})).unwrap(); + app.run("paint.setFill", json!({"swatch": "Ink", "tint": 50})).unwrap(); + frame(&mut app, &ctx, vec![], egui::Modifiers::NONE); + let _ = ctx.run_ui(Default::default(), |ui| { + let tgt = target(&app, ui); + let t = tgt.tint().expect("Tint mode"); + assert_eq!((t.swatch.as_str(), t.spot, t.tint, t.base), ("Ink", true, 0.5, Color::cmyk(0.0, 1.0, 0.0, 0.0))); + }); + // The chip's name and the T row are drawn; a click on the slider's right part raises the + // tint and keeps the link. + let texts = |app: &mut VectorcraftApp, events: Vec| { + fn walk(s: &egui::Shape, out: &mut Vec<(String, egui::Pos2)>) { + match s { + egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.pos + t.galley.rect.center().to_vec2())), + egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + _ => {} + } + } + let screen = Rect::from_min_size(egui::Pos2::ZERO, vec2(260.0, 600.0)); + let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: Some(screen), ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + let mut v = vec![]; + out.shapes.iter().for_each(|c| walk(&c.shape, &mut v)); + v + }; + let drawn = texts(&mut app, vec![]); + let at = |label: &str| drawn.iter().find(|(t, _)| t == label).map(|(_, p)| *p); + assert!(at("Ink").is_some(), "the swatch's name: {drawn:?}"); + let (t_label, field) = (at("T").expect("the T row"), at("50%").expect("the tint field")); + let pos = egui::pos2(field.x - 40.0, t_label.y); + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + texts(&mut app, vec![egui::Event::PointerMoved(pos), button(true)]); + texts(&mut app, vec![button(false)]); + let Paint::Solid { swatch, tint, .. } = crate::panels::current_paints(&app).0 else { panic!() }; + assert_eq!(swatch.as_deref(), Some("Ink")); + assert!(tint > 0.6 && tint < 1.0, "{tint}"); + // Picking a mode makes it a process colour. + let _ = ctx.run_ui(Default::default(), |ui| { + let tgt = target(&app, ui); + set_mode(&mut app, ui.ctx(), &tgt, Mode::Cmyk); + }); + assert!(matches!(crate::panels::current_paints(&app).0, Paint::Solid { swatch: None, .. })); + } + #[test] fn hide_options_leaves_the_proxy_and_spectrum() { let mut app = app(); diff --git a/crates/ui-egui/src/panels/gradient.rs b/crates/ui-egui/src/panels/gradient.rs index 99bd38a0..9e4ba766 100644 --- a/crates/ui-egui/src/panels/gradient.rs +++ b/crates/ui-egui/src/panels/gradient.rs @@ -365,7 +365,7 @@ fn stop_fields(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: boo let stops = &g.stops; let sel = app.session.selected_stop().filter(|i| *i < stops.len() && is_grad); let mid = selected_mid(app, ui.ctx(), stops.len()).filter(|_| is_grad); - let stop = sel.map(|i| stops[i]); + let stop = sel.map(|i| &stops[i]); if let (Some(i), Some(s)) = (sel, stop) { ui.horizontal(|ui| { widgets::dim_label(ui, "Color:"); diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index cdb7fdc7..3275eb3f 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -378,10 +378,12 @@ pub(crate) fn recent_colors_row(app: &mut VectorcraftApp, ui: &mut Ui) { /// A colour as command JSON, keeping its model. pub(crate) use vectorcraft_tools::params::color_json; -/// Paint as command params (`{color}`, `{swatch}`, `{none}`, `{gradient}` (lossless) or `{pattern}`). +/// Paint as command params (`{color}`, `{swatch, tint?}`, `{none}`, `{gradient}` (lossless) or +/// `{pattern}`). pub(crate) fn paint_params(p: &Paint) -> Value { match p { Paint::None => json!({"none": true}), + Paint::Solid { swatch: Some(n), tint, .. } if *tint < 1.0 => json!({"swatch": n, "tint": tint * 100.0}), Paint::Solid { swatch: Some(n), .. } => json!({"swatch": n}), Paint::Solid { color, .. } => json!({"color": color_json(color)}), Paint::Gradient(g) => json!({"gradient": vectorcraft_tools::params::gradient_params(g)}), @@ -423,6 +425,8 @@ mod tests { assert_eq!(p["gradient"]["stops"].as_array().unwrap().len(), 2); assert_eq!(paint_params(&vectorcraft_doc::pattern::pattern_paint("Dots")), json!({"pattern": "Dots"})); assert_eq!(p["gradient"]["stops"][0]["midpoint"], json!(0.5)); + let tint = Paint::Solid { color: Color::gray(0.5), swatch: Some("Ink".into()), tint: 0.5 }; + assert_eq!(paint_params(&tint), json!({"swatch": "Ink", "tint": 50.0}), "a tint keeps its percentage"); } } #[cfg(test)] diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index ab91c3a6..31e1b5be 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -116,7 +116,11 @@ fn entries(app: &VectorcraftApp, kind: Kind, query: &str) -> Vec { let q = query.trim().to_lowercase(); let found = |n: &str| q.is_empty() || n.to_lowercase().contains(&q); let mut out = vec![]; - let sw = |s: &vectorcraft_color::Swatch| Entry::Swatch { name: s.name.clone(), paint: s.paint.clone(), global: s.global, spot: s.spot }; + // A tint swatch shows its base's kind (global or spot). + let sw = |s: &vectorcraft_color::Swatch| { + let (global, spot) = s.tint_of().and_then(|(base, _)| d.swatch(base)).map_or((s.global, s.spot), |b| (b.global, b.spot)); + Entry::Swatch { name: s.name.clone(), paint: s.paint.clone(), global, spot } + }; // None first, then Registration, then the rest (the reference app's order). let (specials, rest): (Vec<_>, Vec<_>) = d.swatches.iter().partition(|s| s.paint.is_none()); for s in specials.iter().filter(|s| kind.accepts(&s.paint, false) && found(&s.name)) { @@ -616,12 +620,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, salt: &'static str) { widgets::subheader(ui, "Swatch Tiles"); let query = if pstate(ui.ctx(), "swatch-show-find") { widgets::search_field(ui, find_id(), "Find") } else { String::new() }; let items = entries(app, kind, &query); - let active = active_paint(app); - let active_swatch = match &active { - Paint::Solid { swatch: Some(n), .. } => Some(n.clone()), - Paint::None => Some("[None]".to_string()), - _ => None, - }; + let active_swatch = active_swatch(app); let selected = selection(app, ui); // Without a selection the swatch of the active paint is highlighted. let fallback: Vec = if selected.is_empty() { active_swatch.into_iter().collect() } else { vec![] }; @@ -693,6 +692,20 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, salt: &'static str) { bottom(app, ui, &selected); } +/// The swatch the active paint comes from: a linked colour's global swatch (a tint's tint swatch +/// when there is one), or None. +fn active_swatch(app: &VectorcraftApp) -> Option { + match active_paint(app) { + Paint::Solid { swatch: Some(n), tint, .. } => { + let d = &app.session.active()?.doc; + let tint_swatch = d.swatches_iter().find(|w| w.tint_of() == Some((n.as_str(), tint))); + Some(tint_swatch.map_or(n.clone(), |w| w.name.clone())) + } + Paint::None => Some("[None]".to_string()), + _ => None, + } +} + /// The thumbnail cells of `items` for `view` in the full available width, row by row; a folder /// (`is_folder`) starts a new row. pub(crate) fn tile_grid<'a, T>(ui: &mut Ui, items: &'a [T], view: View, is_folder: impl Fn(&T) -> bool) -> Vec<(&'a T, Rect)> { @@ -1180,7 +1193,7 @@ mod tests { let doc = &app.session.doc().unwrap().doc; assert_eq!(doc.swatch("Signal").and_then(|w| w.paint.color()).map(|c| c.to_hex()), Some("#00ff00".into())); let fill = doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().appearance.fill_paint(); - assert_eq!(fill, Paint::Solid { color: Color::from_hex("#00ff00").unwrap(), swatch: Some("Signal".into()) }); + assert_eq!(fill, Paint::Solid { color: Color::from_hex("#00ff00").unwrap(), swatch: Some("Signal".into()), tint: 1.0 }); assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); } @@ -1309,7 +1322,7 @@ mod tests { let g = doc.swatch_groups.iter().find(|g| g.name == "Color Group 2").expect("group made"); assert_eq!(g.swatches.iter().map(|w| (w.name.as_str(), w.global)).collect::>(), [("R=18 G=52 B=86", true)]); let fill = doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().appearance.fill_paint(); - assert_eq!(fill, Paint::Solid { color: Color::from_hex("#123456").unwrap(), swatch: Some("R=18 G=52 B=86".into()) }); + assert_eq!(fill, Paint::Solid { color: Color::from_hex("#123456").unwrap(), swatch: Some("R=18 G=52 B=86".into()), tint: 1.0 }); } /// The centre of the tile or row of `name` as laid out by the last frame. @@ -1425,10 +1438,8 @@ mod tests { let ctx = context(); frame(&mut app, &ctx, vec![], 0.0, show); let grad = |c: &str| { - let stops = vec![ - vectorcraft_color::GradientStop { offset: 0.0, color: Color::from_hex(c).unwrap(), opacity: 1.0, midpoint: 0.5 }, - vectorcraft_color::GradientStop { offset: 1.0, color: Color::WHITE, opacity: 1.0, midpoint: 0.5 }, - ]; + let stops = + vec![vectorcraft_color::GradientStop::new(0.0, Color::from_hex(c).unwrap()), vectorcraft_color::GradientStop::new(1.0, Color::WHITE)]; Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient { kind: GradientKind::Radial, stops }))) }; let drop_on = |app: &mut VectorcraftApp, name: &str, paint: Paint, m: Modifiers, time: f64| { @@ -1470,6 +1481,21 @@ mod tests { assert!(!Kind::Groups.accepts(&solid, false)); } + #[test] + fn the_active_tint_swatch_is_highlighted_and_a_tint_edits_its_base() { + let mut app = app(); + app.run("swatch.new", json!({"name": "Ink", "color": "#cc0066", "spot": true})).unwrap(); + app.run("paint.setFill", json!({"swatch": "Ink", "tint": 40})).unwrap(); + assert_eq!(active_swatch(&app).as_deref(), Some("Ink"), "no tint swatch yet: its base"); + app.run("swatch.new", json!({})).unwrap(); + assert_eq!(active_swatch(&app).as_deref(), Some("Ink 40%")); + let tint = entries(&app, Kind::Color, "Ink 40").into_iter().find(|e| e.name() == "Ink 40%").unwrap(); + assert!(matches!(tint, Entry::Swatch { global: true, spot: true, .. }), "a tint swatch shows its base's kind"); + app.run("ui.swatchOptions", json!({"name": "Ink 40%"})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.kind.as_str(), d.str("__swatch").as_str()), (KIND, "Ink"), "a tint swatch opens its base's options"); + } + #[test] fn medium_tiles_match_measured_metrics() { assert_eq!(View::MediumThumb.tile(), (15.5, 17.0)); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 66223797..1a523d4a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -76,7 +76,7 @@ as `ui.color_guide` (`variation`, `steps`, `amount`). Save Colors as Swatches ru (one swatch per colour, one undo step). Edit Colors dialogs: `ui.colorBalanceDialog` opens Adjust Colors (`colorBalance`: fields `mode` `gray`/`rgb`/`cmyk`/ -`global`, the channels `r` `g` `b` / `c` `m` `y` `k` / `gray` from −100 to 100, `convert`, `fill`, `stroke`, +`global`, the channels `r` `g` `b` / `c` `m` `y` `k` / `gray` / `tint` (global mode) from −100 to 100, `convert`, `fill`, `stroke`, `preview`) and `ui.saturateDialog` opens Saturate (`saturate`: `intensity` −100..100, `preview`). Both preview on the canvas while open; `ui.dialog.confirm` keeps the result as one undo step (`edit.colors.adjustBalance` / `edit.colors.saturate`) and `ui.dialog.cancel` rolls it back. Global mode answers with an error until tints of global diff --git a/docs/mcp.md b/docs/mcp.md index 844f9458..7aaeeb21 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -428,3 +428,19 @@ fills and strokes become objects among its members, and its members are expanded ```json {"name":"run_command","arguments":{"command":"effect.expandAppearance","params":{"ids":[12]}}} ``` + +## Tints of global and spot colours + +A colour linked to a global or spot swatch has a tint (the reference app's T slider): `paint.setFill {swatch, +tint?: 0..100}` (and `paint.setStroke`) applies the swatch at that percentage, linked, so `swatch.edit` recolours it +at its own tint. `paint.proxies` and `document.inspect` show the paint as `{type: "solid", color, swatch, tint}` +(`tint` 0..1, left out at 100 %). `swatch.new {tint?}` with the current fill (or `swatch`) saves a tint swatch, +"Name 40%": `swatch.list` reports it with `tintOf` and `tint`, applying it links to its base at that tint, and it +follows edits of its base. A spot tint prints that percentage of its plate (Separations Preview, PDF Separation +value). `edit.colors.adjustBalance {tint: -100..100}` (Global mode) shifts the tints of the selection's linked +colours and leaves the rest alone. + +```json +{"name":"run_command","arguments":{"command":"paint.setFill","params":{"swatch":"Ink","tint":40}}} +{"name":"run_command","arguments":{"command":"edit.colors.adjustBalance","params":{"mode":"global","tint":-20}}} +```