diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 5f12e897..4b2c6b7d 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 00000000..16ad2aa2 --- /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 dfb442f1..385f280c 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 e458789b..c4ed2fd6 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 25fda4ef..d28545e9 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 00000000..a0cb953b --- /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 6b837f65..59479a57 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 35c22b5a..63dbee76 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 d1c3c25b..ecffbffb 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 631b43ae..066c81fa 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 81f0f994..cb5099a4 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 abdda9c2..eb26a181 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 00000000..8af4adca --- /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 471ddafa..75c9715e 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 41427b81..22c5663e 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