From fca068a0a7919121c2f250c4a1051ad047e940aa Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 03:11:03 +0700 Subject: [PATCH 1/2] M3.78: Recolor Artwork with colour reduction, methods, presets and Limit to Library Recolor Artwork keys colours by identity (model and exact values), so CMYK colours stay CMYK and lookalikes stay apart. recolor.reduce groups the selection's colours into rows (k-means in Lab, weighted by use; tints of a global swatch share its row) with Preserve White/Black/Grays and Limit to Library; recolor.apply maps rows by Exact, Preserve Tints, Scale Tints, Tints and Shades or Hue Shift; recolor.randomize shuffles or varies new colours. The dialog has Assign (rows with merge, separate, exclude, randomize, method, library) and Edit (hue/saturation wheel with linked or unlinked handles, brightness, harmony rules) tabs, and Edit Colors gains Recolor with Preset (1/2/3 Color Job, Color Library). Colour parameters accept colour keys. Co-Authored-By: Claude Opus 5.5 --- crates/color/src/lib.rs | 1 + crates/color/src/recolor.rs | 586 +++++++++++++++++++ crates/engine/src/cmd/mod.rs | 8 +- crates/engine/src/cmd/recolor.rs | 301 +++++++++- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_recolor.rs | 224 ++++++++ crates/ui-egui/src/dialogs/mod.rs | 4 +- crates/ui-egui/src/dialogs/recolor.rs | 679 ++++++++++++++++++++--- crates/ui-egui/src/dialogs/tests.rs | 1 + crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menus.rs | 25 +- crates/ui-egui/src/panels/color_guide.rs | 6 +- crates/ui-egui/src/tests_recolor.rs | 111 ++++ docs/control-protocol.md | 7 + docs/mcp.md | 18 + 15 files changed, 1868 insertions(+), 107 deletions(-) create mode 100644 crates/color/src/recolor.rs create mode 100644 crates/engine/src/tests_recolor.rs create mode 100644 crates/ui-egui/src/tests_recolor.rs diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 5f12e897a..4b2c6b7d2 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -11,6 +11,7 @@ pub mod gradient; pub mod harmony; pub mod libraries; pub mod palette_io; +pub mod recolor; pub mod swatch; pub use blend::BlendMode; diff --git a/crates/color/src/recolor.rs b/crates/color/src/recolor.rs new file mode 100644 index 000000000..16ad2aa20 --- /dev/null +++ b/crates/color/src/recolor.rs @@ -0,0 +1,586 @@ +//! Recolor Artwork's colour math (our own): colour identity keys, the White/Black/Grays preserve +//! rules, the methods that map a row of current colours to its new colour, colour reduction by +//! clustering in Lab, the nearest colour of a library and the randomizers. + +use std::fmt; + +use crate::cms::{Lab, delta_e2000}; +use crate::{Color, keep_model}; + +/// A colour's identity: its model and exact components (to a hundredth of an RGB level, a percent +/// or a Lab unit), so colours that only look alike (a CMYK red and an RGB red) stay apart and keep +/// their model. Written as `rgb 255 128 0` (0–255), `cmyk 0 100 100 0`, `gray 40` (percent) or +/// `lab 55 60 -40` (L 0–100, a and b). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ColorKey { + model: u8, + v: [i32; 4], +} + +/// Key resolution: RGB components are kept to 1/RGB_Q, CMYK and Gray ones to 1/INK_Q and Lab ones +/// to 1/LAB_Q (hundredths of the written units). +const RGB_Q: f32 = 25_500.0; +const INK_Q: f32 = 10_000.0; +const LAB_Q: f32 = 100.0; + +impl ColorKey { + pub fn of(c: &Color) -> Self { + let rgb = |x: f32| (x.clamp(0.0, 1.0) * RGB_Q).round() as i32; + let ink = |x: f32| (x.clamp(0.0, 1.0) * INK_Q).round() as i32; + match *c { + Color::Rgb { r, g, b } => Self { model: 0, v: [rgb(r), rgb(g), rgb(b), 0] }, + Color::Cmyk { c, m, y, k } => Self { model: 1, v: [ink(c), ink(m), ink(y), ink(k)] }, + Color::Gray { k } => Self { model: 2, v: [ink(k), 0, 0, 0] }, + Color::Lab { l, a, b } => { + let lab = |x: f32| (x * LAB_Q).round() as i32; + Self { model: 3, v: [lab(l.clamp(0.0, 100.0)), lab(a), lab(b), 0] } + } + } + } + + /// The colour this key names. + pub fn color(&self) -> Color { + let f = |i: usize| self.v[i] as f32 / INK_Q; + match self.model { + 0 => Color::rgb(self.v[0] as f32 / RGB_Q, self.v[1] as f32 / RGB_Q, self.v[2] as f32 / RGB_Q), + 1 => Color::cmyk(f(0), f(1), f(2), f(3)), + 2 => Color::gray(f(0)), + _ => Color::lab(self.v[0] as f32 / LAB_Q, self.v[1] as f32 / LAB_Q, self.v[2] as f32 / LAB_Q), + } + } + + /// Parse the written form (see [`ColorKey`]); extra spaces and case are ignored. + pub fn parse(s: &str) -> Option { + let mut it = s.split_whitespace(); + let model = it.next()?.to_ascii_lowercase(); + let nums: Vec = it.map(|t| t.parse::().ok().filter(|v| v.is_finite())).collect::>()?; + let c = match (model.as_str(), nums.as_slice()) { + ("rgb", [r, g, b]) => Color::rgb(r / 255.0, g / 255.0, b / 255.0), + ("cmyk", [c, m, y, k]) => Color::cmyk(c / 100.0, m / 100.0, y / 100.0, k / 100.0), + ("gray", [k]) => Color::gray(k / 100.0), + ("lab", [l, a, b]) => Color::lab(*l, *a, *b), + _ => return None, + }; + Some(Self::of(&c)) + } +} + +impl fmt::Display for ColorKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // Components are hundredths of the written units: exact with two decimals. + let num = |q: i32| { + let s = format!("{}{}.{:02}", if q < 0 { "-" } else { "" }, q.abs() / 100, q.abs() % 100); + s.trim_end_matches('0').trim_end_matches('.').to_string() + }; + let [a, b, c, k] = self.v.map(num); + match self.model { + 0 => write!(f, "rgb {a} {b} {c}"), + 1 => write!(f, "cmyk {a} {b} {c} {k}"), + 2 => write!(f, "gray {a}"), + _ => write!(f, "lab {a} {b} {c}"), + } + } +} + +/// The neutral a colour is, if any: paper white, black (rich blacks included) or a grey. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Neutral { + White, + Black, + Gray, +} + +impl Neutral { + pub fn of(c: &Color) -> Option { + const E: f32 = 0.005; + match *c { + Color::Gray { k } if k <= E => Some(Neutral::White), + Color::Gray { k } if k >= 1.0 - E => Some(Neutral::Black), + Color::Gray { .. } => Some(Neutral::Gray), + Color::Cmyk { c, m, y, k } if c.max(m).max(y).max(k) <= E => Some(Neutral::White), + Color::Cmyk { k, .. } if k >= 1.0 - E => Some(Neutral::Black), + Color::Cmyk { c, m, y, .. } => (c.max(m).max(y) <= E).then_some(Neutral::Gray), + // Lab: lightness at the ends, no chroma between them (to half a unit). + Color::Lab { l, a, b } if l >= 99.5 && a.abs().max(b.abs()) <= 0.5 => Some(Neutral::White), + Color::Lab { l, .. } if l <= 0.5 => Some(Neutral::Black), + Color::Lab { a, b, .. } => (a.abs().max(b.abs()) <= 0.5).then_some(Neutral::Gray), + Color::Rgb { r, g, b } => { + let (lo, hi) = (r.min(g).min(b), r.max(g).max(b)); + if lo >= 1.0 - E { + Some(Neutral::White) + } else if hi <= E { + Some(Neutral::Black) + } else { + (hi - lo <= E).then_some(Neutral::Gray) + } + } + } + } +} + +/// Which neutrals Recolor Artwork leaves alone (White and Black by default). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Preserve { + pub white: bool, + pub black: bool, + pub grays: bool, +} + +impl Default for Preserve { + fn default() -> Self { + Self { white: true, black: true, grays: false } + } +} + +impl Preserve { + pub const NONE: Self = Self { white: false, black: false, grays: false }; + + /// Whether `c` stays as it is. + pub fn keeps(&self, c: &Color) -> bool { + match Neutral::of(c) { + Some(Neutral::White) => self.white, + Some(Neutral::Black) => self.black, + Some(Neutral::Gray) => self.grays, + None => false, + } + } +} + +/// How the colours of a row take its new colour. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Method { + /// Every colour becomes the new colour. + #[default] + Exact, + /// Tints of the row's darkest colour become the same tints of the new colour; other colours + /// become the new colour. + PreserveTints, + /// Every colour becomes a tint of the new colour as light, relative to the row's darkest colour, + /// as it is. + ScaleTints, + /// The colour of average lightness becomes the new colour; lighter and darker ones become tints + /// and shades of it. + TintsShades, + /// The row's most saturated colour becomes the new colour; the others turn by the same hue and + /// keep their saturation and brightness. + HueShift, +} + +impl Method { + pub const ALL: [Method; 5] = [Method::Exact, Method::PreserveTints, Method::ScaleTints, Method::TintsShades, Method::HueShift]; + + pub fn id(self) -> &'static str { + match self { + Method::Exact => "exact", + Method::PreserveTints => "preserveTints", + Method::ScaleTints => "scaleTints", + Method::TintsShades => "tintsShades", + Method::HueShift => "hueShift", + } + } + pub fn label(self) -> &'static str { + match self { + Method::Exact => "Exact", + Method::PreserveTints => "Preserve Tints", + Method::ScaleTints => "Scale Tints", + Method::TintsShades => "Tints and Shades", + Method::HueShift => "Hue Shift", + } + } + /// A method from its id or label (case and spaces ignored). + pub fn parse(s: &str) -> Option { + let key = |x: &str| x.chars().filter(|c| c.is_alphanumeric()).collect::().to_ascii_lowercase(); + let want = key(s); + Self::ALL.into_iter().find(|m| key(m.id()) == want || key(m.label()) == want) + } + + /// The colour of `row` the new colour stands for: the most saturated for Hue Shift, the one + /// of average lightness for Tints and Shades, else the darkest. `None` for an empty row. + pub fn key_color(self, row: &[Color]) -> Option { + let labs: Vec<(Color, Lab)> = row.iter().map(|c| (*c, c.to_lab())).collect(); + let pick = |score: &dyn Fn(&Lab) -> f32| labs.iter().min_by(|a, b| score(&a.1).total_cmp(&score(&b.1))).map(|x| x.0); + match self { + Method::HueShift => pick(&|l| -l.chroma() * 1000.0 + l.l), + Method::TintsShades => { + let mean = mean_l(&labs); + pick(&|l| (l.l - mean).abs()) + } + _ => pick(&|l| l.l * 1000.0 - l.chroma()), + } + } +} + +fn mean_l(labs: &[(Color, Lab)]) -> f32 { + labs.iter().map(|x| x.1.l).sum::() / labs.len().max(1) as f32 +} + +/// Ink of `c` in `model`'s terms: CMYK and Gray components, 1 − RGB, or Lab's distance from paper +/// white (as [`Color::tinted`] scales it). +fn ink(c: Color, model: crate::cms::Model) -> [f32; 4] { + match c.in_model(model) { + Color::Cmyk { c, m, y, k } => [c, m, y, k], + Color::Gray { k } => [k, 0.0, 0.0, 0.0], + Color::Rgb { r, g, b } => [1.0 - r, 1.0 - g, 1.0 - b, 0.0], + Color::Lab { l, a, b } => [(100.0 - l) / 100.0, a / 100.0, b / 100.0, 0.0], + } +} + +fn dot(a: [f32; 4], b: [f32; 4]) -> f32 { + a.iter().zip(b).map(|(x, y)| x * y).sum() +} + +/// How much of `base`'s ink `c` carries (0 = paper, 1 = `base` or darker), measured in `c`'s model: +/// the tint of `base` closest to `c`. +pub fn tint_of(c: Color, base: Color) -> f32 { + let (a, b) = (ink(c, c.model()), ink(base, c.model())); + let bb = dot(b, b); + if bb <= 1e-9 { 1.0 } else { (dot(a, b) / bb).clamp(0.0, 1.0) } +} + +/// Whether `c` is a tint of `base` (its ink a fraction of `base`'s). +pub fn is_tint(c: Color, base: Color) -> bool { + let t = tint_of(c, base); + let (a, b) = (ink(c, c.model()), ink(base, c.model())); + a.iter().zip(b).all(|(x, y)| (x - y * t).abs() <= 0.01) +} + +/// `c` darkened by `f` (0..1) toward black: more K for CMYK and Gray, scaled RGB and Lab. +pub fn shaded(c: Color, f: f32) -> Color { + let f = f.clamp(0.0, 1.0); + match c { + Color::Cmyk { c, m, y, k } => Color::cmyk(c, m, y, k + (1.0 - k) * f), + Color::Gray { k } => Color::gray(k + (1.0 - k) * f), + Color::Lab { l, a, b } => Color::lab(l * (1.0 - f), a * (1.0 - f), b * (1.0 - f)), + Color::Rgb { r, g, b } => Color::rgb(r * (1.0 - f), g * (1.0 - f), b * (1.0 - f)), + } +} + +/// One row of Recolor Artwork prepared for mapping: its current colours take `to` by `method`. +#[derive(Clone, Copy, Debug)] +pub struct RowMap { + method: Method, + to: Color, + key: Color, + mean_l: f32, + /// Hue turn from the key colour to the new colour (Hue Shift). + turn: f32, +} + +impl RowMap { + /// The map of a row of current colours `row` (non-empty) to the new colour `to`. + pub fn new(method: Method, row: &[Color], to: Color) -> Self { + let key = method.key_color(row).unwrap_or(to); + let mean_l = match method { + Method::TintsShades => mean_l(&row.iter().map(|c| (*c, c.to_lab())).collect::>()), + _ => 0.0, + }; + Self { method, to, key, mean_l, turn: to.to_hsb()[0] - key.to_hsb()[0] } + } + + /// The colour of the row the new colour stands for. + pub fn key(&self) -> Color { + self.key + } + + /// The new colour of current colour `c`, in `c`'s model. + pub fn map(&self, c: Color) -> Color { + let to = keep_model(c, self.to); + match self.method { + Method::Exact => to, + Method::PreserveTints if is_tint(c, self.key) => to.tinted(tint_of(c, self.key)), + Method::PreserveTints => to, + Method::ScaleTints => to.tinted(tint_of(c, self.key)), + Method::TintsShades => { + let d = c.to_lab().l - self.mean_l; + if d >= 0.0 { to.tinted(1.0 - d / (100.0 - self.mean_l).max(1e-3)) } else { shaded(to, -d / self.mean_l.max(1e-3)) } + } + Method::HueShift if c == self.key => to, + Method::HueShift => { + let [h, s, v] = c.to_hsb(); + if s <= 0.0 { c } else { keep_model(c, Color::from_hsb(h + self.turn, s, v)) } + } + } + } +} + +/// Group `colors` into at most `n` rows of similar colours, for colour reduction: k-means in Lab +/// (lightness counting half, so tints and shades of a hue stay together) weighted by `weights`, +/// seeded with the heaviest colour and then farthest-first, so the result is the same on every +/// run. Returns the rows as indices into `colors`, ordered by their first index (each in input +/// order). +pub fn cluster(colors: &[Color], weights: &[f32], n: usize) -> Vec> { + let pts: Vec<[f32; 3]> = colors + .iter() + .map(|c| { + let l = c.to_lab(); + [l.l * 0.5, l.a, l.b] + }) + .collect(); + let w = |i: usize| weights.get(i).copied().unwrap_or(1.0).max(1e-6); + let d2 = |a: &[f32; 3], b: &[f32; 3]| (a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2); + let n = n.max(1); + if pts.is_empty() { + return vec![]; + } + // Seeds: the heaviest colour, then each time the one farthest from every seed so far. + let first = (0..pts.len()).fold(0, |best, i| if w(i) > w(best) { i } else { best }); + let mut centres = vec![pts[first]]; + let mut near: Vec = pts.iter().map(|p| d2(p, &pts[first])).collect(); + while centres.len() < n { + let (i, far) = near.iter().enumerate().fold((0, 0.0f32), |b, (i, d)| if *d > b.1 { (i, *d) } else { b }); + if far <= 1e-6 { + break; + } + centres.push(pts[i]); + for (j, p) in pts.iter().enumerate() { + near[j] = near[j].min(d2(p, &pts[i])); + } + } + let nearest = |p: &[f32; 3], cs: &[[f32; 3]]| (0..cs.len()).min_by(|a, b| d2(p, &cs[*a]).total_cmp(&d2(p, &cs[*b]))).unwrap_or(0); + let mut of: Vec = pts.iter().map(|p| nearest(p, ¢res)).collect(); + for _ in 0..50 { + let mut sum = vec![([0.0f32; 3], 0.0f32); centres.len()]; + for (i, p) in pts.iter().enumerate() { + let s = &mut sum[of[i]]; + for (acc, v) in s.0.iter_mut().zip(p) { + *acc += v * w(i); + } + s.1 += w(i); + } + for (c, (s, wt)) in centres.iter_mut().zip(&sum) { + if *wt > 0.0 { + *c = s.map(|v| v / wt); + } + } + let next: Vec = pts.iter().map(|p| nearest(p, ¢res)).collect(); + if next == of { + break; + } + of = next; + } + let mut rows: Vec> = vec![vec![]; centres.len()]; + for (i, c) in of.into_iter().enumerate() { + rows[c].push(i); + } + rows.retain(|r| !r.is_empty()); + rows.sort_by_key(|r| r[0]); + rows +} + +/// A set of colours to snap to (Limit to Library), with their Lab values cached. +#[derive(Clone, Debug, Default)] +pub struct Palette(Vec<(Color, Lab)>); + +impl Palette { + pub fn new(colors: impl IntoIterator) -> Self { + Self(colors.into_iter().map(|c| (c, c.to_lab())).collect()) + } + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + /// The palette colour closest to `c` (CIEDE2000), in its own model; `c` for an empty palette. + pub fn nearest(&self, c: Color) -> Color { + let l = c.to_lab(); + self.0.iter().min_by(|a, b| delta_e2000(a.1, l).total_cmp(&delta_e2000(b.1, l))).map_or(c, |x| x.0) + } +} + +/// A small deterministic generator (xorshift64*) for the randomizers. +pub struct Rng(u64); + +impl Rng { + pub fn new(seed: u64) -> Self { + Self(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1) + } + pub fn next_u64(&mut self) -> u64 { + let mut x = self.0; + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + self.0 = x; + x.wrapping_mul(0x2545_F491_4F6C_DD1D) + } + /// Uniform in `lo..hi`. + pub fn range(&mut self, lo: f32, hi: f32) -> f32 { + lo + (hi - lo) * ((self.next_u64() >> 40) as f32 / (1u64 << 24) as f32) + } + /// Shuffle `v` in place (Fisher–Yates). + pub fn shuffle(&mut self, v: &mut [T]) { + for i in (1..v.len()).rev() { + let j = (self.next_u64() % (i as u64 + 1)) as usize; + v.swap(i, j); + } + } + /// `c` with a random saturation and brightness, its hue kept (greys stay grey), in its model. + pub fn saturation_brightness(&mut self, c: Color) -> Color { + let [h, s, _] = c.to_hsb(); + let (s2, v2) = (self.range(0.25, 1.0), self.range(0.3, 1.0)); + keep_model(c, Color::from_hsb(h, if s <= 0.0 { 0.0 } else { s2 }, v2)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn close(a: Color, b: Color) -> bool { + 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], + Color::Lab { l, a, b } => vec![l / 100.0, a / 100.0, b / 100.0], + }; + a.model() == b.model() && v(a).iter().zip(v(b)).all(|(x, y)| (x - y).abs() < 1e-3) + } + + #[test] + fn keys_keep_the_model_and_round_trip() { + let colors = [ + Color::rgb(1.0, 0.5, 0.0), + Color::rgb(1.0 / 3.0, 0.2, 0.9), + Color::cmyk(0.0, 1.0, 1.0, 0.0), + Color::cmyk(0.1234, 0.5, 0.0, 0.07), + Color::gray(0.4), + ]; + for c in colors { + let k = ColorKey::of(&c); + assert_eq!(ColorKey::parse(&k.to_string()), Some(k), "{k}"); + assert!(close(k.color(), c)); + } + assert_eq!(ColorKey::of(&Color::rgb(1.0, 0.5, 0.0)).to_string(), "rgb 255 127.5 0"); + assert_eq!(ColorKey::of(&Color::cmyk(0.0, 1.0, 1.0, 0.0)).to_string(), "cmyk 0 100 100 0"); + assert_eq!(ColorKey::of(&Color::gray(0.4)).to_string(), "gray 40"); + assert_eq!(ColorKey::of(&Color::rgb8(18, 52, 86)).to_string(), "rgb 18 52 86", "8-bit colours read as whole levels"); + // A CMYK red and an RGB red are different colours. + assert_ne!(ColorKey::of(&Color::cmyk(0.0, 1.0, 1.0, 0.0)), ColorKey::of(&Color::rgb(1.0, 0.0, 0.0))); + assert_eq!(ColorKey::parse(" CMYK 0 100 100 0 "), ColorKey::parse("cmyk 0 100 100 0")); + let lab = Color::lab(55.0, 60.5, -40.25); + assert_eq!(ColorKey::of(&lab).to_string(), "lab 55 60.5 -40.25"); + assert_eq!(ColorKey::parse("lab 55 60.5 -40.25").map(|k| k.color()), Some(lab)); + assert_ne!(ColorKey::of(&lab), ColorKey::of(&keep_model(Color::WHITE, lab)), "a Lab colour isn't its RGB lookalike"); + assert_eq!(ColorKey::parse("#ff0000"), None); + assert_eq!(ColorKey::parse("rgb 1 2"), None); + } + + #[test] + fn neutrals_and_preserve() { + assert_eq!(Neutral::of(&Color::WHITE), Some(Neutral::White)); + assert_eq!(Neutral::of(&Color::cmyk(0.0, 0.0, 0.0, 0.0)), Some(Neutral::White)); + assert_eq!(Neutral::of(&Color::cmyk(0.6, 0.4, 0.4, 1.0)), Some(Neutral::Black), "rich black"); + assert_eq!(Neutral::of(&Color::gray(0.5)), Some(Neutral::Gray)); + assert_eq!(Neutral::of(&Color::rgb(0.5, 0.5, 0.5)), Some(Neutral::Gray)); + assert_eq!(Neutral::of(&Color::cmyk(0.0, 0.0, 0.0, 0.3)), Some(Neutral::Gray)); + assert_eq!(Neutral::of(&Color::rgb(0.5, 0.4, 0.5)), None); + assert_eq!(Neutral::of(&Color::lab(100.0, 0.0, 0.0)), Some(Neutral::White)); + assert_eq!(Neutral::of(&Color::lab(0.0, 0.0, 0.0)), Some(Neutral::Black)); + assert_eq!(Neutral::of(&Color::lab(50.0, 0.2, -0.3)), Some(Neutral::Gray)); + assert_eq!(Neutral::of(&Color::lab(50.0, 20.0, 0.0)), None); + let p = Preserve::default(); + assert!(p.keeps(&Color::BLACK) && p.keeps(&Color::WHITE) && !p.keeps(&Color::gray(0.5))); + assert!(!Preserve::NONE.keeps(&Color::BLACK)); + } + + #[test] + fn methods_parse_by_id_and_label() { + for m in Method::ALL { + assert_eq!(Method::parse(m.id()), Some(m)); + assert_eq!(Method::parse(m.label()), Some(m)); + } + assert_eq!(Method::parse("tints & shades"), Some(Method::TintsShades)); + assert_eq!(Method::parse("nope"), None); + } + + #[test] + fn scale_tints_keep_tint_ratios() { + let red = Color::cmyk(0.0, 1.0, 1.0, 0.0); + let pink = red.tinted(0.4); + let row = [pink, red]; + let m = RowMap::new(Method::ScaleTints, &row, Color::cmyk(1.0, 0.5, 0.0, 0.0)); + assert!(close(m.key(), red), "the darkest colour is the key"); + assert!(close(m.map(red), Color::cmyk(1.0, 0.5, 0.0, 0.0))); + assert!(close(m.map(pink), Color::cmyk(0.4, 0.2, 0.0, 0.0)), "{:?}", m.map(pink)); + // RGB tints mix toward white. + let blue = Color::rgb(0.0, 0.0, 1.0); + let m = RowMap::new(Method::ScaleTints, &[blue, blue.tinted(0.5)], Color::rgb(1.0, 0.0, 0.0)); + assert!(close(m.map(blue.tinted(0.5)), Color::rgb(1.0, 0.5, 0.5))); + // Lab tints too (toward paper white). + let base = Color::lab(40.0, 60.0, -20.0); + let to = Color::lab(50.0, -40.0, 30.0); + let m = RowMap::new(Method::ScaleTints, &[base, base.tinted(0.25)], to); + assert!(close(m.map(base.tinted(0.25)), to.tinted(0.25)), "{:?}", m.map(base.tinted(0.25))); + } + + #[test] + fn preserve_tints_only_scales_real_tints() { + let red = Color::cmyk(0.0, 1.0, 1.0, 0.0); + let other = Color::cmyk(0.0, 0.3, 0.8, 0.0); + let to = Color::cmyk(1.0, 0.0, 0.0, 0.0); + let m = RowMap::new(Method::PreserveTints, &[red, red.tinted(0.5), other], to); + assert!(close(m.map(red.tinted(0.5)), Color::cmyk(0.5, 0.0, 0.0, 0.0))); + assert!(close(m.map(other), to), "not a tint: exact"); + let s = RowMap::new(Method::ScaleTints, &[red, other], to); + assert!(!close(s.map(other), to), "Scale Tints scales every colour"); + } + + #[test] + fn exact_tints_shades_and_hue_shift() { + let to = Color::rgb(0.0, 0.4, 1.0); + let row = [Color::rgb(0.9, 0.2, 0.2), Color::rgb(0.5, 0.1, 0.1), Color::rgb(1.0, 0.7, 0.7)]; + let e = RowMap::new(Method::Exact, &row, to); + assert!(row.iter().all(|c| e.map(*c) == to)); + let ts = RowMap::new(Method::TintsShades, &row, to); + let l = |c: Color| c.to_lab().l; + assert!(l(ts.map(row[2])) > l(ts.map(row[0])) && l(ts.map(row[0])) > l(ts.map(row[1])), "lighter stays lighter"); + let hs = RowMap::new(Method::HueShift, &row, to); + assert_eq!(hs.map(hs.key()), to); + let [h, s, v] = hs.map(row[1]).to_hsb(); + let [_, s0, v0] = row[1].to_hsb(); + assert!((h - to.to_hsb()[0]).abs() < 1.0 && (s - s0).abs() < 1e-3 && (v - v0).abs() < 1e-3, "{h} {s} {v}"); + // Results keep the model of the colour they replace. + let m = RowMap::new(Method::Exact, &[Color::gray(0.3)], to); + assert!(matches!(m.map(Color::gray(0.3)), Color::Gray { .. })); + let lab = [Color::lab(40.0, 50.0, 30.0), Color::lab(70.0, 20.0, 10.0)]; + for method in Method::ALL { + let m = RowMap::new(method, &lab, to); + assert!(lab.iter().all(|c| matches!(m.map(*c), Color::Lab { .. })), "{}", method.label()); + } + assert!(matches!(shaded(lab[0], 0.5), Color::Lab { .. })); + } + + #[test] + fn clustering_reduces_to_n_rows_of_similar_colours() { + let colors = + [Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0), Color::rgb(0.9, 0.1, 0.05), Color::rgb(0.1, 0.1, 0.9), Color::rgb(1.0, 0.2, 0.2)]; + let rows = cluster(&colors, &[5.0, 4.0, 1.0, 1.0, 1.0], 2); + assert_eq!(rows, vec![vec![0, 2, 4], vec![1, 3]]); + assert_eq!(cluster(&colors, &[1.0; 5], 9).len(), 5, "never more rows than colours"); + assert_eq!(cluster(&colors, &[1.0; 5], 1), vec![vec![0, 1, 2, 3, 4]]); + assert_eq!(cluster(&[Color::WHITE, Color::WHITE], &[1.0, 1.0], 2).len(), 1, "identical colours share a row"); + assert!(cluster(&[], &[], 3).is_empty()); + } + + #[test] + fn palette_snaps_to_the_nearest_colour() { + let p = Palette::new([Color::rgb(1.0, 0.0, 0.0), Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::gray(0.5)]); + assert_eq!(p.nearest(Color::rgb(0.9, 0.1, 0.1)), Color::rgb(1.0, 0.0, 0.0)); + assert_eq!(p.nearest(Color::rgb(0.45, 0.45, 0.45)), Color::gray(0.5)); + let lab = Palette::new([Color::lab(50.0, 70.0, 50.0), Color::lab(50.0, -60.0, 50.0)]); + assert_eq!(lab.nearest(Color::rgb(0.9, 0.1, 0.1)), Color::lab(50.0, 70.0, 50.0), "library colours keep their model"); + assert_eq!(Palette::default().nearest(Color::WHITE), Color::WHITE); + } + + #[test] + fn randomizers_are_deterministic_and_keep_hue() { + let mut a: Vec = (0..10).collect(); + let mut b = a.clone(); + Rng::new(7).shuffle(&mut a); + Rng::new(7).shuffle(&mut b); + assert_eq!(a, b); + assert_ne!(a, (0..10).collect::>()); + let c = Color::cmyk(0.0, 0.8, 0.9, 0.1); + let r = Rng::new(3).saturation_brightness(c); + assert!(matches!(r, Color::Cmyk { .. })); + assert!(matches!(Rng::new(3).saturation_brightness(Color::lab(50.0, 40.0, 20.0)), Color::Lab { .. })); + assert_eq!(Rng::new(3).saturation_brightness(Color::gray(0.4)).to_hsb()[1], 0.0, "greys stay grey"); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index dfb442f1e..385f280c7 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -253,11 +253,13 @@ pub fn matrix_param(p: &Value, key: &str) -> Option { } Some(Affine::new(c)) } -/// A colour from `"#rrggbb"`, `[r,g,b]` (0..1), `{"c":..,"m":..,"y":..,"k":..}`, `{"gray":..}` or -/// `{"l":..,"a":..,"b":..}` (CIE Lab: L 0..100, a and b about −128..127). +/// A colour from `"#rrggbb"`, a colour key (`"cmyk 0 100 100 0"`, `"lab 55 60 40"`), `[r,g,b]` (0..1), +/// `{"c":..,"m":..,"y":..,"k":..}`, `{"gray":..}` or `{"l":..,"a":..,"b":..}` (CIE Lab: L 0..100, a and b about +/// −128..127). pub fn color_value(v: &Value) -> Option { match v { - Value::String(s) => Color::from_hex(s), + // A hex, or a colour key ("cmyk 0 100 100 0", as Recolor Artwork lists colours). + Value::String(s) => Color::from_hex(s).or_else(|| vectorcraft_color::recolor::ColorKey::parse(s).map(|k| k.color())), Value::Array(a) if a.len() >= 3 => Some(Color::rgb(a[0].as_f64()? as f32, a[1].as_f64()? as f32, a[2].as_f64()? as f32)), Value::Object(o) => { if let (Some(c), Some(m), Some(y), Some(k)) = (o.get("c"), o.get("m"), o.get("y"), o.get("k")) { diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index e458789b9..c4ed2fd67 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -1,11 +1,15 @@ -//! Edit → Edit Colors → Recolor Artwork: list the selection's colours and remap them. +//! Edit → Edit Colors → Recolor Artwork: list the selection's colours, reduce them to rows of +//! similar colours, and remap each row to a new colour by a recolour method. Colours are keyed by +//! their identity ([`ColorKey`]: model and exact values), so a CMYK colour stays CMYK and two +//! colours that only look alike stay apart. -use std::collections::BTreeMap; +use std::collections::HashMap; use serde_json::{Value, json}; -use vectorcraft_color::{Color, Paint, keep_model}; +use vectorcraft_color::recolor::{ColorKey, Method, Palette, Preserve, Rng, RowMap, cluster}; +use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::swatches::node_colors; -use vectorcraft_doc::{AppearanceItem, NodeKind}; +use vectorcraft_doc::{AppearanceItem, Document, NodeId, NodeKind}; use super::colorcmds::{Recolor, Scope}; use super::*; @@ -17,7 +21,7 @@ pub fn specs() -> Vec { "Artwork Colors", [], None, - "{} → {colors: [{hex, count}]} unique colours used by the selection (fills, strokes, gradient stops, text, mesh points and the tiles of pattern fills and strokes), most used first", + "{} → {colors: [{key: colour identity (\"rgb 255 0 0\", \"cmyk 0 100 100 0\", \"gray 40\", \"lab 55 60 -40\"), hex, count, swatch?: the global swatch it is linked to}]} unique colours used by the selection (fills, strokes, gradient stops, text, mesh points and the tiles of pattern fills and strokes), most used first", has_selection, colors ), @@ -26,21 +30,58 @@ pub fn specs() -> Vec { "Recolor Artwork", ["Edit", "Edit Colors"], None, - "{map: {\"#rrggbb\": \"#rrggbb\", …}, includeImages?: true (pixels of those colours in embedded images too; the image becomes a recoloured copy), includePatterns?: true (a pattern fill or stroke gets a recoloured copy as a new pattern swatch; the original pattern stays)} replace colours across the selection (gradients, text and meshes included; each new colour keeps the model of the one it replaces) as one undo step → {changed}", + "{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null, exclude?: false (the row's colours are kept)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library (new colours snap to its nearest colour), includeImages?: true, includePatterns?: true} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces) as one undo step → {changed}", has_selection, apply ), + cmd!( + query "recolor.reduce", + "Reduce Colors", + [], + None, + "{colors?: n (rows; default: one per colour, with the tints of a global swatch in its row), method?: as recolor.apply, default scaleTints (picks each row's colour), preserve?: {white?: true, black?: true, grays?: false} (left out of the rows), limitTo?: swatch library (new colours snap to it)} group the selection's colours into rows of similar colours (k-means in Lab, weighted by use) → {map: [{from: [keys, the row's colour first], to: key}], preserved: [keys], method}", + has_selection, + reduce + ), + cmd!( + query "recolor.randomize", + "Randomize Colors", + [], + None, + "{map: rows as recolor.apply, order?: true (shuffle the new colours among the rows that aren't excluded), saturationBrightness?: false (random saturation and brightness, hue kept), seed?: 0} → {map: the rows with their new colours as keys}", + always, + randomize + ), ] } -fn colors(s: &mut Session, _: &Value) -> Result { - let st = s.doc()?; - let d = &st.doc; - let mut counts: BTreeMap = BTreeMap::new(); - let mut count = |c: &Color, _: Option<&str>| *counts.entry(c.to_hex()).or_default() += 1; +/// A colour the art uses: how often, and the global swatch it is linked to. +struct Used { + color: Color, + count: usize, + swatch: Option, +} + +/// The unique colours of the selection, most used first (ties by key): fills, strokes, gradient +/// stops, text, mesh points and the tiles of the patterns used as fills or strokes. +fn used_colors(d: &Document, ids: &[NodeId]) -> Vec { + let mut index: HashMap = HashMap::new(); + let mut used: Vec<(ColorKey, Used)> = vec![]; + let mut count = |c: &Color, link: Option<&str>| { + let k = ColorKey::of(c); + let i = *index.entry(k).or_insert_with(|| { + used.push((k, Used { color: *c, count: 0, swatch: None })); + used.len() - 1 + }); + let u = &mut used[i].1; + u.count += 1; + if u.swatch.is_none() { + u.swatch = link.filter(|l| d.swatch(l).is_some_and(|w| w.global && w.paint.color().is_some())).map(str::to_string); + } + }; // The patterns used as fills or strokes: their tiles count once each. let mut patterns: Vec<&str> = vec![]; - for n in st.selection.objects.iter().filter_map(|id| d.node(*id)) { + for n in ids.iter().filter_map(|id| d.node(*id)) { node_colors(n, &mut count); n.walk(&mut |m| { let runs = match &m.kind { @@ -63,22 +104,236 @@ fn colors(s: &mut Session, _: &Value) -> Result { for def in patterns.iter().filter_map(|p| d.pattern(p)) { def.art.iter().for_each(|n| node_colors(n, &mut count)); } - let mut v: Vec<(String, usize)> = counts.into_iter().collect(); - v.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); - Ok(json!({ "colors": v.iter().map(|(h, n)| json!({"hex": h, "count": n})).collect::>() })) + used.sort_by(|a, b| b.1.count.cmp(&a.1.count).then(a.0.cmp(&b.0))); + used.into_iter().map(|(_, u)| u).collect() +} + +fn colors(s: &mut Session, _: &Value) -> Result { + let st = s.doc()?; + let v: Vec = used_colors(&st.doc, &st.selection.objects) + .iter() + .map(|u| { + let mut v = json!({"key": key_str(&u.color), "hex": u.color.to_hex(), "count": u.count}); + if let Some(w) = &u.swatch { + v["swatch"] = json!(w); + } + v + }) + .collect(); + Ok(json!({ "colors": v })) +} + +/// The `method` parameter (`default` without one). +fn method_param(p: &Value, cmd: &str, default: Method) -> Result { + match str_param(p, "method") { + None => Ok(default), + Some(m) => Method::parse(m).ok_or_else(|| { + let ids: Vec<&str> = Method::ALL.iter().map(|m| m.id()).collect(); + bad(cmd, format!("unknown method `{m}` ({})", ids.join("|"))) + }), + } +} + +/// The colours of the `limitTo` library (`None` without one). +fn palette_param(s: &Session, p: &Value, cmd: &str) -> Result> { + let Some(key) = str_param(p, "limitTo").filter(|k| !k.is_empty()) else { return Ok(None) }; + let (_, lib) = super::swatchlib::library(s, key).ok_or_else(|| bad(cmd, format!("no swatch library `{key}` (see swatch.library.list)")))?; + let palette = Palette::new(lib.iter().filter_map(|w| w.paint.color())); + if palette.is_empty() { + return Err(bad(cmd, format!("swatch library `{key}` has no colours"))); + } + Ok(Some(palette)) +} + +fn key_str(c: &Color) -> Value { + json!(ColorKey::of(c).to_string()) +} + +fn reduce(s: &mut Session, p: &Value) -> Result { + const C: &str = "recolor.reduce"; + let method = method_param(p, C, Method::ScaleTints)?; + let palette = palette_param(s, p, C)?; + let def = Preserve::default(); + let flag = |k: &str, d: bool| p.get("preserve").map_or(d, |v| bool_or(v, k, d)); + let preserve = Preserve { white: flag("white", def.white), black: flag("black", def.black), grays: flag("grays", def.grays) }; + let st = s.doc()?; + let (kept, used): (Vec, Vec) = used_colors(&st.doc, &st.selection.objects).into_iter().partition(|u| preserve.keeps(&u.color)); + // Units that stay together: the tints of a global swatch, else one colour each. + let mut units: Vec> = vec![]; + for u in &used { + match units.iter_mut().find(|g| u.swatch.is_some() && g[0].swatch == u.swatch) { + Some(g) => g.push(u), + None => units.push(vec![u]), + } + } + let unit_colors = |g: &[&Used]| g.iter().map(|u| u.color).collect::>(); + let rows: Vec> = match p.get("colors").and_then(Value::as_u64) { + Some(n) if (n as usize) < units.len() => { + let keys: Vec = units.iter().map(|g| method.key_color(&unit_colors(g)).unwrap_or_default()).collect(); + let weights: Vec = units.iter().map(|g| g.iter().map(|u| u.count as f32).sum()).collect(); + cluster(&keys, &weights, n as usize).into_iter().map(|ix| ix.into_iter().flat_map(|i| units[i].iter().copied()).collect()).collect() + } + _ => units, + }; + let map: Vec = rows + .iter() + .map(|row| { + let mut from = unit_colors(row); + let key = method.key_color(&from).unwrap_or_default(); + // The row's colour first. + if let Some(i) = from.iter().position(|c| *c == key) { + from[..=i].rotate_right(1); + } + let to = palette.as_ref().map_or(key, |pl| pl.nearest(key)); + json!({"from": from.iter().map(key_str).collect::>(), "to": key_str(&to)}) + }) + .collect(); + Ok(json!({"map": map, "preserved": kept.iter().map(|u| key_str(&u.color)).collect::>(), "method": method.id()})) +} + +/// A current colour of a row: an exact colour, or every colour shown as a hex. +enum Match { + Key(ColorKey), + Hex(String), +} + +impl Match { + /// A colour key, a hex (`#rrggbb`) or a colour value. + fn of(v: &Value) -> Option { + if let Some(s) = v.as_str() { + if let Some(k) = ColorKey::parse(s) { + return Some(Match::Key(k)); + } + return Color::from_hex(s).map(|c| Match::Hex(c.to_hex())); + } + color_value(v).map(|c| Match::Key(ColorKey::of(&c))) + } + fn color(&self) -> Color { + match self { + Match::Key(k) => k.color(), + Match::Hex(h) => Color::from_hex(h).unwrap_or_default(), + } + } + fn to_json(&self) -> Value { + match self { + Match::Key(k) => json!(k.to_string()), + Match::Hex(h) => json!(h), + } + } +} + +/// A row of the map: its current colours and its new colour; an excluded row keeps its colours. +struct Row { + from: Vec, + to: Option, + exclude: bool, +} + +impl Row { + /// The new colour, unless the row is excluded. + fn new_color(&self) -> Option { + self.to.filter(|_| !self.exclude) + } +} + +/// The rows of a `map` parameter, in order: an array of `{from, to, exclude?}` or an object of +/// `current: new` pairs. +fn parse_rows(p: &Value, cmd: &str) -> Result> { + let new = |v: Option<&Value>| -> Result> { + match v { + None | Some(Value::Null) => Ok(None), + Some(v) => color_value(v).map(Some).ok_or_else(|| bad(cmd, format!("bad new colour {v}"))), + } + }; + let cur = |v: &Value| Match::of(v).ok_or_else(|| bad(cmd, format!("bad current colour {v}"))); + match p.get("map") { + Some(Value::Object(m)) => m.iter().map(|(k, v)| Ok(Row { from: vec![cur(&json!(k))?], to: new(Some(v))?, exclude: false })).collect(), + Some(Value::Array(rows)) => rows + .iter() + .map(|r| { + let from = match r.get("from") { + Some(Value::Array(a)) => a.iter().map(cur).collect::>>()?, + Some(v) => vec![cur(v)?], + None => return Err(bad(cmd, "each row needs `from`")), + }; + Ok(Row { from, to: new(r.get("to"))?, exclude: bool_or(r, "exclude", false) }) + }) + .collect(), + _ => Err(bad(cmd, "missing `map` (rows [{from, to}] or {current: new})")), + } +} + +/// A parsed map ready to recolour: the row of each current colour. +struct ColorMap { + rows: Vec, + keys: HashMap, + hexes: HashMap, +} + +impl ColorMap { + fn new(rows: &[Row], method: Method) -> Self { + let mut out = Self { rows: vec![], keys: HashMap::new(), hexes: HashMap::new() }; + for r in rows.iter().filter(|r| !r.from.is_empty()) { + let Some(to) = r.new_color() else { continue }; + let i = out.rows.len(); + let colors: Vec = r.from.iter().map(Match::color).collect(); + out.rows.push(RowMap::new(method, &colors, to)); + for m in &r.from { + match m { + Match::Key(k) => out.keys.entry(*k).or_insert(i), + Match::Hex(h) => out.hexes.entry(h.clone()).or_insert(i), + }; + } + } + out + } + + fn map(&self, c: Color) -> Color { + let row = self.keys.get(&ColorKey::of(&c)).or_else(|| if self.hexes.is_empty() { None } else { self.hexes.get(&c.to_hex()) }); + row.map_or(c, |i| self.rows[*i].map(c)) + } } fn apply(s: &mut Session, p: &Value) -> Result { - let map: BTreeMap = p - .get("map") - .and_then(Value::as_object) - .ok_or_else(|| bad("recolor.apply", "missing map"))? - .iter() - .filter_map(|(k, v)| Some((Color::from_hex(k)?.to_hex(), color_value(v)?))) - .collect(); + const C: &str = "recolor.apply"; + let method = method_param(p, C, Method::Exact)?; + let palette = palette_param(s, p, C)?; + let mut rows = parse_rows(p, C)?; + if let Some(pl) = &palette { + for r in &mut rows { + r.to = r.to.map(|c| pl.nearest(c)); + } + } let ids = s.doc()?.selection.objects.clone(); - let f = |c: Color| map.get(&c.to_hex()).map_or(c, |to| keep_model(c, *to)); + let map = ColorMap::new(&rows, method); + let f = |c: Color| map.map(c); let scope = Scope { fill: true, stroke: true, ..Scope::of(p) }; let changed = s.edit("Recolor Artwork", |d, _| Ok(Recolor::new(scope, &f).run(d, &ids)))?; Ok(json!({ "changed": changed })) } + +fn randomize(_: &mut Session, p: &Value) -> Result { + const C: &str = "recolor.randomize"; + let rows = parse_rows(p, C)?; + let mut rng = Rng::new(p.get("seed").and_then(Value::as_u64).unwrap_or(0)); + let mut news: Vec = rows.iter().filter_map(Row::new_color).collect(); + if bool_or(p, "order", true) { + rng.shuffle(&mut news); + } + if bool_or(p, "saturationBrightness", false) { + news = news.into_iter().map(|c| rng.saturation_brightness(c)).collect(); + } + let mut news = news.into_iter(); + let map: Vec = rows + .iter() + .map(|r| { + let to = if r.new_color().is_some() { news.next() } else { r.to }; + let mut row = json!({"from": r.from.iter().map(Match::to_json).collect::>(), "to": to.as_ref().map(key_str)}); + if r.exclude { + row["exclude"] = json!(true); + } + row + }) + .collect(); + Ok(json!({ "map": map })) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 25fda4ef4..d28545e9e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -908,6 +908,8 @@ mod tests_previewbounds; #[cfg(test)] mod tests_proxyitems; #[cfg(test)] +mod tests_recolor; +#[cfg(test)] mod tests_registration; #[cfg(test)] mod tests_scalestrokes; diff --git a/crates/engine/src/tests_recolor.rs b/crates/engine/src/tests_recolor.rs new file mode 100644 index 000000000..a0cb953be --- /dev/null +++ b/crates/engine/src/tests_recolor.rs @@ -0,0 +1,224 @@ +//! Recolor Artwork (M3.78): colour reduction, preserve rules, recolour methods, Limit to Library, +//! randomize. + +use serde_json::{Value, json}; +use vectorcraft_color::Color; + +use super::*; +use crate::cmd::color_value; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 200})).unwrap(); + s +} + +fn run(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +/// A rectangle filled with `fill` (any colour the paint commands take). +fn rect(s: &mut Session, fill: Value) -> NodeId { + let n = s.doc().map_or(0, |st| st.doc.layers[0].children().map_or(0, |c| c.len())) as f64; + let id = NodeId(run(s, "shape.rectangle", json!({"x": 10.0 + 20.0 * n, "y": 10, "width": 15, "height": 15}))["id"].as_u64().unwrap()); + run(s, "paint.setFill", json!({"ids": [id.0], "color": fill})); + run(s, "paint.setStroke", json!({"ids": [id.0], "none": true})); + id +} + +fn fill(s: &Session, id: NodeId) -> Color { + s.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint().color().unwrap() +} + +fn select_all(s: &mut Session) { + run(s, "select.all", json!({})); +} + +fn keys(v: &Value) -> Vec { + v.as_array().unwrap().iter().map(|k| k.as_str().unwrap().to_string()).collect() +} + +fn close(a: Color, b: Color) -> bool { + 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], + Color::Lab { l, a, b } => vec![l / 100.0, a / 100.0, b / 100.0], + }; + a.model() == b.model() && v(a).iter().zip(v(b)).all(|(x, y)| (x - y).abs() < 2e-3) +} + +#[test] +fn five_colours_reduce_to_two() { + let mut s = session(); + for c in ["#ff0000", "#e01010", "#0000ff", "#1010e0", "#ff3030"] { + rect(&mut s, json!(c)); + } + select_all(&mut s); + assert_eq!(run(&mut s, "recolor.colors", json!({}))["colors"].as_array().unwrap().len(), 5); + let r = run(&mut s, "recolor.reduce", json!({"colors": 2})); + let map = r["map"].as_array().unwrap(); + assert_eq!(map.len(), 2); + let mut sizes: Vec = map.iter().map(|row| row["from"].as_array().unwrap().len()).collect(); + sizes.sort(); + assert_eq!(sizes, [2, 3], "blues and reds"); + assert_eq!(r["method"], "scaleTints"); + // Exact: each row becomes one colour. + run(&mut s, "recolor.apply", json!({"map": r["map"], "method": "exact"})); + let after = run(&mut s, "recolor.colors", json!({})); + assert_eq!(after["colors"].as_array().unwrap().len(), 2, "{after}"); + // One undo step. + run(&mut s, "edit.undo", json!({})); + assert_eq!(run(&mut s, "recolor.colors", json!({}))["colors"].as_array().unwrap().len(), 5); + // Without a count every colour is its own row. + assert_eq!(run(&mut s, "recolor.reduce", json!({}))["map"].as_array().unwrap().len(), 5); +} + +#[test] +fn preserve_flags_leave_neutrals_out() { + let mut s = session(); + for c in [json!("#ffffff"), json!("#000000"), json!({"gray": 50}), json!("#ff0000")] { + rect(&mut s, c); + } + select_all(&mut s); + let r = run(&mut s, "recolor.reduce", json!({})); + assert_eq!(keys(&r["preserved"]), ["rgb 0 0 0", "rgb 255 255 255"]); + assert_eq!(r["map"].as_array().unwrap().len(), 2, "the grey and the red"); + let r = run(&mut s, "recolor.reduce", json!({"preserve": {"white": false, "black": false, "grays": true}})); + assert_eq!(keys(&r["preserved"]), ["gray 50"]); + assert_eq!(r["map"].as_array().unwrap().len(), 3); +} + +#[test] +fn scale_and_preserve_tints_keep_tint_ratios() { + let mut s = session(); + let full = rect(&mut s, json!({"c": 0, "m": 100, "y": 100, "k": 0})); + let tint = rect(&mut s, json!({"c": 0, "m": 40, "y": 40, "k": 0})); + select_all(&mut s); + let r = run(&mut s, "recolor.reduce", json!({"colors": 1})); + let row = &r["map"][0]; + assert_eq!(keys(&row["from"]), ["cmyk 0 100 100 0", "cmyk 0 40 40 0"], "the darkest colour leads the row"); + for method in ["scaleTints", "preserveTints"] { + run(&mut s, "recolor.apply", json!({"map": [{"from": row["from"], "to": "cmyk 100 50 0 0"}], "method": method})); + assert!(close(fill(&s, full), Color::cmyk(1.0, 0.5, 0.0, 0.0)), "{method}"); + assert!(close(fill(&s, tint), Color::cmyk(0.4, 0.2, 0.0, 0.0)), "{method}: {:?}", fill(&s, tint)); + run(&mut s, "edit.undo", json!({})); + } + // Exact makes both the new colour. + run(&mut s, "recolor.apply", json!({"map": [{"from": row["from"], "to": "cmyk 100 50 0 0"}]})); + assert!(close(fill(&s, tint), Color::cmyk(1.0, 0.5, 0.0, 0.0))); +} + +#[test] +fn cmyk_colours_keep_their_identity_and_model() { + let mut s = session(); + let cmyk = rect(&mut s, json!({"c": 0, "m": 100, "y": 100, "k": 0})); + let shown = Color::cmyk(0.0, 1.0, 1.0, 0.0).to_hex(); + // An RGB colour that looks the same is a different colour. + let rgb = rect(&mut s, json!(shown)); + select_all(&mut s); + let listed: Vec = + run(&mut s, "recolor.colors", json!({}))["colors"].as_array().unwrap().iter().map(|c| c["key"].as_str().unwrap().to_string()).collect(); + assert_eq!(listed.len(), 2, "{listed:?}"); + assert!(listed.contains(&"cmyk 0 100 100 0".to_string())); + run(&mut s, "recolor.apply", json!({"map": {"cmyk 0 100 100 0": "cmyk 100 0 0 0"}})); + assert_eq!(fill(&s, cmyk), Color::cmyk(1.0, 0.0, 0.0, 0.0), "exact CMYK values survive"); + assert!(matches!(fill(&s, rgb), Color::Rgb { .. }) && fill(&s, rgb).to_hex() == shown, "the RGB lookalike is untouched"); + // A new colour given in RGB still lands in CMYK. + run(&mut s, "recolor.apply", json!({"map": {"cmyk 100 0 0 0": "#00ff00"}, "method": "scaleTints"})); + assert!(matches!(fill(&s, cmyk), Color::Cmyk { .. })); + // Hex keys still match every colour shown as that hex. + run(&mut s, "recolor.apply", json!({"map": {shown.as_str(): "#0000ff"}})); + assert_eq!(fill(&s, rgb).to_hex(), "#0000ff"); +} + +#[test] +fn lab_colours_keep_their_identity_and_model() { + let mut s = session(); + let lab = rect(&mut s, json!({"l": 55, "a": 60, "b": 40})); + let rgb = rect(&mut s, json!(Color::lab(55.0, 60.0, 40.0).to_hex())); + select_all(&mut s); + let r = run(&mut s, "recolor.reduce", json!({})); + let rows: Vec> = r["map"].as_array().unwrap().iter().map(|row| keys(&row["from"])).collect(); + assert!(rows.contains(&vec!["lab 55 60 40".to_string()]) && rows.len() == 2, "{rows:?}"); + run(&mut s, "recolor.apply", json!({"map": {"lab 55 60 40": "#00ff00"}, "method": "scaleTints"})); + assert!(matches!(fill(&s, lab), Color::Lab { .. }), "the Lab colour stays Lab"); + assert_eq!(fill(&s, lab).to_hex(), "#00ff00"); + assert!(matches!(fill(&s, rgb), Color::Rgb { .. }) && fill(&s, rgb).to_hex() != "#00ff00", "its RGB lookalike is untouched"); +} + +#[test] +fn tints_of_a_global_swatch_share_its_row() { + let mut s = session(); + let brand = Color::cmyk(0.0, 0.8, 0.9, 0.0); + run(&mut s, "swatch.new", json!({"name": "Brand", "color": {"c": 0, "m": 80, "y": 90, "k": 0}, "global": true})); + let full = rect(&mut s, json!("#00ff00")); + let tint = rect(&mut s, json!("#00ff00")); + let other = rect(&mut s, json!("#0000ff")); + // Both linked to the swatch; the second as a 50% tint. + run(&mut s, "paint.setFill", json!({"ids": [full.0], "swatch": "Brand"})); + run(&mut s, "paint.setFill", json!({"ids": [tint.0], "swatch": "Brand", "tint": 50})); + assert!(close(fill(&s, tint), brand.tinted(0.5))); + select_all(&mut s); + let colors = run(&mut s, "recolor.colors", json!({})); + assert!(colors["colors"].as_array().unwrap().iter().filter(|c| c["swatch"] == "Brand").count() == 2, "{colors}"); + let r = run(&mut s, "recolor.reduce", json!({})); + let map = r["map"].as_array().unwrap(); + assert_eq!(map.len(), 2, "the swatch and its tint in one row, the blue in another"); + let brand_row = map.iter().find(|row| row["from"].as_array().unwrap().len() == 2).unwrap(); + assert_eq!(brand_row["to"], "cmyk 0 80 90 0", "the swatch's colour leads its row"); + let blue_row = map.iter().find(|row| row["from"][0] == "rgb 0 0 255").unwrap(); + let rows = json!([{"from": brand_row["from"], "to": "cmyk 100 0 0 0"}, {"from": blue_row["from"], "to": null}]); + run(&mut s, "recolor.apply", json!({"map": rows, "method": "scaleTints"})); + assert!(close(fill(&s, tint), Color::cmyk(0.5, 0.0, 0.0, 0.0)), "{:?}", fill(&s, tint)); + assert_eq!(fill(&s, other).to_hex(), "#0000ff", "a row without a new colour is left alone"); +} + +#[test] +fn limit_to_library_snaps_new_colours() { + let mut s = session(); + rect(&mut s, json!("#f01020")); + rect(&mut s, json!("#2040e0")); + select_all(&mut s); + let r = run(&mut s, "recolor.reduce", json!({"limitTo": "web-safe-216"})); + let web = |k: &Value| { + let c = color_value(k).unwrap(); + c.to_rgb().iter().all(|v| ((v * 5.0).round() - v * 5.0).abs() < 1e-3) + }; + assert!(r["map"].as_array().unwrap().iter().all(|row| web(&row["to"])), "{r}"); + run(&mut s, "recolor.apply", json!({"map": {"#f01020": "#f01020"}, "limitTo": "Web Safe 216"})); + let hexes: Vec = run(&mut s, "recolor.colors", json!({}))["colors"].as_array().unwrap().iter().map(|c| c["hex"].clone()).collect(); + assert!(hexes.contains(&json!("#ff0033")) && !hexes.contains(&json!("#f01020")), "{hexes:?}"); + assert!(s.execute("recolor.reduce", &json!({"limitTo": "nope"})).is_err()); +} + +#[test] +fn methods_and_bad_params() { + let mut s = session(); + rect(&mut s, json!("#ff0000")); + select_all(&mut s); + assert!(s.execute("recolor.apply", &json!({"map": {"#ff0000": "#00ff00"}, "method": "nope"})).is_err()); + assert!(s.execute("recolor.apply", &json!({"map": {"#ff0000": "nonsense"}})).is_err()); + assert!(s.execute("recolor.apply", &json!({})).is_err()); +} + +#[test] +fn randomize_shuffles_new_colours_and_skips_excluded_rows() { + let mut s = session(); + let map = json!([ + {"from": ["rgb 255 0 0"], "to": "rgb 255 0 0"}, + {"from": ["rgb 0 255 0"], "to": "rgb 0 255 0"}, + {"from": ["rgb 0 0 255"], "to": "rgb 0 0 255", "exclude": true}, + {"from": ["rgb 255 255 0"], "to": "rgb 255 255 0"}, + ]); + let a = run(&mut s, "recolor.randomize", json!({"map": map, "seed": 4})); + let b = run(&mut s, "recolor.randomize", json!({"map": map, "seed": 4})); + assert_eq!(a, b, "deterministic"); + assert_eq!(a["map"][2], map[2], "an excluded row keeps its colour"); + let mut tos: Vec = [0, 1, 3].iter().map(|i| a["map"][*i]["to"].as_str().unwrap().to_string()).collect(); + tos.sort(); + assert_eq!(tos, ["rgb 0 255 0", "rgb 255 0 0", "rgb 255 255 0"], "a permutation"); + let sb = run(&mut s, "recolor.randomize", json!({"map": map, "order": false, "saturationBrightness": true})); + let hue = |v: &Value| color_value(v).unwrap().to_hsb()[0]; + assert!((hue(&sb["map"][0]["to"]) - 0.0).abs() < 1.0 && (hue(&sb["map"][1]["to"]) - 120.0).abs() < 1.0, "hue kept"); +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 6b837f654..59479a57d 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -28,7 +28,7 @@ pub mod new_color_group; mod new_document; pub mod new_swatch; mod path_ops; -mod recolor; +pub mod recolor; pub mod saturate; mod save_changes; pub mod save_style_library; @@ -140,7 +140,7 @@ registry! { ArtboardOptions: ["artboardOptions"] => artboard_options::SPEC, AllTools: ["allTools"] => all_tools::SPEC, ExportForScreens: ["exportForScreens"] => export_for_screens::SPEC, - Recolor: ["recolor"] => recolor::SPEC, + Recolor: [recolor::KIND] => recolor::SPEC, Command: ["command"] => command::SPEC, Effect: ["effect"] => effect::SPEC, SaveChanges: [crate::unsaved::KIND] => save_changes::SPEC, diff --git a/crates/ui-egui/src/dialogs/recolor.rs b/crates/ui-egui/src/dialogs/recolor.rs index 35c22b5a7..63dbee76d 100644 --- a/crates/ui-egui/src/dialogs/recolor.rs +++ b/crates/ui-egui/src/dialogs/recolor.rs @@ -1,105 +1,646 @@ -//! Edit → Edit Colors → Recolor Artwork: current → new colour pairs, previewed live. +//! Edit → Edit Colors → Recolor Artwork: the selection's colours in rows of current → new colours, +//! previewed live (engine: `recolor.reduce`, `recolor.randomize`, `recolor.apply`). +//! +//! Assign tab: Colors (Auto, or a count: similar colours merge into rows), Preserve White, Black and +//! Grays, the rows (click to select, Shift or Cmd/Ctrl-click to add; a row's arrow excludes or +//! includes it; the hex field edits its new colour), Merge and Separate, Randomize order or +//! saturation and brightness, Reset, the Method and Limit to Library. Edit tab: a hue and +//! saturation wheel with a marker per new colour (drag one; with linked handles they all turn and +//! scale together), a brightness slider and the harmony rules. The preset menu sets up a 1, 2 or 3 +//! colour job or a library. +//! +//! Fields (`ui.dialog.set`): `rows` ([{from: [keys], to: key, exclude?}]), `colors` (null: Auto, or a +//! count), `method`, `preserveWhite`, `preserveBlack`, `preserveGrays`, `limitTo` (library id, "" for +//! none), `tab` (`assign` or `edit`), `rule` (harmony id), `linked`, `preview`. Changing `colors`, +//! a preserve flag or `limitTo` reduces the rows again. +use egui::{Pos2, Rect, Sense, Ui, pos2, vec2}; use serde_json::{Value, json}; +use vectorcraft_color::harmony::Harmony; +use vectorcraft_color::recolor::{ColorKey, Method, Palette}; +use vectorcraft_color::{Color, keep_model}; +use vectorcraft_engine::cmd::color_value; +use vectorcraft_engine::cmd::swatchlib; -use super::DialogSpec; -use crate::VectorcraftApp; +use super::{DialogSpec, form}; +use crate::panels::c32; use crate::state::Dialog; -use crate::theme::Tokens; +use crate::theme::{self, Tokens}; +use crate::{VectorcraftApp, widgets}; -pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Recolor Artwork".into(), body, confirm, preview: true, ..DialogSpec::FORM }; +/// The dialog kind of Recolor Artwork. +pub const KIND: &str = "recolor"; -fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { - if editor(ui, d) || !app.session.in_interaction() { - let _ = app.session.begin_interaction("Recolor Artwork"); - let _ = app.session.preview("recolor.apply", &json!({"map": recolor_map(d)})); +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |_| "Recolor Artwork".into(), body, confirm, preview: true, min_width: 540.0, max_width: Some(560.0), ..DialogSpec::FORM }; + +const CMD: &str = "recolor.apply"; +/// Width of the current colours of a row. +const CURRENT_W: f32 = 250.0; +/// Diameter of the Edit tab's colour wheel. +const WHEEL: f32 = 220.0; +/// Height of the rows table, and of either tab (so switching tabs doesn't resize the dialog). +const TABLE_H: f32 = 220.0; +const TAB_H: f32 = 400.0; +const PRESETS: [&str; 5] = ["Custom", "1 Color Job", "2 Color Job", "3 Color Job", "Color Library"]; + +/// Open Recolor Artwork (`ui.recolorDialog {colors?, library?}`) on the selected art. `colors` is +/// a count (an n-colour job) or the new colours to assign; `library` limits to a library ("" picks +/// the first). +pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { + let st = app.session.active().ok_or("no document open")?; + let art = !st.selection.is_empty(); + if !art { + return Err("select artwork to recolor".into()); + } + let assign: Vec = match p.get("colors") { + Some(Value::Array(cs)) => cs.iter().filter_map(color_value).map(|c| key(&c)).collect(), + _ => vec![], + }; + let count = match p.get("colors").and_then(Value::as_u64) { + Some(n) => json!(n.max(1)), + None if !assign.is_empty() => json!(assign.len()), + None => Value::Null, + }; + let limit = match p.get("library").and_then(Value::as_str) { + Some("") => first_library(app).unwrap_or_default(), + Some(l) => swatchlib::library(&app.session, l).map(|(info, _)| info.id).ok_or_else(|| format!("no swatch library `{l}`"))?, + None => String::new(), + }; + let fields = json!({ + "rows": [], "colors": count, "method": Method::ScaleTints.id(), + "preserveWhite": true, "preserveBlack": true, "preserveGrays": false, "limitTo": limit, + "tab": "assign", "rule": Harmony::Complementary.id(), "linked": true, + "preview": true, "__art": art, "__assign": assign, + }); + app.ui.dialog = Some(Dialog::new(KIND, fields)); + Ok(Value::Null) +} + +fn key(c: &Color) -> Value { + json!(ColorKey::of(c).to_string()) +} + +/// The libraries with colours, for Limit to Library: (id, name). +fn colour_libraries(app: &VectorcraftApp) -> Vec<(String, String)> { + swatchlib::libraries(&app.session).into_iter().filter(|l| l.category != "gradients").map(|l| (l.id, l.name)).collect() +} + +fn first_library(app: &VectorcraftApp) -> Option { + colour_libraries(app).into_iter().next().map(|l| l.0) +} + +/// `recolor.reduce` parameters (Method only picks each row's colour, so changing it keeps the rows). +fn reduce_params(d: &Dialog) -> Value { + json!({ + "colors": d.fields.get("colors").cloned().unwrap_or(Value::Null), + "preserve": {"white": d.bool("preserveWhite"), "black": d.bool("preserveBlack"), "grays": d.bool("preserveGrays")}, + "limitTo": d.str("limitTo"), + }) +} + +/// Reduce the art's colours into rows again when the count, preserve flags or library changed +/// (assigning the opener's colours as the new colours). +fn sync(app: &mut VectorcraftApp, d: &mut Dialog) { + const DONE: &str = "__reduced"; + let sig = reduce_params(d); + if !d.bool("__art") || d.fields.get(DONE) == Some(&sig) { + return; + } + let mut p = sig.clone(); + p["method"] = json!(d.str("method")); + match app.session.execute("recolor.reduce", &p) { + Ok(r) => { + let mut rows = r["map"].as_array().cloned().unwrap_or_default(); + let assign = d.fields.get("__assign").and_then(Value::as_array).cloned().unwrap_or_default(); + for (row, to) in rows.iter_mut().zip(assign) { + row["to"] = to; + } + snap(app, d, &mut rows); + d.fields.insert("rows".into(), Value::Array(rows)); + d.fields.insert("__sel".into(), json!([])); + } + Err(e) => app.status(e.to_string()), + } + d.fields.insert(DONE.into(), sig); +} + +/// With Limit to Library, the rows' new colours snap to the library's nearest colours. +fn snap(app: &VectorcraftApp, d: &Dialog, rows: &mut [Value]) { + let Some((_, lib)) = Some(d.str("limitTo")).filter(|l| !l.is_empty()).and_then(|l| swatchlib::library(&app.session, &l)) else { return }; + let palette = Palette::new(lib.iter().filter_map(|w| w.paint.color())); + for r in rows { + if let Some(c) = to_color(r) { + r["to"] = key(&palette.nearest(c)); + } + } +} + +fn rows(d: &Dialog) -> Vec { + d.fields.get("rows").and_then(Value::as_array).cloned().unwrap_or_default() +} + +fn from_colors(row: &Value) -> Vec { + row["from"].as_array().map(|a| a.iter().filter_map(color_value).collect()).unwrap_or_default() +} + +fn to_color(row: &Value) -> Option { + color_value(&row["to"]) +} + +fn excluded(row: &Value) -> bool { + row["exclude"].as_bool().unwrap_or(false) +} + +fn selection(d: &Dialog) -> Vec { + d.fields.get("__sel").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect()).unwrap_or_default() +} + +/// `recolor.apply` parameters: the rows, method and library. +fn apply_params(d: &Dialog) -> Value { + json!({"map": rows(d), "method": d.str("method"), "limitTo": d.str("limitTo")}) +} + +fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool { + sync(app, d); + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + let tab = d.str("tab"); + for (id, label) in [("assign", "Assign"), ("edit", "Edit")] { + if ui.selectable_label(tab == id || (id == "assign" && tab != "edit"), egui::RichText::new(label).font(theme::semibold(12.5))).clicked() { + d.fields.insert("tab".into(), json!(id)); + } + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if let Some(i) = widgets::dropdown(ui, "recolor-preset", PRESETS[preset_of(d)], &PRESETS, 130.0) { + set_preset(app, d, i); + } + ui.label(egui::RichText::new("Preset:").color(t.text_dim)); + }); + }); + widgets::divider(ui); + ui.add_space(4.0); + let mut rs = rows(d); + let top = ui.cursor().top(); + let changed = if d.str("tab") == "edit" { edit_tab(ui, d, &mut rs) } else { assign_tab(app, ui, d, &mut rs) }; + ui.add_space((TAB_H - (ui.cursor().top() - top)).max(0.0)); + if changed { + snap(app, d, &mut rs); + d.fields.insert("rows".into(), Value::Array(rs)); + } + sync(app, d); + if d.bool("__art") { + form::preview(app, ui, d, "Recolor Artwork", CMD, apply_params(d)); } false } -/// Keep the previewed recolour as one undo step (or apply it when no preview is running). +/// OK: keep the previewed recolour as one undo step. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { - let map = recolor_map(d); - app.ui.dialog = None; - if app.session.in_interaction() { - let _ = app.session.preview("recolor.apply", &json!({"map": map})); - return app.session.commit_interaction().map(|_| Value::Null).map_err(|e| e.to_string()); - } - app.run("recolor.apply", json!({"map": map})) + let mut d = d.clone(); + sync(app, &mut d); + form::commit_preview(app, CMD, apply_params(&d)) } -fn recolor_map(d: &Dialog) -> Value { - let mut m = serde_json::Map::new(); - for p in d.fields.get("pairs").and_then(Value::as_array).cloned().unwrap_or_default() { - if let (Some(a), Some(b)) = (p.get(0).and_then(Value::as_str), p.get(1).and_then(Value::as_str)) { - m.insert(a.to_string(), json!(b)); +/// The preset the fields match: an n-colour job (n rows, Scale Tints), a library, or Custom. +fn preset_of(d: &Dialog) -> usize { + match d.fields.get("colors").and_then(Value::as_u64) { + Some(n @ 1..=3) if d.str("method") == Method::ScaleTints.id() => n as usize, + _ if !d.str("limitTo").is_empty() => 4, + _ => 0, + } +} + +fn set_preset(app: &VectorcraftApp, d: &mut Dialog, i: usize) { + match i { + 1..=3 => { + d.fields.insert("colors".into(), json!(i)); + d.fields.insert("method".into(), json!(Method::ScaleTints.id())); } + 4 if d.str("limitTo").is_empty() => { + d.fields.insert("limitTo".into(), json!(first_library(app).unwrap_or_default())); + } + _ => {} } - Value::Object(m) } -/// Current → new colour rows, harmony rules, randomize. Returns true when changed. -fn editor(ui: &mut egui::Ui, d: &mut Dialog) -> bool { - use vectorcraft_color::Color; +/// A colour chip with a hairline frame. +fn chip(ui: &Ui, r: Rect, c: Color, dim: bool) { + let t = Tokens::get(ui.ctx()); + let fill = c32(&c); + ui.painter().rect_filled(r, 1.0, if dim { fill.gamma_multiply(0.35) } else { fill }); + ui.painter().rect_stroke(r, 1.0, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Inside); +} + +/// The Assign tab. Returns true when the rows changed. +fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut Vec) -> bool { let t = Tokens::get(ui.ctx()); - let mut pairs: Vec = d.fields.get("pairs").and_then(Value::as_array).cloned().unwrap_or_default(); let mut changed = false; - let chip = |ui: &mut egui::Ui, hex: &str| { - let (r, _) = ui.allocate_exact_size(egui::vec2(34.0, 22.0), egui::Sense::hover()); - let c = Color::from_hex(hex).unwrap_or(Color::BLACK).to_rgba8(1.0); - ui.painter().rect_filled(r, 2.0, egui::Color32::from_rgb(c[0], c[1], c[2])); - ui.painter().rect_stroke(r, 2.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside); - }; + let total: usize = rows.iter().map(|r| r["from"].as_array().map_or(0, Vec::len)).sum(); ui.horizontal(|ui| { - ui.label(egui::RichText::new("Current Colors").color(t.text_dim)); - ui.add_space(70.0); + ui.label(egui::RichText::new("Colors:").color(t.text_dim)); + let labels: Vec = std::iter::once("Auto".to_string()).chain((1..=total).map(|n| n.to_string())).collect(); + let refs: Vec<&str> = labels.iter().map(String::as_str).collect(); + let cur = d.fields.get("colors").and_then(Value::as_u64).map_or("Auto".to_string(), |n| n.to_string()); + if let Some(i) = widgets::dropdown(ui, "recolor-count", &cur, &refs, 70.0) { + d.fields.insert("colors".into(), if i == 0 { Value::Null } else { json!(i) }); + } + ui.add_space(18.0); + ui.label(egui::RichText::new("Preserve:").color(t.text_dim)); + for (k, label) in [("preserveWhite", "White"), ("preserveBlack", "Black"), ("preserveGrays", "Grays")] { + let on = d.bool(k); + if widgets::check(ui, label, on, d.bool("__art")) { + d.fields.insert(k.into(), json!(!on)); + } + } + }); + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.add_space(6.0); + ui.add_sized([CURRENT_W, 16.0], egui::Label::new(egui::RichText::new(format!("Current Colors ({total})")).color(t.text_dim))); + ui.add_space(60.0); ui.label(egui::RichText::new("New").color(t.text_dim)); }); - egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { - for p in pairs.iter_mut() { - let from = p[0].as_str().unwrap_or("#000000").to_string(); - let mut to = p[1].as_str().unwrap_or("#000000").to_string(); - ui.horizontal(|ui| { - chip(ui, &from); - ui.label(egui::RichText::new("→").color(t.text_dim)); - chip(ui, &to); - if ui.add(egui::TextEdit::singleline(&mut to).desired_width(80.0)).changed() && Color::from_hex(&to).is_some() { - p[1] = json!(to); - changed = true; + let mut sel = selection(d); + let mut clicked: Option = None; + widgets::list_box(ui, |ui| { + egui::ScrollArea::vertical().id_salt("recolor-rows").max_height(TABLE_H).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.set_min_height(TABLE_H); + if rows.is_empty() { + ui.add_space(8.0); + widgets::dim_label(ui, "No colors to recolor (preserved colors are left out)."); + } + for (i, row) in rows.iter_mut().enumerate() { + let bg = ui.painter().add(egui::Shape::Noop); + let off = excluded(row); + let r = ui.horizontal(|ui| { + ui.add_space(4.0); + let from = from_colors(row); + let (cur, cr) = ui.allocate_exact_size(vec2(CURRENT_W, 20.0), Sense::click()); + let w = CURRENT_W / from.len().max(1) as f32; + for (j, c) in from.iter().enumerate() { + let x = cur.left() + j as f32 * w; + chip(ui, Rect::from_min_max(pos2(x, cur.top()), pos2(x + w - if w > 4.0 { 2.0 } else { 0.0 }, cur.bottom())), *c, false); + } + if cr.clicked() { + clicked = Some(i); + } + let (ar, aresp) = ui.allocate_exact_size(vec2(44.0, 20.0), Sense::click()); + arrow(ui, ar, !off, aresp.hovered()); + if aresp.on_hover_text(if off { "Recolor this row" } else { "Keep this row's colors (exclude)" }).clicked() { + row["exclude"] = json!(!off); + changed = true; + } + let (nr, nresp) = ui.allocate_exact_size(vec2(46.0, 20.0), Sense::click()); + let to = to_color(row).unwrap_or_default(); + chip(ui, nr, to, off); + if nresp.clicked() { + clicked = Some(i); + } + ui.add_space(6.0); + let hex = crate::panels::color::hex_digits(&to); + if let Some(h) = widgets::hex_field(ui, ("recolor-hex", i), &hex) + && let Some(c) = crate::panels::color::parse_hex(&h) + { + row["to"] = key(&keep_model(to, c)); + changed = true; + } + }); + if sel.contains(&i) { + ui.painter().set(bg, egui::Shape::rect_filled(r.response.rect.expand2(vec2(2.0, 1.0)), 2.0, t.row_selected)); } - }); + } + }); + }); + if let Some(i) = clicked { + let m = ui.input(|i| i.modifiers); + match sel.iter().position(|s| *s == i) { + Some(p) if m.shift || m.command => { + sel.remove(p); + } + None if m.shift || m.command => sel.push(i), + _ => sel = vec![i], + } + d.fields.insert("__sel".into(), json!(sel)); + } + ui.add_space(4.0); + ui.horizontal(|ui| { + let can_merge = sel.len() > 1; + if widgets::icon_button_enabled(ui, "combine", "Merge colors into one row", false, can_merge, 24.0).clicked() { + merge(rows, &sel); + d.fields.insert("__sel".into(), json!([sel.iter().min()])); + changed = true; + } + let can_split = sel.iter().any(|i| rows.get(*i).is_some_and(|r| r["from"].as_array().is_some_and(|a| a.len() > 1))); + if widgets::icon_button_enabled(ui, "ungroup", "Separate colors into rows", false, can_split, 24.0).clicked() { + separate(rows, &sel); + d.fields.insert("__sel".into(), json!([])); + changed = true; + } + ui.add_space(12.0); + for (icon, tip, sb) in + [("arrow-left-right", "Randomly change color order", false), ("sparkles", "Randomly change saturation and brightness", true)] + { + if widgets::icon_button_enabled(ui, icon, tip, false, rows.len() > 1 || sb, 24.0).clicked() { + let seed = d.f64("__seed", 0.0) as u64 + 1; + d.fields.insert("__seed".into(), json!(seed)); + let p = json!({"map": rows, "order": !sb, "saturationBrightness": sb, "seed": seed}); + match app.session.execute("recolor.randomize", &p) { + Ok(r) => { + *rows = r["map"].as_array().cloned().unwrap_or_default(); + changed = true; + } + Err(e) => app.status(e.to_string()), + } + } + } + if widgets::icon_button(ui, "rotate-ccw", "Reset the rows to the artwork's colors", false, 24.0).clicked() { + d.fields.remove("__reduced"); } }); ui.add_space(8.0); - ui.horizontal(|ui| { - if ui.button("Randomize").clicked() { - // Deterministic scramble of hues (seeded by the colour itself), keeping brightness. - for (i, p) in pairs.iter_mut().enumerate() { - let c = Color::from_hex(p[0].as_str().unwrap_or("#000")).unwrap_or(Color::BLACK); - let [h, s, v] = c.to_hsb(); - let nh = (h + 97.0 * (i as f32 + 1.0) + 41.0) % 360.0; - p[1] = json!(Color::from_hsb(nh, s.max(0.35), v.max(0.3)).to_hex()); - } - changed = true; + egui::Grid::new("recolor-options").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| { + ui.label(egui::RichText::new("Method:").color(t.text_dim)); + let m = Method::parse(&d.str("method")).unwrap_or_default(); + let labels: Vec<&str> = Method::ALL.iter().map(|m| m.label()).collect(); + if let Some(i) = widgets::dropdown(ui, "recolor-method", m.label(), &labels, 180.0) { + d.fields.insert("method".into(), json!(Method::ALL[i].id())); } - for (label, deg) in [("Complement", 180.0), ("Triad", 120.0), ("Analogous", 30.0)] { - if ui.button(label).clicked() { - for (i, p) in pairs.iter_mut().enumerate() { - let c = Color::from_hex(p[0].as_str().unwrap_or("#000")).unwrap_or(Color::BLACK); - let [h, s, v] = c.to_hsb(); - let shift = if label == "Complement" { deg } else { deg * (i as f32 + 1.0) }; - p[1] = json!(Color::from_hsb(h + shift, s, v).to_hex()); - } + ui.end_row(); + ui.label(egui::RichText::new("Limit to Library:").color(t.text_dim)); + let libs = colour_libraries(app); + let cur = d.str("limitTo"); + let name = libs.iter().find(|l| l.0 == cur).map_or("None", |l| l.1.as_str()); + let labels: Vec<&str> = std::iter::once("None").chain(libs.iter().map(|l| l.1.as_str())).collect(); + if let Some(i) = widgets::dropdown(ui, "recolor-library", name, &labels, 180.0) { + d.fields.insert("limitTo".into(), json!(if i == 0 { String::new() } else { libs[i - 1].0.clone() })); + } + ui.end_row(); + }); + changed +} + +/// The arrow between a row's current and new colours: solid when the row is recoloured, a dashed +/// line without a head when it is excluded. +fn arrow(ui: &Ui, r: Rect, on: bool, hovered: bool) { + let t = Tokens::get(ui.ctx()); + let c = if hovered { + t.text_strong + } else if on { + t.text + } else { + t.text_disabled + }; + let (a, b) = (pos2(r.left() + 6.0, r.center().y), pos2(r.right() - 6.0, r.center().y)); + let stroke = egui::Stroke::new(1.2, c); + if on { + ui.painter().line_segment([a, b], stroke); + ui.painter().line_segment([b, b + vec2(-5.0, -4.0)], stroke); + ui.painter().line_segment([b, b + vec2(-5.0, 4.0)], stroke); + } else { + ui.painter().extend(egui::Shape::dashed_line(&[a, b], stroke, 3.0, 3.0)); + } +} + +/// Merge the selected rows into the first of them (its new colour stays). +fn merge(rows: &mut Vec, sel: &[usize]) { + let mut sel: Vec = sel.iter().copied().filter(|i| *i < rows.len()).collect(); + sel.sort_unstable(); + let Some((&first, rest)) = sel.split_first() else { return }; + let mut moved = vec![]; + for &i in rest.iter().rev() { + let row = rows.remove(i); + moved.splice(0..0, row["from"].as_array().cloned().unwrap_or_default()); + } + if let Some(dst) = rows[first]["from"].as_array_mut() { + dst.extend(moved); + } +} + +/// Split the selected rows into one row per colour, each its own new colour. +fn separate(rows: &mut Vec, sel: &[usize]) { + let mut out = vec![]; + for (i, row) in rows.drain(..).enumerate() { + match row["from"].as_array() { + Some(a) if sel.contains(&i) && a.len() > 1 => out.extend(a.iter().map(|k| json!({"from": [k], "to": k}))), + _ => out.push(row), + } + } + *rows = out; +} + +/// Position of hue `h` (degrees) and saturation `s` on a wheel of radius `r` around `c`. +fn wheel_pos(c: Pos2, r: f32, h: f32, s: f32) -> Pos2 { + let a = h.to_radians(); + c + vec2(a.cos(), -a.sin()) * r * s +} + +/// The hue and saturation wheel at brightness `v`: hue around, saturation outwards. +fn wheel_shape(c: Pos2, r: f32, v: f32) -> egui::Shape { + const SEG: u32 = 72; + const RINGS: u32 = 8; + let mut mesh = egui::Mesh::default(); + mesh.colored_vertex(c, c32(&Color::from_hsb(0.0, 0.0, v))); + for ring in 1..=RINGS { + let s = ring as f32 / RINGS as f32; + for k in 0..SEG { + let h = k as f32 * 360.0 / SEG as f32; + mesh.colored_vertex(wheel_pos(c, r, h, s), c32(&Color::from_hsb(h, s, v))); + } + } + let at = |ring: u32, k: u32| if ring == 0 { 0 } else { 1 + (ring - 1) * SEG + k % SEG }; + for ring in 0..RINGS { + for k in 0..SEG { + if ring == 0 { + mesh.add_triangle(0, at(1, k), at(1, k + 1)); + } else { + mesh.add_triangle(at(ring, k), at(ring + 1, k), at(ring + 1, k + 1)); + mesh.add_triangle(at(ring, k), at(ring + 1, k + 1), at(ring, k + 1)); + } + } + } + egui::Shape::mesh(mesh) +} + +/// Set row `i`'s new colour to hue/saturation/brightness `hsb` (keeping its model); with `linked` +/// every other row turns by the same hue, scales its saturation and shifts its brightness alike. +/// Excluded rows stay. +fn move_color(rows: &mut [Value], i: usize, hsb: [f32; 3], linked: bool) { + let Some(old) = rows.get(i).and_then(to_color) else { return }; + let [h0, s0, v0] = old.to_hsb(); + let (dh, ratio, dv) = (hsb[0] - h0, if s0 > 0.01 { hsb[1] / s0 } else { 1.0 }, hsb[2] - v0); + for (j, row) in rows.iter_mut().enumerate() { + let Some(c) = to_color(row).filter(|_| !excluded(row)) else { continue }; + let new = if j == i { + hsb + } else if linked { + let [h, s, v] = c.to_hsb(); + [h + dh, (s * ratio).clamp(0.0, 1.0), (v + dv).clamp(0.0, 1.0)] + } else { + continue; + }; + row["to"] = key(&keep_model(c, Color::from_hsb(new[0], new[1], new[2]))); + } +} + +/// The Edit tab. Returns true when the rows changed. +fn edit_tab(ui: &mut Ui, d: &mut Dialog, rows: &mut [Value]) -> bool { + let t = Tokens::get(ui.ctx()); + let mut changed = false; + let linked = d.bool("linked"); + let active: Vec = (0..rows.len()).filter(|i| !excluded(&rows[*i]) && to_color(&rows[*i]).is_some()).collect(); + let base = selection(d).into_iter().find(|i| active.contains(i)).or(active.first().copied()); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Harmony Rules:").color(t.text_dim)); + let rule = Harmony::parse(&d.str("rule")).unwrap_or(Harmony::Complementary); + let labels: Vec<&str> = Harmony::ALL.iter().map(|h| h.label()).collect(); + // Choosing a rule gives the new colours its colours, from the base colour. + if let Some(i) = widgets::dropdown(ui, "recolor-rule", rule.label(), &labels, 170.0) { + d.fields.insert("rule".into(), json!(Harmony::ALL[i].id())); + if let Some((b, c)) = base.and_then(|b| Some((b, to_color(&rows[b])?))) { + apply_rule(rows, Harmony::ALL[i], b, c, &active); changed = true; } } - if ui.button("Reset").clicked() { - for p in pairs.iter_mut() { - p[1] = p[0].clone(); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let tip = if linked { "Unlink harmony colors" } else { "Link harmony colors" }; + if widgets::icon_button(ui, if linked { "link" } else { "link-2-off" }, tip, linked, 24.0).clicked() { + d.fields.insert("linked".into(), json!(!linked)); } + }); + }); + ui.add_space(8.0); + let v = base.and_then(|b| to_color(&rows[b])).map_or(1.0, |c| c.to_hsb()[2]); + ui.horizontal(|ui| { + ui.add_space(((ui.available_width() - WHEEL) / 2.0).max(0.0)); + let (rect, resp) = ui.allocate_exact_size(vec2(WHEEL, WHEEL), Sense::click_and_drag()); + let (c, r) = (rect.center(), WHEEL / 2.0 - 10.0); + let painter = ui.painter_at(rect); + painter.add(wheel_shape(c, r, v.max(0.15))); + painter.circle_stroke(c, r, egui::Stroke::new(1.0, t.border)); + let at = |rows: &[Value], i: usize| { + to_color(&rows[i]).map(|col| { + let [h, s, _] = col.to_hsb(); + wheel_pos(c, r, h, s) + }) + }; + for &i in &active { + let (Some(p), Some(col)) = (at(rows, i), to_color(&rows[i])) else { continue }; + let big = Some(i) == base; + painter.line_segment([c, p], egui::Stroke::new(1.0, egui::Color32::from_white_alpha(160))); + painter.circle(p, if big { 8.0 } else { 5.5 }, c32(&col), egui::Stroke::new(1.5, egui::Color32::WHITE)); + painter.circle_stroke(p, if big { 9.0 } else { 6.5 }, egui::Stroke::new(1.0, egui::Color32::BLACK)); + } + // Drag a marker: the nearest one within reach of the press. + let id = ui.id().with("recolor-wheel-drag"); + if (resp.drag_started() || resp.clicked()) + && let Some(p) = resp.interact_pointer_pos() + { + let near = active.iter().filter_map(|&i| Some((i, at(rows, i)?.distance(p)))).filter(|x| x.1 <= 14.0).min_by(|a, b| a.1.total_cmp(&b.1)); + ui.data_mut(|m| m.insert_temp(id, near.map(|x| x.0))); + if let Some((i, _)) = near { + d.fields.insert("__sel".into(), json!([i])); + } + } + if resp.dragged() + && let (Some(i), Some(p)) = (ui.data(|m| m.get_temp::>(id)).flatten(), resp.interact_pointer_pos()) + && let Some(old) = to_color(&rows[i]) + { + let dv = p - c; + let h = (-dv.y).atan2(dv.x).to_degrees().rem_euclid(360.0); + move_color(rows, i, [h, (dv.length() / r).clamp(0.0, 1.0), old.to_hsb()[2]], linked); changed = true; } }); - d.fields.insert("pairs".into(), Value::Array(pairs)); + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.add_space(((ui.available_width() - WHEEL - 76.0) / 2.0).max(0.0)); + ui.add_sized([70.0, 22.0], egui::Label::new(egui::RichText::new("Brightness").color(t.text_dim))); + if let Some(b) = base + && let Some(col) = to_color(&rows[b]) + { + let [h, s, _] = col.to_hsb(); + if let (Some(nv), _) = widgets::color_slider(ui, "recolor-brightness", v, WHEEL, &|x| c32(&Color::from_hsb(h, s, x))) { + move_color(rows, b, [h, s, nv], linked); + changed = true; + } + } + }); + ui.add_space(6.0); + // The new colours: click one to make it the base colour. + ui.horizontal_wrapped(|ui| { + for &i in &active { + let (r, resp) = ui.allocate_exact_size(vec2(28.0, 20.0), Sense::click()); + chip(ui, r, to_color(&rows[i]).unwrap_or_default(), false); + if Some(i) == base { + ui.painter().rect_stroke(r.expand(1.5), 2.0, egui::Stroke::new(1.5, t.accent), egui::StrokeKind::Outside); + } + if resp.on_hover_text(rows[i]["to"].as_str().unwrap_or_default()).clicked() { + d.fields.insert("__sel".into(), json!([i])); + } + } + }); changed } + +/// Give the rows `active` the colours of `rule` from the base colour `b` of row `base` (the base row +/// first, then the others in order, the rule's colours repeating), each in its row's model. +fn apply_rule(rows: &mut [Value], rule: Harmony, base: usize, b: Color, active: &[usize]) { + let colors = rule.apply(b); + let order = std::iter::once(base).chain(active.iter().copied().filter(|i| *i != base)); + for (n, i) in order.enumerate() { + let old = to_color(&rows[i]).unwrap_or(b); + rows[i]["to"] = key(&keep_model(old, colors[n % colors.len()])); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rows() -> Vec { + vec![ + json!({"from": ["rgb 255 0 0"], "to": "rgb 255 0 0"}), + json!({"from": ["rgb 0 0 255", "rgb 0 0 128"], "to": "rgb 0 0 255"}), + json!({"from": ["rgb 0 255 0"], "to": "rgb 0 255 0"}), + ] + } + + #[test] + fn merge_and_separate_rows() { + let mut r = rows(); + merge(&mut r, &[2, 0]); + assert_eq!(r.len(), 2); + assert_eq!(r[0]["from"], json!(["rgb 255 0 0", "rgb 0 255 0"])); + assert_eq!(r[0]["to"], "rgb 255 0 0", "the first row's new colour stays"); + separate(&mut r, &[1]); + assert_eq!(r.len(), 3); + assert_eq!(r[1], json!({"from": ["rgb 0 0 255"], "to": "rgb 0 0 255"})); + assert_eq!(r[2]["to"], "rgb 0 0 128"); + } + + #[test] + fn linked_handles_move_together() { + let mut r = rows(); + move_color(&mut r, 0, [120.0, 1.0, 1.0], false); + assert_eq!(r[0]["to"], "rgb 0 255 0"); + assert_eq!(r[1]["to"], "rgb 0 0 255", "unlinked: only that colour"); + let mut r = rows(); + move_color(&mut r, 0, [120.0, 1.0, 1.0], true); + assert_eq!(r[1]["to"], "rgb 255 0 0", "linked: every colour turns by the same hue"); + assert_eq!(r[2]["to"], "rgb 0 0 255"); + // Excluded rows stay. + let mut r = rows(); + r[2]["exclude"] = json!(true); + move_color(&mut r, 0, [120.0, 1.0, 1.0], true); + assert_eq!(r[2]["to"], "rgb 0 255 0"); + } + + #[test] + fn harmony_rules_fill_the_rows_from_the_base() { + let mut r = rows(); + apply_rule(&mut r, Harmony::Triad, 0, Color::rgb(1.0, 0.0, 0.0), &[0, 1, 2]); + assert_eq!([&r[0]["to"], &r[1]["to"], &r[2]["to"]], ["rgb 255 0 0", "rgb 0 255 0", "rgb 0 0 255"]); + } +} diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index d1c3c25bb..ecffbffb8 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -218,6 +218,7 @@ fn dialogs_do_not_stretch_to_the_screen() { ("ui.spotColors", json!({})), ("ui.paramDialog", json!({"command": "object.path.simplify", "label": "Simplify", "params": {"tolerance": 2}})), ("effect.dialog", json!({"effect": "distort.roughen"})), + ("ui.recolorDialog", json!({})), ]; let height = |id: &str, p: &serde_json::Value, screen_h: f32| -> Option<(String, f32)> { let mut app = app(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 631b43ae0..066c81fac 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -39,6 +39,8 @@ mod tests_overprint; #[cfg(test)] mod tests_paintchips; #[cfg(test)] +mod tests_recolor; +#[cfg(test)] mod tests_transparencygrid; use std::sync::mpsc::{Receiver, Sender}; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 81f0f9944..cb5099a4b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -158,7 +158,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "{effect: id, index?: int (edit that applied effect in place, prefilled; OK runs effect.setParams), item?: appearance item index|null (the fill/stroke whose effects, null the object's; default: the Appearance panel's active item)} open the effect's dialog with live preview; without `index`, an effect already in that list first opens `effectExists` (confirm edits it, discard adds another) → {pending: \"effectExists\"}", ), ("ui.paramDialog", "Command Dialog", "", "{command, label?, params} open a parameter dialog for any command"), - ("ui.recolorDialog", "Recolor Artwork…", "", "{} open Recolor Artwork (engine: recolor.colors / recolor.apply)"), + ( + "ui.recolorDialog", + "Recolor Artwork…", + "", + "{colors?: n (an n-colour job: n rows, Scale Tints) | [colour] (new colours to assign, in order), library?: id or name (Limit to Library; \"\" the first library)} open Recolor Artwork (dialog `recolor`; engine: recolor.reduce / recolor.apply)", + ), ("effect.applyLast", "Apply Last Effect", "Cmd+Shift+E", "{}"), ("file.export.pdf", "Save as PDF…", "", "{path?, artboard? | artboards? | range?: \"1-3, 5\"} (document.export options)"), ("help.about", "About VectorCraft", "", "{}"), @@ -604,14 +609,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::open_effect_dialog(app, p), - "ui.recolorDialog" => match app.run("recolor.colors", json!({})) { - Ok(v) => { - let pairs: Vec = v["colors"].as_array().cloned().unwrap_or_default().iter().map(|c| json!([c["hex"], c["hex"]])).collect(); - app.ui.dialog = Some(crate::state::Dialog::new("recolor", json!({"pairs": pairs, "preview": true}))); - Ok(Value::Null) - } - Err(e) => Err(e), - }, + "ui.recolorDialog" => crate::dialogs::recolor::open(app, p), "ui.paramDialog" => { let cmd = s("command").unwrap_or_default(); let mut fields = p.get("params").and_then(Value::as_object).cloned().unwrap_or_default(); @@ -990,6 +988,15 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { "Edit Colors", vec![ c("Recolor Artwork…", "ui.recolorDialog"), + sub( + "Recolor with Preset", + vec![ + cp("1 Color Job…", "ui.recolorDialog", json!({"colors": 1})), + cp("2 Color Job…", "ui.recolorDialog", json!({"colors": 2})), + cp("3 Color Job…", "ui.recolorDialog", json!({"colors": 3})), + cp("Color Library…", "ui.recolorDialog", json!({"library": ""})), + ], + ), c("Adjust Color Balance…", "ui.colorBalanceDialog"), c("Blend Front to Back", "edit.colors.blendFrontToBack"), c("Blend Horizontally", "edit.colors.blendHorizontally"), diff --git a/crates/ui-egui/src/panels/color_guide.rs b/crates/ui-egui/src/panels/color_guide.rs index abdda9c2b..eb26a1816 100644 --- a/crates/ui-egui/src/panels/color_guide.rs +++ b/crates/ui-egui/src/panels/color_guide.rs @@ -133,7 +133,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { widgets::bottom_bar(ui, |ui| { widgets::icon_button_enabled(ui, "library", "Limit to Swatch Library (on the roadmap)", false, false, 24.0); ui.add_space((ui.available_width() - 3.0 * 28.0).max(0.0)); - widgets::icon_button_enabled(ui, "palette", "Edit or Apply Colors (on the roadmap)", false, false, 24.0); + // Recolor Artwork with the harmony colours as the new colours (the selected art's, if any). + let art = app.session.active().is_some_and(|d| !d.selection.is_empty()); + if widgets::icon_button_enabled(ui, "palette", "Edit or Apply Colors", false, art, 24.0).clicked() { + app.run("ui.recolorDialog", json!({ "colors": g.colors.iter().map(color_json).collect::>() })).ok(); + } if widgets::icon_button_enabled(ui, "dc-new-item", "Save selected colors as swatches", false, !sel.is_empty(), 24.0).clicked() { save_selected(app, &ctx); } diff --git a/crates/ui-egui/src/tests_recolor.rs b/crates/ui-egui/src/tests_recolor.rs new file mode 100644 index 000000000..8af4adcaf --- /dev/null +++ b/crates/ui-egui/src/tests_recolor.rs @@ -0,0 +1,111 @@ +//! Recolor Artwork (M3.78), drawn headlessly. + +use egui::{Event, Pos2, Rect, vec2}; +use serde_json::{Value, json}; +use vectorcraft_engine::Session; + +use crate::{VectorcraftApp, dialogs}; + +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + app +} + +fn context() -> egui::Context { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx +} + +fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec, time: f64, draw: fn(&mut VectorcraftApp, &mut egui::Ui)) { + let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(900.0, 800.0))); + let input = egui::RawInput { events, time: Some(time), screen_rect, ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| draw(app, ui)); + out.textures_delta.clear(); +} + +fn dialog_frame(app: &mut VectorcraftApp, ctx: &egui::Context) { + let time = ctx.input(|i| i.time) + 1.0; + frame(app, ctx, vec![], time, |app, ui| dialogs::show(app, ui.ctx())); +} + +/// Rectangles filled with `colors`, all selected. +fn art(app: &mut VectorcraftApp, colors: &[&str]) { + for (i, c) in colors.iter().enumerate() { + let id = app.run("shape.rectangle", json!({"x": 10 + 30 * i, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone(); + app.run("paint.setFill", json!({"ids": [id], "color": c})).unwrap(); + app.run("paint.setStroke", json!({"ids": [id], "none": true})).unwrap(); + } + app.run("select.all", json!({})).unwrap(); +} + +fn colors(app: &mut VectorcraftApp) -> usize { + app.session.execute("recolor.colors", &json!({})).unwrap()["colors"].as_array().unwrap().len() +} + +fn field(app: &VectorcraftApp, k: &str) -> Value { + app.ui.dialog.as_ref().and_then(|d| d.fields.get(k).cloned()).unwrap_or(Value::Null) +} + +fn set(app: &mut VectorcraftApp, k: &str, v: Value) { + app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); +} + +#[test] +fn the_dialog_reduces_previews_and_applies_as_one_undo_step() { + let mut app = app(); + let ctx = context(); + art(&mut app, &["#ff0000", "#e01010", "#0000ff", "#1010e0", "#ff3030"]); + app.run("ui.recolorDialog", json!({})).unwrap(); + dialog_frame(&mut app, &ctx); + assert_eq!(field(&app, "rows").as_array().unwrap().len(), 5, "Auto: a row per colour"); + assert!(app.session.in_interaction(), "the dialog previews"); + // Two colours, Exact; OK without another frame still reduces first. + set(&mut app, "colors", json!(2)); + set(&mut app, "method", json!("exact")); + let undo = app.session.doc().unwrap().history.undo.len(); + dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(colors(&mut app), 2); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(colors(&mut app), 5); + // Cancel rolls the preview back. + app.run("ui.recolorDialog", json!({"colors": 1})).unwrap(); + set(&mut app, "method", json!("exact")); + dialog_frame(&mut app, &ctx); + assert_eq!(colors(&mut app), 1, "previewed"); + dialogs::cancel(&mut app); + assert_eq!(colors(&mut app), 5); +} + +#[test] +fn presets_open_colour_jobs_and_libraries_and_both_tabs_draw() { + let mut app = app(); + let ctx = context(); + art(&mut app, &["#ff0000", "#00ff00", "#0000ff"]); + let entries = crate::menus::menu_entries(&app); + let presets: Vec<(&str, &Value)> = + entries.iter().filter(|e| e.path.last().is_some_and(|p| p == "Recolor with Preset")).map(|e| (e.label.as_str(), &e.params)).collect(); + assert_eq!(presets.iter().map(|p| p.0).collect::>(), ["1 Color Job…", "2 Color Job…", "3 Color Job…", "Color Library…"]); + app.run("ui.recolorDialog", presets[1].1.clone()).unwrap(); + dialog_frame(&mut app, &ctx); + assert_eq!((field(&app, "colors"), field(&app, "method")), (json!(2), json!("scaleTints"))); + assert_eq!(field(&app, "rows").as_array().unwrap().len(), 2); + set(&mut app, "tab", json!("edit")); + dialog_frame(&mut app, &ctx); + dialogs::cancel(&mut app); + app.run("ui.recolorDialog", presets[3].1.clone()).unwrap(); + assert_eq!(field(&app, "limitTo"), "web-safe-216", "Color Library starts on the first library"); + dialog_frame(&mut app, &ctx); + let web = |k: &Value| vectorcraft_engine::cmd::color_value(k).unwrap().to_rgb().iter().all(|v| ((v * 5.0).round() - v * 5.0).abs() < 1e-3); + assert!(field(&app, "rows").as_array().unwrap().iter().all(|r| web(&r["to"]))); + dialogs::cancel(&mut app); + // The Color Guide's colours become the new colours. + app.run("ui.recolorDialog", json!({"colors": ["#123456", "#abcdef"]})).unwrap(); + dialog_frame(&mut app, &ctx); + let tos: Vec = field(&app, "rows").as_array().unwrap().iter().map(|r| r["to"].clone()).collect(); + assert_eq!(tos, [json!("rgb 18 52 86"), json!("rgb 171 205 239")]); + dialogs::cancel(&mut app); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 471ddafa6..75c9715e9 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -82,6 +82,13 @@ canvas while open; `ui.dialog.confirm` keeps the result as one undo step (`edit. `edit.colors.saturate`) and `ui.dialog.cancel` rolls it back. Global mode answers with an error until tints of global and spot colours exist; the dialog then stays open. +Recolor Artwork: `ui.recolorDialog {colors?, library?}` opens the `recolor` dialog on the selected art (`colors`: a +count for an n-colour job, or colours to assign as the new colours; `library`: Limit to Library, `""` for the first). +Fields: `rows` (`[{from: [keys], to: key, exclude?}]` as `recolor.reduce` returns them), `colors` (null for Auto), +`method`, `preserveWhite`, `preserveBlack`, `preserveGrays`, `limitTo`, `tab` (`assign` or `edit`), `rule` (harmony +id), `linked`, `preview`. Setting `colors`, a preserve flag or `limitTo` reduces the rows again (also on +`ui.dialog.confirm`). The dialog previews `recolor.apply` on the canvas; confirm keeps it as one undo step. + Library panel: `engine.execute {command: "window.swatchLibrary", params: {library}}` opens the read-only library panel on a swatch library (`ui.inspect` shows it as `library_panel: {kind, id}`; `library: null` closes it). Clicking a swatch there runs `swatch.library.add {library, names: [name], apply}` (the active proxy, or the other diff --git a/docs/mcp.md b/docs/mcp.md index 41427b818..22c5663ec 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -317,6 +317,24 @@ patterns out. The Blend commands also grade gradient meshes, which keep their sh {"name":"run_command","arguments":{"command":"edit.colors.adjustBalance","params":{"mode":"cmyk","m":-20,"k":10}}} ``` +Recolor Artwork keys colours by identity, not hex: `recolor.colors` lists each colour's `key` (`"rgb 255 0 0"`, +`"cmyk 0 100 100 0"`, `"gray 40"`, `"lab 55 60 -40"`; RGB in 0–255 levels, CMYK and Gray in percent), so CMYK, +Lab and RGB colours that look alike stay apart, and every colour parameter also accepts a key. `recolor.reduce {colors?, method?, +preserve?, limitTo?}` groups the selection's colours into rows of similar colours (k-means in Lab, weighted by use; +tints of a global swatch share its row; White and Black are preserved by default) and returns a map of rows +`[{from: [keys], to: key}]` to edit and pass to `recolor.apply {map, method?, limitTo?}`. +`method` says how a row's colours take its new colour: `exact`, `preserveTints` (tints of the row's darkest colour +stay tints), `scaleTints` (every colour becomes a tint as light, relative to the darkest), `tintsShades` (lighter and +darker than the row's average become tints and shades) or `hueShift` (the most saturated colour takes the new colour, +the others turn by the same hue). `limitTo` snaps new colours to a swatch library's nearest colour; +`recolor.randomize {map, order?, saturationBrightness?, seed?}` shuffles or varies new colours; a row with +`exclude: true` keeps its colours. + +```json +{"name":"run_command","arguments":{"command":"recolor.reduce","params":{"colors":2,"preserve":{"grays":true}}}} +{"name":"run_command","arguments":{"command":"recolor.apply","params":{"map":[{"from":["cmyk 0 100 100 0","cmyk 0 40 40 0"],"to":"cmyk 100 50 0 0"}],"method":"scaleTints"}}} +``` + ## Group, layer and type appearance Groups and layers carry fills, strokes and effects of their own, as in the reference app: their fills and strokes From fcc0f4ca0fa564dba89f1caf314b7999ce2cf54c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 03:17:07 +0700 Subject: [PATCH 2/2] M3.79: Edit or Apply Color Group opens Recolor Artwork on a colour group Double-clicking a colour group's folder in the Swatches panel, or the options button (Edit or Apply Color Group) while a group is selected, opens Recolor Artwork on the group: without art the rows are the group's colours and OK rewrites the group; with art selected the art is reduced to the group's size, takes its colours, and art and group change in one undo step. New swatch.editGroup {group, colors, rename?} rewrites a group in order (art linked to its global swatches follows); recolor.apply takes group, groupColors and rename. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/recolor.rs | 28 ++++++- crates/engine/src/cmd/swatch.rs | 84 +++++++++++++++++++++ crates/engine/src/tests_recolor.rs | 83 ++++++++++++++++++++- crates/ui-egui/src/dialogs/recolor.rs | 102 ++++++++++++++++++-------- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/panels/swatches.rs | 34 +++++++-- crates/ui-egui/src/tests_recolor.rs | 99 ++++++++++++++++++++++++- docs/control-protocol.md | 15 ++-- docs/mcp.md | 11 ++- 9 files changed, 401 insertions(+), 57 deletions(-) diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index c4ed2fd67..74c5c7794 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -30,8 +30,8 @@ pub fn specs() -> Vec { "Recolor Artwork", ["Edit", "Edit Colors"], None, - "{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null, exclude?: false (the row's colours are kept)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library (new colours snap to its nearest colour), includeImages?: true, includePatterns?: true} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces) as one undo step → {changed}", - has_selection, + "{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null, exclude?: false (the row's colours are kept)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library (new colours snap to its nearest colour), group?: colour group rewritten with groupColors?: [colour] (default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces) and the group, as one undo step → {changed}", + has_doc, apply ), cmd!( @@ -305,10 +305,32 @@ fn apply(s: &mut Session, p: &Value) -> Result { } } let ids = s.doc()?.selection.objects.clone(); + let group = str_param(p, "group"); + if ids.is_empty() && group.is_none() { + return Err(bad(C, "select artwork to recolour, or give a colour `group`")); + } let map = ColorMap::new(&rows, method); let f = |c: Color| map.map(c); let scope = Scope { fill: true, stroke: true, ..Scope::of(p) }; - let changed = s.edit("Recolor Artwork", |d, _| Ok(Recolor::new(scope, &f).run(d, &ids)))?; + let label = if ids.is_empty() { "Edit Color Group" } else { "Recolor Artwork" }; + let new: Vec = match p.get("groupColors").and_then(Value::as_array) { + Some(cs) => cs + .iter() + .map(|v| { + color_value(v).map(|c| palette.as_ref().map_or(c, |pl| pl.nearest(c))).ok_or_else(|| bad(C, format!("bad colour {v} in groupColors"))) + }) + .collect::>()?, + None => rows.iter().filter_map(Row::new_color).collect(), + }; + let rename = str_param(p, "rename"); + let (changed, edit) = s.edit(label, |d, _| { + let changed = Recolor::new(scope, &f).run(d, &ids); + let edit = group.map(|g| super::swatch::rewrite_group(d, g, &new, rename, C)).transpose()?; + Ok((changed, edit)) + })?; + if let Some(e) = edit { + e.defaults(s); + } Ok(json!({ "changed": changed })) } diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index 57483fdec..90d0aee28 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -122,6 +122,15 @@ pub fn specs() -> Vec { has_doc, swatch_spot_options ), + cmd!( + "swatch.editGroup", + "Edit Color Group", + ["Window", "Swatches"], + None, + "{group: colour group, colors: [colour] (hex, key, {c,m,y,k} or {l,a,b}; the group's swatches take them in order, extra colours become new swatches, swatches past the end are removed; built-in swatches can't change), rename?: new group name} rewrite a colour group as one undo step; art linked to its global swatches takes their new colours (and keeps its colour, unlinked, when its swatch goes) → {name, swatches: [names], relinked}", + has_doc, + swatch_edit_group + ), ] } @@ -770,3 +779,78 @@ fn follow_bases(bases: &[(String, Color)]) -> impl FnMut(&mut Color, &mut Option std::mem::replace(c, new) != new } } + +/// A colour group rewrite: its name afterwards and the swatch links it changed. +pub(super) struct GroupEdit { + name: String, + swatches: Vec, + relinks: Vec, + relinked: usize, +} + +impl GroupEdit { + /// Apply the link changes to the default fill and stroke for new art. + pub(super) fn defaults(&self, s: &mut Session) { + self.relinks.iter().for_each(|r| r.defaults(s)); + } +} + +/// Rewrite colour group `group` with `colors`: its swatches take them in order (keeping their +/// names, kinds and global or spot flags), extra colours are added as new swatches and swatches +/// past the end are removed. Paints linked to its global swatches follow (a removed swatch's are +/// unlinked); `rename` renames the group. A built-in swatch (None, Registration) it would change or +/// remove is refused. +pub(super) fn rewrite_group(d: &mut Document, group: &str, colors: &[Color], rename: Option<&str>, cmd: &str) -> Result { + let gi = d.swatch_groups.iter().position(|g| g.name == group).ok_or_else(|| bad(cmd, format!("no colour group `{group}`")))?; + let name = match rename.map(str::trim).filter(|n| !n.is_empty() && *n != group) { + Some(n) => d.free_swatch_name(n), + None => group.to_string(), + }; + let old = &d.swatch_groups[gi].swatches; + if let Some(w) = old.iter().enumerate().find(|(i, w)| w.is_reserved() && colors.get(*i).is_none_or(|c| w.paint.color() != Some(*c))).map(|x| x.1) + { + return Err(bad(cmd, format!("`{}` can't be edited or removed", w.name))); + } + let mut relinks = vec![]; + let mut swatches = std::mem::take(&mut d.swatch_groups[gi].swatches); + for gone in swatches.split_off(colors.len().min(swatches.len())) { + if gone.global { + relinks.push(Relink { from: gone.name.clone(), to: gone.name, color: None, keep: false }); + } + } + for (w, c) in swatches.iter_mut().zip(colors) { + if w.paint.color() != Some(*c) { + // A tint swatch becomes a colour of its own. + w.paint = Paint::solid(*c); + if w.global { + // Art linked to a Lab spot shows it as the Spot Colors options say. + relinks.push(Relink { from: w.name.clone(), to: w.name.clone(), color: Some(d.linked_color(*c, w.spot)), keep: true }); + } + } + } + for c in colors.iter().skip(swatches.len()) { + let nm = unique_name(&color_name(*c), |n| n == name || d.swatch_name_taken(n) || swatches.iter().any(|w| w.name == n)); + swatches.push(Swatch { name: nm, paint: Paint::solid(*c), global: false, spot: false }); + } + let g = &mut d.swatch_groups[gi]; + (g.name, g.swatches) = (name.clone(), swatches); + let names = g.swatches.iter().map(|w| w.name.clone()).collect(); + let relinked = if relinks.is_empty() { 0 } else { d.map_solid_paints(&mut |c, l, t| relinks.iter().any(|r| r.apply(c, l, t))) }; + Ok(GroupEdit { name, swatches: names, relinks, relinked }) +} + +fn swatch_edit_group(s: &mut Session, p: &Value) -> Result { + const C: &str = "swatch.editGroup"; + let group = str_param(p, "group").ok_or_else(|| bad(C, "missing `group`"))?; + let colors: Vec = p + .get("colors") + .and_then(Value::as_array) + .ok_or_else(|| bad(C, "missing `colors`"))? + .iter() + .map(|v| color_value(v).ok_or_else(|| bad(C, format!("bad colour {v}")))) + .collect::>()?; + let rename = str_param(p, "rename"); + let e = s.edit("Edit Color Group", |d, _| rewrite_group(d, group, &colors, rename, C))?; + e.defaults(s); + Ok(json!({"name": e.name, "swatches": e.swatches, "relinked": e.relinked})) +} diff --git a/crates/engine/src/tests_recolor.rs b/crates/engine/src/tests_recolor.rs index a0cb953be..a8de2898a 100644 --- a/crates/engine/src/tests_recolor.rs +++ b/crates/engine/src/tests_recolor.rs @@ -1,5 +1,5 @@ //! Recolor Artwork (M3.78): colour reduction, preserve rules, recolour methods, Limit to Library, -//! randomize. +//! randomize; Edit Color Group (M3.79). use serde_json::{Value, json}; use vectorcraft_color::Color; @@ -200,6 +200,8 @@ fn methods_and_bad_params() { assert!(s.execute("recolor.apply", &json!({"map": {"#ff0000": "#00ff00"}, "method": "nope"})).is_err()); assert!(s.execute("recolor.apply", &json!({"map": {"#ff0000": "nonsense"}})).is_err()); assert!(s.execute("recolor.apply", &json!({})).is_err()); + run(&mut s, "select.none", json!({})); + assert!(s.execute("recolor.apply", &json!({"map": {}})).is_err(), "needs art or a group"); } #[test] @@ -222,3 +224,82 @@ fn randomize_shuffles_new_colours_and_skips_excluded_rows() { let hue = |v: &Value| color_value(v).unwrap().to_hsb()[0]; assert!((hue(&sb["map"][0]["to"]) - 0.0).abs() < 1.0 && (hue(&sb["map"][1]["to"]) - 120.0).abs() < 1.0, "hue kept"); } + +#[test] +fn edit_group_rewrites_in_order_as_one_undo_step() { + let mut s = session(); + let g = run(&mut s, "swatch.newGroup", json!({"name": "Theme", "colors": ["#ff0000", "#00ff00", "#0000ff"]})); + let names = keys(&g["swatches"]); + let group = |s: &Session| { + let d = &s.doc().unwrap().doc; + let g = d.swatch_groups.iter().find(|g| g.name == "Theme" || g.name == "Brand Theme").unwrap(); + (g.name.clone(), g.swatches.iter().map(|w| (w.name.clone(), w.paint.color().unwrap().to_hex())).collect::>()) + }; + let before = group(&s); + let r = run( + &mut s, + "swatch.editGroup", + json!({"group": "Theme", "colors": ["#111111", "cmyk 0 0 0 50", "#333333", "#444444"], "rename": "Brand Theme"}), + ); + assert_eq!(r["name"], "Brand Theme"); + let (name, sw) = group(&s); + assert_eq!(name, "Brand Theme"); + assert_eq!(sw.iter().map(|w| w.1.as_str()).collect::>()[..1], ["#111111"]); + assert_eq!(sw.len(), 4, "an extra colour becomes a new swatch"); + assert_eq!(sw[..3].iter().map(|w| w.0.clone()).collect::>(), names, "the swatches keep their names, in order"); + assert!(matches!(s.doc().unwrap().doc.swatch(&names[1]).unwrap().paint.color(), Some(Color::Cmyk { .. }))); + run(&mut s, "edit.undo", json!({})); + assert_eq!(group(&s), before, "one undo step"); + // Fewer colours drop the swatches past the end. + run(&mut s, "swatch.editGroup", json!({"group": "Theme", "colors": ["#ababab"]})); + assert_eq!(group(&s).1.len(), 1); + assert!(s.execute("swatch.editGroup", &json!({"group": "Nope", "colors": []})).is_err()); +} + +#[test] +fn edit_group_relinks_art_and_apply_rewrites_the_group_with_the_art() { + let mut s = session(); + run(&mut s, "swatch.newGroup", json!({"name": "Theme", "colors": ["#ff0000"]})); + let name = s.doc().unwrap().doc.swatch_groups.iter().find(|g| g.name == "Theme").unwrap().swatches[0].name.clone(); + run(&mut s, "swatch.edit", json!({"name": name, "global": true})); + let linked = rect(&mut s, json!("#000000")); + run(&mut s, "paint.setFill", json!({"ids": [linked.0], "swatch": name})); + let r = run(&mut s, "swatch.editGroup", json!({"group": "Theme", "colors": ["#00ff00"]})); + assert_eq!(r["relinked"], 1); + assert_eq!(fill(&s, linked).to_hex(), "#00ff00", "linked art follows its swatch"); + // Recolor with a group: the art and the group change in one undo step. + let plain = rect(&mut s, json!("#0000ff")); + run(&mut s, "select.set", json!({"ids": [plain.0]})); + let undo = s.doc().unwrap().history.undo.len(); + run(&mut s, "recolor.apply", json!({"map": [{"from": ["#0000ff"], "to": "#ffff00"}], "group": "Theme"})); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + assert_eq!(fill(&s, plain).to_hex(), "#ffff00"); + assert_eq!(s.doc().unwrap().doc.swatch(&name).unwrap().paint.color().unwrap().to_hex(), "#ffff00"); + // Without a selection, only the group. + run(&mut s, "select.none", json!({})); + run(&mut s, "recolor.apply", json!({"map": [{"from": ["#ffff00"], "to": "#ff00ff"}], "group": "Theme"})); + assert_eq!(s.doc().unwrap().doc.swatch(&name).unwrap().paint.color().unwrap().to_hex(), "#ff00ff"); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Edit Color Group"); + // Explicit group colours. + run(&mut s, "recolor.apply", json!({"map": [], "group": "Theme", "groupColors": ["#010203", "cmyk 0 0 0 20"]})); + let g = s.doc().unwrap().doc.swatch_groups.iter().find(|g| g.name == "Theme").unwrap().clone(); + assert_eq!(g.swatches.iter().map(|w| w.paint.color().unwrap()).collect::>(), [Color::rgb8(1, 2, 3), Color::cmyk(0.0, 0.0, 0.0, 0.2)]); +} + +#[test] +fn edit_group_refuses_to_change_built_in_swatches() { + let mut s = session(); + run(&mut s, "swatch.newGroup", json!({"name": "Marks", "colors": ["#ff0000"]})); + // A group holding the built-in Registration swatch (as an older file might). + s.edit("test", |d, _| { + d.swatch_groups.iter_mut().find(|g| g.name == "Marks").unwrap().swatches.insert(0, vectorcraft_color::swatch::registration().clone()); + Ok(()) + }) + .unwrap(); + let reg = vectorcraft_color::swatch::registration().paint.color().unwrap(); + assert!(s.execute("swatch.editGroup", &json!({"group": "Marks", "colors": ["#00ff00", "#0000ff"]})).is_err(), "changed"); + assert!(s.execute("swatch.editGroup", &json!({"group": "Marks", "colors": []})).is_err(), "removed"); + let key = vectorcraft_color::recolor::ColorKey::of(®).to_string(); + let ok = run(&mut s, "swatch.editGroup", json!({"group": "Marks", "colors": [key, "#0000ff"]})); + assert_eq!(ok["swatches"][0], vectorcraft_color::swatch::REGISTRATION, "kept as it is"); +} diff --git a/crates/ui-egui/src/dialogs/recolor.rs b/crates/ui-egui/src/dialogs/recolor.rs index 63dbee76d..591de5e59 100644 --- a/crates/ui-egui/src/dialogs/recolor.rs +++ b/crates/ui-egui/src/dialogs/recolor.rs @@ -7,12 +7,13 @@ //! saturation and brightness, Reset, the Method and Limit to Library. Edit tab: a hue and //! saturation wheel with a marker per new colour (drag one; with linked handles they all turn and //! scale together), a brightness slider and the harmony rules. The preset menu sets up a 1, 2 or 3 -//! colour job or a library. +//! colour job or a library. Opened on a colour group (Edit or Apply Color Group), OK also rewrites +//! the group with the new colours (renamed by `groupName`); without art only the group changes. //! //! Fields (`ui.dialog.set`): `rows` ([{from: [keys], to: key, exclude?}]), `colors` (null: Auto, or a //! count), `method`, `preserveWhite`, `preserveBlack`, `preserveGrays`, `limitTo` (library id, "" for -//! none), `tab` (`assign` or `edit`), `rule` (harmony id), `linked`, `preview`. Changing `colors`, -//! a preserve flag or `limitTo` reduces the rows again. +//! none), `group`, `groupName`, `tab` (`assign` or `edit`), `rule` (harmony id), `linked`, +//! `preview`. Changing `colors`, a preserve flag or `limitTo` reduces the rows again. use egui::{Pos2, Rect, Sense, Ui, pos2, vec2}; use serde_json::{Value, json}; @@ -44,17 +45,28 @@ const TABLE_H: f32 = 220.0; const TAB_H: f32 = 400.0; const PRESETS: [&str; 5] = ["Custom", "1 Color Job", "2 Color Job", "3 Color Job", "Color Library"]; -/// Open Recolor Artwork (`ui.recolorDialog {colors?, library?}`) on the selected art. `colors` is -/// a count (an n-colour job) or the new colours to assign; `library` limits to a library ("" picks +/// Open Recolor Artwork (`ui.recolorDialog {colors?, library?, group?}`) on the selected art and, +/// with `group`, on that colour group: art is reduced to as many rows as the group has colours, +/// which become their new colours; without art the rows are the group's colours. `colors` is a +/// count (an n-colour job) or the new colours to assign; `library` limits to a library ("" picks /// the first). pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { let st = app.session.active().ok_or("no document open")?; let art = !st.selection.is_empty(); - if !art { - return Err("select artwork to recolor".into()); + let group = p.get("group").and_then(Value::as_str); + let group_colors: Option> = match group { + Some(g) => { + let g = st.doc.swatch_groups.iter().find(|x| x.name == g).ok_or_else(|| format!("no colour group `{g}`"))?; + Some(g.swatches.iter().filter_map(|w| w.paint.color()).collect()) + } + None => None, + }; + if !art && group.is_none() { + return Err("select artwork (or a colour group) to recolor".into()); } - let assign: Vec = match p.get("colors") { - Some(Value::Array(cs)) => cs.iter().filter_map(color_value).map(|c| key(&c)).collect(), + let assign: Vec = match (&group_colors, p.get("colors")) { + (Some(cs), _) => cs.iter().map(key).collect(), + (None, Some(Value::Array(cs))) => cs.iter().filter_map(color_value).map(|c| key(&c)).collect(), _ => vec![], }; let count = match p.get("colors").and_then(Value::as_u64) { @@ -67,10 +79,12 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { Some(l) => swatchlib::library(&app.session, l).map(|(info, _)| info.id).ok_or_else(|| format!("no swatch library `{l}`"))?, None => String::new(), }; + // Without art the rows are the group's colours, as they are. + let rows: Vec = if art { vec![] } else { assign.iter().map(|k| json!({"from": [k], "to": k})).collect() }; let fields = json!({ - "rows": [], "colors": count, "method": Method::ScaleTints.id(), + "rows": rows, "colors": count, "method": Method::ScaleTints.id(), "preserveWhite": true, "preserveBlack": true, "preserveGrays": false, "limitTo": limit, - "tab": "assign", "rule": Harmony::Complementary.id(), "linked": true, + "group": group, "groupName": group, "tab": "assign", "rule": Harmony::Complementary.id(), "linked": true, "preview": true, "__art": art, "__assign": assign, }); app.ui.dialog = Some(Dialog::new(KIND, fields)); @@ -100,7 +114,7 @@ fn reduce_params(d: &Dialog) -> Value { } /// Reduce the art's colours into rows again when the count, preserve flags or library changed -/// (assigning the opener's colours as the new colours). +/// (assigning a group's colours or the opener's colours as the new colours). fn sync(app: &mut VectorcraftApp, d: &mut Dialog) { const DONE: &str = "__reduced"; let sig = reduce_params(d); @@ -156,9 +170,23 @@ fn selection(d: &Dialog) -> Vec { d.fields.get("__sel").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect()).unwrap_or_default() } -/// `recolor.apply` parameters: the rows, method and library. +/// `recolor.apply` parameters: the rows, method, library and the group with its new name and +/// colours: the rows' new colours, then (with art) the group's colours no row took. fn apply_params(d: &Dialog) -> Value { - json!({"map": rows(d), "method": d.str("method"), "limitTo": d.str("limitTo")}) + let rs = rows(d); + let group = d.fields.get("group").and_then(Value::as_str).map(|g| { + let rest = + d.fields.get("__assign").and_then(Value::as_array).filter(|_| d.bool("__art")).map_or(&[][..], |a| a.get(rs.len()..).unwrap_or_default()); + let colors: Vec<&Value> = rs.iter().map(|r| &r["to"]).chain(rest).collect(); + let name = d.str("groupName"); + let rename = (!name.trim().is_empty() && name != g).then_some(name); + json!({"group": g, "groupColors": colors, "rename": rename}) + }); + let mut p = json!({"map": rs, "method": d.str("method"), "limitTo": d.str("limitTo")}); + if let (Some(Value::Object(g)), Some(p)) = (group, p.as_object_mut()) { + p.extend(g.into_iter().filter(|(_, v)| !v.is_null())); + } + p } fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool { @@ -188,6 +216,13 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool { snap(app, d, &mut rs); d.fields.insert("rows".into(), Value::Array(rs)); } + if d.fields.get("group").is_some_and(Value::is_string) { + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Color Group:").color(t.text_dim)); + form::text(ui, d, "groupName", 200.0); + }); + } sync(app, d); if d.bool("__art") { form::preview(app, ui, d, "Recolor Artwork", CMD, apply_params(d)); @@ -195,7 +230,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool { false } -/// OK: keep the previewed recolour as one undo step. +/// OK: keep the previewed recolour as one undo step (or apply it, e.g. to a colour group only). fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let mut d = d.clone(); sync(app, &mut d); @@ -237,24 +272,27 @@ fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut let t = Tokens::get(ui.ctx()); let mut changed = false; let total: usize = rows.iter().map(|r| r["from"].as_array().map_or(0, Vec::len)).sum(); - ui.horizontal(|ui| { - ui.label(egui::RichText::new("Colors:").color(t.text_dim)); - let labels: Vec = std::iter::once("Auto".to_string()).chain((1..=total).map(|n| n.to_string())).collect(); - let refs: Vec<&str> = labels.iter().map(String::as_str).collect(); - let cur = d.fields.get("colors").and_then(Value::as_u64).map_or("Auto".to_string(), |n| n.to_string()); - if let Some(i) = widgets::dropdown(ui, "recolor-count", &cur, &refs, 70.0) { - d.fields.insert("colors".into(), if i == 0 { Value::Null } else { json!(i) }); - } - ui.add_space(18.0); - ui.label(egui::RichText::new("Preserve:").color(t.text_dim)); - for (k, label) in [("preserveWhite", "White"), ("preserveBlack", "Black"), ("preserveGrays", "Grays")] { - let on = d.bool(k); - if widgets::check(ui, label, on, d.bool("__art")) { - d.fields.insert(k.into(), json!(!on)); + // Reduction and the preserve rules apply to art (a colour group alone has a row per colour). + if d.bool("__art") { + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Colors:").color(t.text_dim)); + let labels: Vec = std::iter::once("Auto".to_string()).chain((1..=total).map(|n| n.to_string())).collect(); + let refs: Vec<&str> = labels.iter().map(String::as_str).collect(); + let cur = d.fields.get("colors").and_then(Value::as_u64).map_or("Auto".to_string(), |n| n.to_string()); + if let Some(i) = widgets::dropdown(ui, "recolor-count", &cur, &refs, 70.0) { + d.fields.insert("colors".into(), if i == 0 { Value::Null } else { json!(i) }); } - } - }); - ui.add_space(6.0); + ui.add_space(18.0); + ui.label(egui::RichText::new("Preserve:").color(t.text_dim)); + for (k, label) in [("preserveWhite", "White"), ("preserveBlack", "Black"), ("preserveGrays", "Grays")] { + let on = d.bool(k); + if widgets::check(ui, label, on, true) { + d.fields.insert(k.into(), json!(!on)); + } + } + }); + ui.add_space(6.0); + } ui.horizontal(|ui| { ui.add_space(6.0); ui.add_sized([CURRENT_W, 16.0], egui::Label::new(egui::RichText::new(format!("Current Colors ({total})")).color(t.text_dim))); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index cb5099a4b..627fda65e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -162,7 +162,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.recolorDialog", "Recolor Artwork…", "", - "{colors?: n (an n-colour job: n rows, Scale Tints) | [colour] (new colours to assign, in order), library?: id or name (Limit to Library; \"\" the first library)} open Recolor Artwork (dialog `recolor`; engine: recolor.reduce / recolor.apply)", + "{colors?: n (an n-colour job: n rows, Scale Tints) | [colour] (new colours to assign, in order), library?: id or name (Limit to Library; \"\" the first library), group?: colour group (Edit or Apply Color Group: OK rewrites the group with the new colours and recolours the selected art, if any)} open Recolor Artwork (dialog `recolor`; engine: recolor.reduce / recolor.apply)", ), ("effect.applyLast", "Apply Last Effect", "Cmd+Shift+E", "{}"), ("file.export.pdf", "Save as PDF…", "", "{path?, artboard? | artboards? | range?: \"1-3, 5\"} (document.export options)"), diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index b26ff6224..1254e23cc 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -396,8 +396,9 @@ fn drag_of(e: &Entry, names: Vec) -> PanelDrag { #[derive(Default)] struct TileEvents { clicked: Option<(Entry, egui::Modifiers)>, - /// Double-click opens the swatch's editor (Swatch Options, Gradient panel or pattern editing). - edit: Option, + /// Double-click opens the swatch's editor (Swatch Options, Gradient panel or pattern editing), + /// or Edit or Apply Color Group on a colour group. + edit: Option, drop: Option, /// A drag is held over a tile or row. over: bool, @@ -408,8 +409,8 @@ struct TileEvents { /// starts a [`PanelDrag`] carrying the rows it moves; see [`tile_drop`] for drags held over it. fn tile_input(ui: &Ui, resp: Response, e: &Entry, items: &[Entry], sel: &[String], list: bool, ev: &mut TileEvents) { let name = e.name(); - if matches!(e, Entry::Swatch { .. }) && resp.double_clicked() { - ev.edit = Some(name.to_string()); + if matches!(e, Entry::Swatch { .. } | Entry::Folder(_)) && resp.double_clicked() { + ev.edit = Some(e.clone()); } if resp.drag_started() { let with_sel = sel.iter().any(|s| s == name); @@ -685,8 +686,12 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, salt: &'static str) { let zone = ui.interact(out.inner_rect, ui.id().with("swatch-drop"), Sense::hover()); zone_input(ui, &zone, &mut ev); }); - if let Some(name) = ev.edit { - app.run("ui.swatchOptions", json!({"name": name})).ok(); + match ev.edit { + Some(Entry::Folder(g)) => edit_group(app, &g), + Some(e) => { + app.run("ui.swatchOptions", json!({"name": e.name()})).ok(); + } + None => {} } if let Some(drop) = ev.drop { apply_drop(app, drop); @@ -815,6 +820,11 @@ fn selected_group(app: &VectorcraftApp, sel: &[String]) -> Option { sel.iter().find(|n| d.swatch_groups.iter().any(|g| g.name == **n)).cloned() } +/// Edit or Apply Color Group: Recolor Artwork on colour group `group` (and the selected art). +fn edit_group(app: &mut VectorcraftApp, group: &str) { + app.run("ui.recolorDialog", json!({ "group": group })).ok(); +} + /// Delete `names` (swatches and colour groups) after asking, or at once with `now` (Alt-click). fn delete(app: &mut VectorcraftApp, names: Vec, now: bool) { let params = json!({"names": names}); @@ -846,8 +856,13 @@ fn bottom(app: &mut VectorcraftApp, ui: &mut Ui, sel: &[String]) { } } }); + // With a colour group selected the options button edits or applies the group. let opts = editable(app, sel); - if widgets::icon_button_enabled(ui, "dc-options", "Swatch Options", false, opts.is_some(), 24.0).clicked() + if let Some(g) = selected_group(app, sel).filter(|_| opts.is_none()) { + if widgets::icon_button(ui, "palette", "Edit or Apply Color Group", false, 24.0).clicked() { + edit_group(app, &g); + } + } else if widgets::icon_button_enabled(ui, "dc-options", "Swatch Options", false, opts.is_some(), 24.0).clicked() && let Some(n) = opts { app.run("ui.swatchOptions", json!({"name": n})).ok(); @@ -917,6 +932,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { delete(app, del, false); } let group = selected_group(app, &selected); + if menu_item(ui, "Edit or Apply Color Group…", group.is_some(), false) + && let Some(g) = &group + { + edit_group(app, g); + } if menu_item(ui, "Ungroup Color Group", group.is_some(), false) && let Some(g) = group { diff --git a/crates/ui-egui/src/tests_recolor.rs b/crates/ui-egui/src/tests_recolor.rs index 8af4adcaf..7a3be3218 100644 --- a/crates/ui-egui/src/tests_recolor.rs +++ b/crates/ui-egui/src/tests_recolor.rs @@ -1,10 +1,12 @@ -//! Recolor Artwork (M3.78), drawn headlessly. +//! Recolor Artwork (M3.78) and Edit or Apply Color Group (M3.79), drawn headlessly. -use egui::{Event, Pos2, Rect, vec2}; +use egui::{Event, Modifiers, PointerButton, Pos2, Rect, vec2}; use serde_json::{Value, json}; +use vectorcraft_color::Color; +use vectorcraft_color::recolor::ColorKey; use vectorcraft_engine::Session; -use crate::{VectorcraftApp, dialogs}; +use crate::{VectorcraftApp, dialogs, panels}; fn app() -> VectorcraftApp { let mut app = VectorcraftApp::new(Session::new(), Default::default()); @@ -30,6 +32,11 @@ fn dialog_frame(app: &mut VectorcraftApp, ctx: &egui::Context) { frame(app, ctx, vec![], time, |app, ui| dialogs::show(app, ui.ctx())); } +fn press(at: Pos2, clicks: usize) -> Vec { + let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + std::iter::once(Event::PointerMoved(at)).chain((0..clicks).flat_map(|_| [button(true), button(false)])).collect() +} + /// Rectangles filled with `colors`, all selected. fn art(app: &mut VectorcraftApp, colors: &[&str]) { for (i, c) in colors.iter().enumerate() { @@ -44,6 +51,12 @@ fn colors(app: &mut VectorcraftApp) -> usize { app.session.execute("recolor.colors", &json!({})).unwrap()["colors"].as_array().unwrap().len() } +fn group_keys(app: &VectorcraftApp, name: &str) -> Vec { + let d = &app.session.doc().unwrap().doc; + let g = d.swatch_groups.iter().find(|g| g.name == name).unwrap(); + g.swatches.iter().filter_map(|w| w.paint.color()).map(|c| json!(ColorKey::of(&c).to_string())).collect() +} + fn field(app: &VectorcraftApp, k: &str) -> Value { app.ui.dialog.as_ref().and_then(|d| d.fields.get(k).cloned()).unwrap_or(Value::Null) } @@ -109,3 +122,83 @@ fn presets_open_colour_jobs_and_libraries_and_both_tabs_draw() { assert_eq!(tos, [json!("rgb 18 52 86"), json!("rgb 171 205 239")]); dialogs::cancel(&mut app); } + +#[test] +fn double_clicking_a_colour_group_opens_it_and_ok_rewrites_it() { + let mut app = app(); + let ctx = context(); + let show: fn(&mut VectorcraftApp, &mut egui::Ui) = panels::swatches::show; + frame(&mut app, &ctx, vec![], 0.0, show); + let folder = ctx.read_response(egui::Id::new(("swatch-tile", "Brights"))).expect("the Brights folder").rect.center(); + frame(&mut app, &ctx, press(folder, 2), 1.0, show); + let d = app.ui.dialog.as_ref().expect("a dialog opened"); + assert_eq!((d.kind.as_str(), d.str("group")), (dialogs::recolor::KIND, "Brights".to_string())); + let keys = group_keys(&app, "Brights"); + let from: Vec = field(&app, "rows").as_array().unwrap().iter().map(|r| r["from"][0].clone()).collect(); + assert_eq!(from, keys, "a row per group colour, in order"); + dialog_frame(&mut app, &ctx); + // A new first colour and a new name; nothing is selected, so only the group changes. + let mut rows = field(&app, "rows"); + rows[0]["to"] = json!("cmyk 100 0 0 0"); + set(&mut app, "rows", rows); + set(&mut app, "groupName", json!("Brights 2026")); + let undo = app.session.doc().unwrap().history.undo.len(); + dialogs::confirm(&mut app).unwrap(); + let mut want = keys.clone(); + want[0] = json!("cmyk 100 0 0 0"); + assert_eq!(group_keys(&app, "Brights 2026"), want); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); +} + +#[test] +fn the_options_button_edits_a_selected_group_and_applies_it_to_the_art() { + let mut app = app(); + let ctx = context(); + art(&mut app, &["#ff0000", "#00ff00", "#0000ff", "#ffff00", "#ff00ff"]); + let show: fn(&mut VectorcraftApp, &mut egui::Ui) = panels::swatches::show; + frame(&mut app, &ctx, vec![], 0.0, show); + let folder = ctx.read_response(egui::Id::new(("swatch-tile", "Brights"))).unwrap().rect.center(); + frame(&mut app, &ctx, press(folder, 1), 1.0, show); + frame(&mut app, &ctx, vec![], 2.0, show); + // The bottom bar's third button: Swatch Options, or Edit or Apply Color Group for a group. + let mut bar: Vec = ctx.viewport(|vp| { + vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == vec2(24.0, 24.0)).map(|w| w.rect).collect() + }); + let bottom = bar.iter().map(|r| r.top()).fold(f32::MIN, f32::max); + bar.retain(|r| r.top() == bottom); + bar.sort_by(|a, b| a.left().total_cmp(&b.left())); + frame(&mut app, &ctx, press(bar[2].center(), 1), 3.0, show); + assert_eq!(field(&app, "group"), "Brights"); + dialog_frame(&mut app, &ctx); + let group = group_keys(&app, "Brights"); + let tos: Vec = field(&app, "rows").as_array().unwrap().iter().map(|r| r["to"].clone()).collect(); + assert_eq!(tos, group, "the art's colours in as many rows as the group has, taking its colours"); + dialogs::confirm(&mut app).unwrap(); + let used: Vec = app.session.execute("recolor.colors", &json!({})).unwrap()["colors"] + .as_array() + .unwrap() + .iter() + .map(|c| c["key"].as_str().unwrap().to_string()) + .collect(); + assert!(used.iter().all(|k| group.contains(&json!(k))), "the art uses the group's colours: {used:?}"); + assert!(matches!(ColorKey::parse(&used[0]).unwrap().color(), Color::Rgb { .. })); +} + +#[test] +fn a_group_keeps_the_colours_no_art_row_took() { + let mut app = app(); + let ctx = context(); + art(&mut app, &["#ff0000", "#0000ff"]); + let before = group_keys(&app, "Brights"); + app.run("ui.recolorDialog", json!({"group": "Brights"})).unwrap(); + dialog_frame(&mut app, &ctx); + assert_eq!(field(&app, "rows").as_array().unwrap().len(), 2, "two art colours"); + let mut rows = field(&app, "rows"); + rows[1]["to"] = json!("rgb 1 2 3"); + set(&mut app, "rows", rows); + dialogs::confirm(&mut app).unwrap(); + let after = group_keys(&app, "Brights"); + assert_eq!(after.len(), before.len()); + assert_eq!((&after[0], &after[1]), (&before[0], &json!("rgb 1 2 3"))); + assert_eq!(after[2..], before[2..]); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 75c9715e9..5cc8eb463 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -82,12 +82,15 @@ canvas while open; `ui.dialog.confirm` keeps the result as one undo step (`edit. `edit.colors.saturate`) and `ui.dialog.cancel` rolls it back. Global mode answers with an error until tints of global and spot colours exist; the dialog then stays open. -Recolor Artwork: `ui.recolorDialog {colors?, library?}` opens the `recolor` dialog on the selected art (`colors`: a -count for an n-colour job, or colours to assign as the new colours; `library`: Limit to Library, `""` for the first). -Fields: `rows` (`[{from: [keys], to: key, exclude?}]` as `recolor.reduce` returns them), `colors` (null for Auto), -`method`, `preserveWhite`, `preserveBlack`, `preserveGrays`, `limitTo`, `tab` (`assign` or `edit`), `rule` (harmony -id), `linked`, `preview`. Setting `colors`, a preserve flag or `limitTo` reduces the rows again (also on -`ui.dialog.confirm`). The dialog previews `recolor.apply` on the canvas; confirm keeps it as one undo step. +Recolor Artwork: `ui.recolorDialog {colors?, library?, group?}` opens the `recolor` dialog on the selected art +(`colors`: a count for an n-colour job, or colours to assign as the new colours; `library`: Limit to Library, `""` for +the first; `group`: a colour group, Edit or Apply Color Group). Fields: `rows` (`[{from: [keys], to: key, exclude?}]` +as `recolor.reduce` returns them), `colors` (null for Auto), `method`, `preserveWhite`, `preserveBlack`, +`preserveGrays`, `limitTo`, `groupName`, `tab` (`assign` or `edit`), `rule` (harmony id), `linked`, `preview`. Setting +`colors`, a preserve flag or `limitTo` reduces the rows again (also on `ui.dialog.confirm`). The dialog previews +`recolor.apply` on the canvas; confirm keeps it as one undo step (with a group, the group is rewritten in the same +step; with no art selected only the group changes). In the Swatches panel, double-clicking a colour group's folder, +or the options button while a group is selected, opens it. Library panel: `engine.execute {command: "window.swatchLibrary", params: {library}}` opens the read-only library panel on a swatch library (`ui.inspect` shows it as `library_panel: {kind, id}`; `library: null` closes it). diff --git a/docs/mcp.md b/docs/mcp.md index 22c5663ec..6f988644b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -319,20 +319,23 @@ patterns out. The Blend commands also grade gradient meshes, which keep their sh Recolor Artwork keys colours by identity, not hex: `recolor.colors` lists each colour's `key` (`"rgb 255 0 0"`, `"cmyk 0 100 100 0"`, `"gray 40"`, `"lab 55 60 -40"`; RGB in 0–255 levels, CMYK and Gray in percent), so CMYK, -Lab and RGB colours that look alike stay apart, and every colour parameter also accepts a key. `recolor.reduce {colors?, method?, -preserve?, limitTo?}` groups the selection's colours into rows of similar colours (k-means in Lab, weighted by use; +Lab and RGB colours that look alike stay apart, and every colour parameter also accepts a key. +`recolor.reduce {colors?, method?, preserve?, limitTo?}` groups the selection's colours into rows of similar colours (k-means in Lab, weighted by use; tints of a global swatch share its row; White and Black are preserved by default) and returns a map of rows -`[{from: [keys], to: key}]` to edit and pass to `recolor.apply {map, method?, limitTo?}`. +`[{from: [keys], to: key}]` to edit and pass to `recolor.apply {map, method?, limitTo?, group?, groupColors?, rename?}`. `method` says how a row's colours take its new colour: `exact`, `preserveTints` (tints of the row's darkest colour stay tints), `scaleTints` (every colour becomes a tint as light, relative to the darkest), `tintsShades` (lighter and darker than the row's average become tints and shades) or `hueShift` (the most saturated colour takes the new colour, the others turn by the same hue). `limitTo` snaps new colours to a swatch library's nearest colour; `recolor.randomize {map, order?, saturationBrightness?, seed?}` shuffles or varies new colours; a row with -`exclude: true` keeps its colours. +`exclude: true` keeps its colours. `swatch.editGroup {group, colors, rename?}` rewrites a colour group in one undo +step (art linked to its global swatches follows), and `recolor.apply` with `group` recolours the art and rewrites the +group together. ```json {"name":"run_command","arguments":{"command":"recolor.reduce","params":{"colors":2,"preserve":{"grays":true}}}} {"name":"run_command","arguments":{"command":"recolor.apply","params":{"map":[{"from":["cmyk 0 100 100 0","cmyk 0 40 40 0"],"to":"cmyk 100 50 0 0"}],"method":"scaleTints"}}} +{"name":"run_command","arguments":{"command":"swatch.editGroup","params":{"group":"Brights","colors":["#ff0000","cmyk 0 0 100 0"]}}} ``` ## Group, layer and type appearance