diff --git a/Cargo.lock b/Cargo.lock index 1c3e883f..2eb175ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4103,6 +4103,7 @@ dependencies = [ name = "vectorcraft-doc" version = "0.1.1" dependencies = [ + "image", "kurbo 0.13.1", "proptest", "serde", diff --git a/crates/color/src/harmony.rs b/crates/color/src/harmony.rs index fe170d8f..5223673c 100644 --- a/crates/color/src/harmony.rs +++ b/crates/color/src/harmony.rs @@ -1,73 +1,298 @@ -//! Colour harmony rules (the Color Guide panel). +//! Colour harmony rules and the variation grid of the Color Guide panel (our own colour math). + +use serde::{Deserialize, Serialize}; use crate::{Color, keep_model}; +/// A harmony rule: a group of colours built from a base colour, base first. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Harmony { Complementary, + Complementary2, SplitComplementary, LeftComplement, RightComplement, Analogous, - Triad, - Tetrad, - Square, + Analogous2, + Monochromatic, Shades, + Triad, + Triad2, + Triad3, + Tetrad, + Tetrad2, + Tetrad3, + Compound, + Compound2, + HighContrast, + HighContrast2, + HighContrast3, + Pentagram, +} + +/// One colour of a rule: the base turned by `turn` degrees of hue, its saturation scaled by `sat` +/// and its brightness by `bright` (or flipped to the contrasting brightness). +#[derive(Clone, Copy)] +struct Tone { + turn: f32, + sat: f32, + bright: Bright, +} + +#[derive(Clone, Copy)] +enum Bright { + Times(f32), + /// Dark for a light base, light for a dark one ([`contrast`]). + Contrast, +} + +const fn sv(turn: f32, sat: f32, bright: f32) -> Tone { + Tone { turn, sat, bright: Bright::Times(bright) } +} +/// The base's hue turned by `turn`. +const fn t(turn: f32) -> Tone { + sv(turn, 1.0, 1.0) +} +/// A softer (half as saturated) partner. +const fn soft(turn: f32) -> Tone { + sv(turn, 0.5, 1.0) +} +/// A deeper (darker) partner. +const fn deep(turn: f32) -> Tone { + sv(turn, 1.0, 0.6) +} +/// A partner of contrasting brightness. +const fn hc(turn: f32) -> Tone { + Tone { turn, sat: 1.0, bright: Bright::Contrast } +} + +/// The brightness that contrasts with `v`. +fn contrast(v: f32) -> f32 { + if v >= 0.5 { v * 0.3 } else { 0.7 + 0.3 * v } } impl Harmony { - pub const ALL: [Harmony; 9] = [ + pub const ALL: [Harmony; 21] = [ Harmony::Complementary, + Harmony::Complementary2, Harmony::SplitComplementary, Harmony::LeftComplement, Harmony::RightComplement, Harmony::Analogous, - Harmony::Triad, - Harmony::Tetrad, - Harmony::Square, + Harmony::Analogous2, + Harmony::Monochromatic, Harmony::Shades, + Harmony::Triad, + Harmony::Triad2, + Harmony::Triad3, + Harmony::Tetrad, + Harmony::Tetrad2, + Harmony::Tetrad3, + Harmony::Compound, + Harmony::Compound2, + Harmony::HighContrast, + Harmony::HighContrast2, + Harmony::HighContrast3, + Harmony::Pentagram, ]; + pub fn label(self) -> &'static str { match self { Harmony::Complementary => "Complementary", + Harmony::Complementary2 => "Complementary 2", Harmony::SplitComplementary => "Split Complementary", Harmony::LeftComplement => "Left Complement", Harmony::RightComplement => "Right Complement", Harmony::Analogous => "Analogous", - Harmony::Triad => "Triad", - Harmony::Tetrad => "Tetrad", - Harmony::Square => "Square", + Harmony::Analogous2 => "Analogous 2", + Harmony::Monochromatic => "Monochromatic", Harmony::Shades => "Shades", + Harmony::Triad => "Triad", + Harmony::Triad2 => "Triad 2", + Harmony::Triad3 => "Triad 3", + Harmony::Tetrad => "Tetrad", + Harmony::Tetrad2 => "Tetrad 2", + Harmony::Tetrad3 => "Tetrad 3", + Harmony::Compound => "Compound", + Harmony::Compound2 => "Compound 2", + Harmony::HighContrast => "High Contrast", + Harmony::HighContrast2 => "High Contrast 2", + Harmony::HighContrast3 => "High Contrast 3", + Harmony::Pentagram => "Pentagram", } } - /// Colours of the harmony, base colour first, in the base colour's model. + + /// The rule's id in commands: its label in camelCase ("splitComplementary", "triad2"). + pub fn id(self) -> String { + let mut out = String::new(); + for (i, w) in self.label().split(' ').enumerate() { + let mut c = w.chars(); + if let Some(f) = c.next() { + if i == 0 { + out.extend(f.to_lowercase()); + } else { + out.extend(f.to_uppercase()); + } + out.push_str(c.as_str()); + } + } + out + } + + /// A rule 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_whitespace()).collect::().to_ascii_lowercase(); + let want = key(s); + Self::ALL.into_iter().find(|h| key(h.label()) == want) + } + + fn tones(self) -> &'static [Tone] { + match self { + Harmony::Complementary => const { &[t(0.0), t(180.0)] }, + Harmony::Complementary2 => const { &[t(0.0), soft(0.0), t(180.0), soft(180.0)] }, + Harmony::SplitComplementary => const { &[t(0.0), t(150.0), t(210.0)] }, + Harmony::LeftComplement => const { &[t(0.0), t(-30.0), t(150.0)] }, + Harmony::RightComplement => const { &[t(0.0), t(30.0), t(210.0)] }, + Harmony::Analogous => const { &[t(0.0), t(30.0), t(-30.0)] }, + Harmony::Analogous2 => const { &[t(0.0), t(30.0), t(60.0), t(-30.0), t(-60.0)] }, + Harmony::Monochromatic => const { &[t(0.0), sv(0.0, 0.5, 1.0), sv(0.0, 1.0, 0.6), sv(0.0, 0.25, 1.0), sv(0.0, 1.0, 0.35)] }, + Harmony::Shades => const { &[t(0.0), sv(0.0, 1.0, 0.8), sv(0.0, 1.0, 0.6), sv(0.0, 1.0, 0.4), sv(0.0, 1.0, 0.2)] }, + Harmony::Triad => const { &[t(0.0), t(120.0), t(240.0)] }, + Harmony::Triad2 => const { &[t(0.0), t(120.0), soft(120.0), t(240.0), soft(240.0)] }, + Harmony::Triad3 => const { &[t(0.0), t(120.0), deep(120.0), t(240.0), deep(240.0)] }, + Harmony::Tetrad => const { &[t(0.0), t(60.0), t(180.0), t(240.0)] }, + Harmony::Tetrad2 => const { &[t(0.0), t(90.0), t(180.0), t(270.0)] }, + Harmony::Tetrad3 => const { &[t(0.0), t(30.0), t(180.0), t(210.0)] }, + Harmony::Compound => const { &[t(0.0), t(40.0), t(160.0), t(200.0)] }, + Harmony::Compound2 => const { &[t(0.0), t(-40.0), t(160.0), t(200.0)] }, + Harmony::HighContrast => const { &[t(0.0), hc(180.0)] }, + Harmony::HighContrast2 => const { &[t(0.0), hc(0.0), t(180.0), hc(180.0)] }, + Harmony::HighContrast3 => const { &[t(0.0), hc(120.0), hc(240.0)] }, + Harmony::Pentagram => const { &[t(0.0), t(72.0), t(144.0), t(216.0), t(288.0)] }, + } + } + + /// The rule's colours in the base colour's model, the base colour itself first. pub fn apply(self, base: Color) -> Vec { let [h, s, v] = base.to_hsb(); - let hue = |d: f32| keep_model(base, Color::from_hsb(h + d, s, v)); + std::iter::once(base) + .chain(self.tones()[1..].iter().map(|tone| { + let b = match tone.bright { + Bright::Times(f) => v * f, + Bright::Contrast => contrast(v), + }; + keep_model(base, Color::from_hsb(h + tone.turn, (s * tone.sat).clamp(0.0, 1.0), b.clamp(0.0, 1.0))) + })) + .collect() + } +} + +/// What the variation grid varies: towards black and white, towards cool and warm, or towards +/// grey and full saturation. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Variation { + #[default] + TintsShades, + WarmCool, + VividMuted, +} + +impl Variation { + pub const ALL: [Variation; 3] = [Variation::TintsShades, Variation::WarmCool, Variation::VividMuted]; + + pub fn id(self) -> &'static str { match self { - Harmony::Complementary => vec![base, hue(180.0)], - Harmony::SplitComplementary => vec![base, hue(150.0), hue(210.0)], - Harmony::LeftComplement => vec![base, hue(150.0)], - Harmony::RightComplement => vec![base, hue(210.0)], - Harmony::Analogous => vec![hue(-60.0), hue(-30.0), base, hue(30.0), hue(60.0)], - Harmony::Triad => vec![base, hue(120.0), hue(240.0)], - Harmony::Tetrad => vec![base, hue(60.0), hue(180.0), hue(240.0)], - Harmony::Square => vec![base, hue(90.0), hue(180.0), hue(270.0)], - Harmony::Shades => (0..5).map(|i| keep_model(base, Color::from_hsb(h, s, (v * (1.0 - i as f32 * 0.18)).max(0.0)))).collect(), + Variation::TintsShades => "tintsShades", + Variation::WarmCool => "warmCool", + Variation::VividMuted => "vividMuted", + } + } + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|v| v.id().eq_ignore_ascii_case(s)) + } + /// The ends of the grid: (left, right). + pub fn sides(self) -> (&'static str, &'static str) { + match self { + Variation::TintsShades => ("Shades", "Tints"), + Variation::WarmCool => ("Cool", "Warm"), + Variation::VividMuted => ("Muted", "Vivid"), } } } -/// Tints and shades row for the Color Guide variation grid, in the base colour's model. -pub fn variations(base: Color, steps: usize) -> Vec { - let n = steps.max(1) as f32; - (0..steps) - .map(|i| { - let t = (i as f32 + 1.0) / (n + 1.0); - let c = if i < steps / 2 { base.lerp(&Color::BLACK, 1.0 - t * 2.0) } else { base.lerp(&Color::WHITE, (t - 0.5) * 2.0) }; - keep_model(base, c) - }) - .collect() +/// The Color Guide's options: variation kind, steps on each side and how far they reach. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct GuideOptions { + pub variation: Variation, + /// Variations on each side of the harmony colour (1..=20). + pub steps: u32, + /// How far the outermost variations go, 0..=100 (Less … More). + pub amount: f32, +} + +impl Default for GuideOptions { + fn default() -> Self { + Self { variation: Variation::TintsShades, steps: 4, amount: 50.0 } + } +} + +impl GuideOptions { + pub const MAX_STEPS: u32 = 20; + + /// The options with `steps` and `amount` clamped to their ranges. + pub fn clamped(self) -> Self { + Self { steps: self.steps.clamp(1, Self::MAX_STEPS), amount: self.amount.clamp(0.0, 100.0), ..self } + } + /// The share of the way to the target that the outermost steps reach (10% … 90%). + fn reach(&self) -> f32 { + 0.1 + 0.8 * self.amount.clamp(0.0, 100.0) / 100.0 + } +} + +/// Variation of `c` at `step` (−steps..=steps: negative towards shades/cool/muted, positive towards +/// tints/warm/vivid; 0 is `c` itself), in `c`'s model. +pub fn variation(c: Color, opts: &GuideOptions, step: i32) -> Color { + if step == 0 { + return c; + } + let f = (step.unsigned_abs() as f32 / opts.steps.max(1) as f32).min(1.0) * opts.reach(); + let v = match opts.variation { + Variation::TintsShades => c.lerp(if step < 0 { &Color::BLACK } else { &Color::WHITE }, f), + Variation::WarmCool => { + let target = if step < 0 { Color::rgb(0.2, 0.45, 1.0) } else { Color::rgb(1.0, 0.55, 0.1) }; + c.lerp(&target, f * 0.6) + } + Variation::VividMuted => { + let [h, s, b] = c.to_hsb(); + let s2 = if step < 0 { s * (1.0 - f) } else { s + (1.0 - s) * f }; + Color::from_hsb(h, s2.clamp(0.0, 1.0), b) + } + }; + keep_model(c, v) +} + +/// The Color Guide for a base colour: the harmony colours and, per colour, its row of variations +/// from `-steps` to `+steps` (the colour itself in the centre column). +#[derive(Clone, Debug, PartialEq)] +pub struct Guide { + pub colors: Vec, + pub grid: Vec>, +} + +impl Guide { + pub fn new(base: Color, rule: Harmony, opts: &GuideOptions) -> Self { + let opts = opts.clamped(); + let n = opts.steps as i32; + let colors = rule.apply(base); + let grid = colors.iter().map(|c| (-n..=n).map(|step| variation(*c, &opts, step)).collect()).collect(); + Self { colors, grid } + } + /// Index of the centre column (the harmony colours) in each grid row. + pub fn centre(&self) -> usize { + self.grid.first().map_or(0, |r| r.len() / 2) + } } #[cfg(test)] @@ -79,7 +304,20 @@ mod tests { let c = Harmony::Complementary.apply(Color::rgb(1.0, 0.0, 0.0)); assert_eq!(c[1].to_hex(), "#00ffff"); assert_eq!(Harmony::Triad.apply(Color::rgb(1.0, 0.0, 0.0))[1].to_hex(), "#00ff00"); - assert_eq!(variations(Color::rgb(1.0, 0.0, 0.0), 6).len(), 6); + assert_eq!(Harmony::Pentagram.apply(Color::rgb(1.0, 0.0, 0.0))[1].to_hsb()[0].round(), 72.0); + } + + #[test] + fn every_rule_puts_the_base_first() { + let counts = [2, 4, 3, 3, 3, 3, 5, 5, 5, 3, 5, 5, 4, 4, 4, 4, 4, 2, 4, 3, 5]; + assert_eq!(Harmony::ALL.len(), counts.len()); + for base in [Color::rgb8(230, 120, 40), Color::cmyk(0.1, 0.8, 0.3, 0.05), Color::gray(0.4)] { + for (h, n) in Harmony::ALL.into_iter().zip(counts) { + let c = h.apply(base); + assert_eq!(c.len(), n, "{}", h.label()); + assert_eq!(c[0], base, "{}: the base comes first, in its own model", h.label()); + } + } } #[test] @@ -90,6 +328,58 @@ mod tests { } let Color::Cmyk { c, m, y, .. } = Harmony::Complementary.apply(base)[1] else { unreachable!() }; assert!(c > m && c > y, "the complement of red is a cyan: {c} {m} {y}"); - assert!(variations(Color::gray(0.4), 4).iter().all(|c| matches!(c, Color::Gray { .. }))); + let g = Guide::new(Color::gray(0.4), Harmony::Triad, &GuideOptions::default()); + assert!(g.grid.iter().flatten().all(|c| matches!(c, Color::Gray { .. }))); + } + + #[test] + fn rules_parse_by_id_and_label() { + for h in Harmony::ALL { + assert_eq!(Harmony::parse(&h.id()), Some(h)); + assert_eq!(Harmony::parse(h.label()), Some(h)); + } + assert_eq!(Harmony::SplitComplementary.id(), "splitComplementary"); + assert_eq!(Harmony::HighContrast3.id(), "highContrast3"); + assert_eq!(Harmony::parse("square"), None); + } + + #[test] + fn high_contrast_flips_brightness() { + let light = Harmony::HighContrast.apply(Color::rgb(0.9, 0.8, 0.2)); + assert!(light[1].to_hsb()[2] < 0.5); + let dark = Harmony::HighContrast.apply(Color::rgb(0.2, 0.1, 0.05)); + assert!(dark[1].to_hsb()[2] > 0.5); + } + + #[test] + fn variations_step_out_from_the_colour() { + let c = Color::rgb(0.5, 0.5, 0.5); + let o = GuideOptions::default(); + assert_eq!(variation(c, &o, 0), c, "step 0 is the colour"); + assert!(variation(c, &o, 4).to_rgb()[0] > variation(c, &o, 2).to_rgb()[0]); + assert!(variation(c, &o, -4).to_rgb()[0] < 0.5); + let red = Color::rgb(1.0, 0.2, 0.2); + let vm = GuideOptions { variation: Variation::VividMuted, ..o }; + assert!(variation(red, &vm, -2).to_hsb()[1] < red.to_hsb()[1]); + let warm = variation(c, &GuideOptions { variation: Variation::WarmCool, ..o }, 2).to_rgb(); + assert!(warm[0] > warm[2]); + // More variation reaches further. + let more = GuideOptions { amount: 100.0, ..o }; + assert!(variation(c, &more, -4).to_rgb()[0] < variation(c, &o, -4).to_rgb()[0]); + } + + #[test] + fn guide_rows_hold_the_colour_in_the_centre() { + let base = Color::rgb8(30, 120, 200); + let g = Guide::new(base, Harmony::Triad, &GuideOptions { steps: 3, ..Default::default() }); + assert_eq!(g.colors.len(), 3); + assert!(g.grid.iter().all(|r| r.len() == 7)); + assert_eq!(g.centre(), 3); + for (row, c) in g.grid.iter().zip(&g.colors) { + assert_eq!(row[3], *c); + } + // Steps are clamped to 1..=20. + let g = Guide::new(base, Harmony::Triad, &GuideOptions { steps: 99, ..Default::default() }); + assert_eq!(g.grid[0].len(), 41); } } diff --git a/crates/doc/Cargo.toml b/crates/doc/Cargo.toml index fb90ad71..e6eecf32 100644 --- a/crates/doc/Cargo.toml +++ b/crates/doc/Cargo.toml @@ -12,6 +12,7 @@ workspace = true vectorcraft-geom = { workspace = true } vectorcraft-color = { workspace = true } kurbo = { workspace = true } +image = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index a24dd3db..349a38c9 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -12,6 +12,7 @@ pub mod live; pub mod node; pub mod overprint; pub mod pattern; +mod pixels; pub mod selection; pub mod swatches; pub mod text; diff --git a/crates/doc/src/pixels.rs b/crates/doc/src/pixels.rs new file mode 100644 index 00000000..242dc243 --- /dev/null +++ b/crates/doc/src/pixels.rs @@ -0,0 +1,77 @@ +//! Pixel access for embedded images: a stable content key and recolouring the pixels. + +use std::collections::HashMap; +use std::io::Cursor; +use std::sync::Arc; + +use crate::ImageBlob; + +impl ImageBlob { + /// A PNG blob of `png` bytes. + pub fn png(png: Vec) -> Self { + Self { mime: "image/png".into(), bytes: Arc::new(png) } + } + + /// A key derived from the bytes (FNV-1a), the same for identical images. + pub fn content_key(&self) -> String { + let h = self.bytes.iter().fold(0xcbf29ce484222325u64, |h, x| (h ^ *x as u64).wrapping_mul(0x100000001b3)); + format!("img{h:016x}") + } + + /// The image with `f` applied to each pixel's (straight) RGB, alpha kept and fully transparent + /// pixels left alone, re-encoded as PNG. `f` runs once per distinct colour. `None` when the + /// image can't be decoded or no pixel changed. + pub fn map_rgb(&self, mut f: impl FnMut([u8; 3]) -> [u8; 3]) -> Option { + let mut img = image::load_from_memory(&self.bytes).ok()?.to_rgba8(); + let mut memo: HashMap<[u8; 3], [u8; 3]> = HashMap::new(); + let mut changed = false; + for px in img.pixels_mut().filter(|p| p.0[3] > 0) { + let rgb = [px.0[0], px.0[1], px.0[2]]; + let out = *memo.entry(rgb).or_insert_with(|| f(rgb)); + if out != rgb { + px.0[..3].copy_from_slice(&out); + changed = true; + } + } + if !changed { + return None; + } + let mut png = Vec::new(); + img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?; + Some(Self::png(png)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn blob(px: &[[u8; 4]], w: u32, h: u32) -> ImageBlob { + let img = image::RgbaImage::from_raw(w, h, px.concat()).unwrap(); + let mut png = Vec::new(); + img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + ImageBlob::png(png) + } + + fn pixels(b: &ImageBlob) -> Vec<[u8; 4]> { + image::load_from_memory(&b.bytes).unwrap().to_rgba8().pixels().map(|p| p.0).collect() + } + + #[test] + fn maps_each_colour_once_and_keeps_alpha() { + let b = blob(&[[255, 0, 0, 255], [255, 0, 0, 128], [0, 0, 255, 255], [10, 20, 30, 0]], 2, 2); + let mut calls = 0; + let out = b + .map_rgb(|[r, g, bl]| { + calls += 1; + [255 - r, 255 - g, 255 - bl] + }) + .unwrap(); + assert_eq!(calls, 2, "red and blue, once each; the transparent pixel is skipped"); + assert_eq!(pixels(&out), [[0, 255, 255, 255], [0, 255, 255, 128], [255, 255, 0, 255], [10, 20, 30, 0]]); + assert_ne!(out.content_key(), b.content_key()); + assert_eq!(b.content_key(), blob(&[[255, 0, 0, 255], [255, 0, 0, 128], [0, 0, 255, 255], [10, 20, 30, 0]], 2, 2).content_key()); + assert!(b.map_rgb(|c| c).is_none(), "nothing changed"); + assert!(ImageBlob::png(vec![1, 2, 3]).map_rgb(|c| c).is_none(), "undecodable"); + } +} diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index 630bef24..bbe9f403 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -1,14 +1,19 @@ //! Edit → Edit Colors: invert, convert to CMYK/Grayscale/RGB, saturate, adjust colour balance and the -//! three Blend commands. They recolour fills and strokes (solid colours, gradient stops and text -//! runs) of the selected objects and everything inside them. Colours linked to a global swatch are +//! three Blend commands. They recolour the selected objects and everything inside them through the +//! shared colour visitor [`Recolor`]: fills and strokes (solid colours, gradient stops and text +//! runs), gradient meshes, embedded images and pattern fills. Colours linked to a global swatch are //! unlinked when they change. +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::cms::Model; +use vectorcraft_color::harmony::{Guide, GuideOptions, Harmony, Variation}; use vectorcraft_color::{Color, Paint, keep_model}; -use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind}; +use vectorcraft_doc::live::lerp_color; +use vectorcraft_doc::pattern::PatternDef; +use vectorcraft_doc::{AppearanceItem, Document, ImageBlob, Node, NodeId, NodeKind}; use super::edit::selected_roots; use super::*; @@ -20,7 +25,7 @@ pub fn specs() -> Vec { "Invert Colors", ["Edit", "Edit Colors"], None, - "{fill?: true, stroke?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) → {changed}", + "{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step → {changed}", has_selection, |s, p| recolor(s, p, "Invert Colors", &invert) ), @@ -29,7 +34,7 @@ pub fn specs() -> Vec { "Convert to CMYK", ["Edit", "Edit Colors"], None, - "{fill?, stroke?, ids?} → {changed}", + "{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed}", has_selection, |s, p| recolor(s, p, "Convert to CMYK", &to_cmyk) ), @@ -38,22 +43,30 @@ pub fn specs() -> Vec { "Convert to Grayscale", ["Edit", "Edit Colors"], None, - "{fill?, stroke?, ids?} → {changed}", + "{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed}", has_selection, |s, p| recolor(s, p, "Convert to Grayscale", &to_gray) ), - cmd!("edit.colors.toRGB", "Convert to RGB", ["Edit", "Edit Colors"], None, "{fill?, stroke?, ids?} → {changed}", has_selection, |s, p| { - recolor(s, p, "Convert to RGB", &|c| { - let [r, g, b] = c.to_rgb(); - Color::rgb(r, g, b) - }) - }), + cmd!( + "edit.colors.toRGB", + "Convert to RGB", + ["Edit", "Edit Colors"], + None, + "{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed}", + has_selection, + |s, p| { + recolor(s, p, "Convert to RGB", &|c| { + let [r, g, b] = c.to_rgb(); + Color::rgb(r, g, b) + }) + } + ), cmd!( "edit.colors.saturate", "Saturate…", ["Edit", "Edit Colors"], None, - "{intensity: -100..100 (%), fill?, stroke?, ids?} scale saturation by (1 + intensity/100) → {changed}", + "{intensity: -100..100 (%), fill?, stroke?, includeImages?, includePatterns?, ids?} scale saturation by (1 + intensity/100), reaching what edit.colors.invert does → {changed}", has_selection, saturate ), @@ -62,7 +75,7 @@ pub fn specs() -> Vec { "Adjust Color Balance…", ["Edit", "Edit Colors"], None, - "{r?, g?, b? | c?, m?, y?, k? | gray?: -100..100 (% added per channel), convert?: false (keep the result in the adjusted model), fill?, stroke?, ids?} → {changed}", + "{mode?: \"rgb\"|\"cmyk\"|\"gray\"|\"global\" (default: from the channels given), r?, g?, b? | c?, m?, y?, k? | gray?: -100..100 (% added per channel, default 0), convert?: false (true: the results stay in the adjusted model; false: each colour keeps its own), fill?, stroke?, includeImages?, includePatterns?, ids?} reaching what edit.colors.invert does → {changed}. Global mode (tints of global and spot colours) isn't available yet", has_selection, adjust_balance ), @@ -71,7 +84,7 @@ pub fn specs() -> Vec { "Blend Front to Back", ["Edit", "Edit Colors"], None, - "{} ≥3 filled objects: intermediate fills graded between the frontmost and backmost fill → {changed}", + "{} ≥3 filled objects (solid fills or gradient meshes, which keep their shading): intermediate fills graded between the frontmost and backmost fill → {changed}", has_selection, |s, _| blend(s, BlendOrder::Stack) ), @@ -80,7 +93,7 @@ pub fn specs() -> Vec { "Blend Horizontally", ["Edit", "Edit Colors"], None, - "{} ≥3 filled objects: fills graded between the leftmost and rightmost → {changed}", + "{} ≥3 filled objects or meshes: fills graded between the leftmost and rightmost → {changed}", has_selection, |s, _| blend(s, BlendOrder::Horizontal) ), @@ -89,10 +102,19 @@ pub fn specs() -> Vec { "Blend Vertically", ["Edit", "Edit Colors"], None, - "{} ≥3 filled objects: fills graded between the topmost and bottommost → {changed}", + "{} ≥3 filled objects or meshes: fills graded between the topmost and bottommost → {changed}", has_selection, |s, _| blend(s, BlendOrder::Vertical) ), + cmd!( + query "color.harmony", + "Color Guide", + [], + None, + "{color, rule: complementary|complementary2|splitComplementary|leftComplement|rightComplement|analogous|analogous2|monochromatic|shades|triad|triad2|triad3|tetrad|tetrad2|tetrad3|compound|compound2|highContrast|highContrast2|highContrast3|pentagram (or its label), steps?: 1..20 (4), variation?: \"tintsShades\"|\"warmCool\"|\"vividMuted\", amount?: 0..100 (50; how far the outermost steps go)} the Color Guide for a base colour → {rule, colors: [\"#rrggbb\", the base first], grid: [per colour, 2·steps+1 variations from shades/cool/muted to tints/warm/vivid with the colour itself in the centre]}", + always, + harmony + ), ] } @@ -139,49 +161,190 @@ pub(crate) fn map_paint(p: &mut Paint, f: &dyn Fn(Color) -> Color) -> bool { } } -/// Recolour a node and its descendants. Returns the number of paints changed. -pub(crate) fn map_node_colors(n: &mut Node, f: &dyn Fn(Color) -> Color, fill: bool, stroke: bool) -> usize { - let mut count = 0; - for it in &mut n.appearance.items { - match it { - AppearanceItem::Fill(l) if fill => count += map_paint(&mut l.paint, f) as usize, - AppearanceItem::Stroke(l) if stroke => count += map_paint(&mut l.paint, f) as usize, - _ => {} - } - } - match &mut n.kind { - NodeKind::Text(t) => { - for r in &mut t.runs { - if fill { - count += map_paint(&mut r.style.fill, f) as usize; - } - if stroke { - count += map_paint(&mut r.style.stroke, f) as usize; - } - } - } - _ => { - if let Some(ch) = n.children_mut() { - for c in ch.iter_mut() { - count += map_node_colors(Arc::make_mut(c), f, fill, stroke); - } - } - } - } - count +/// What a recolouring pass covers: fills (with the points of gradient meshes), strokes, the pixels +/// of embedded images and the art of patterns (params `fill`, `stroke`, `includeImages`, +/// `includePatterns`; all on by default). +#[derive(Clone, Copy, Debug)] +pub(crate) struct Scope { + pub fill: bool, + pub stroke: bool, + pub images: bool, + pub patterns: bool, } -fn recolor_ids(s: &mut Session, label: &str, ids: Vec, fill: bool, stroke: bool, f: &dyn Fn(Color) -> Color) -> Result { - let n = s.edit(label, |d, _| { - let mut n = 0; - for id in &ids { - if let Some(node) = d.node_mut(*id) { - n += map_node_colors(node, f, fill, stroke); +impl Scope { + pub(crate) fn of(p: &Value) -> Self { + Self { + fill: bool_or(p, "fill", true), + stroke: bool_or(p, "stroke", true), + images: bool_or(p, "includeImages", true), + patterns: bool_or(p, "includePatterns", true), + } + } +} + +/// One recolouring pass, the colour visitor Edit Colors and Recolor Artwork share: `f` maps the +/// solid colours and gradient stops of fills, strokes and text runs, gradient-mesh points, the +/// pixels of embedded images and the art of patterns used as fills or strokes. A recoloured image +/// becomes a new image and a recoloured pattern a new pattern swatch ("Dots 2"); the originals stay +/// as they are for other objects. Colours that change lose their swatch link. Only nodes that +/// change are kept as copies. +pub(crate) struct Recolor<'a> { + f: &'a dyn Fn(Color) -> Color, + scope: Scope, + /// Image key → the key of its recoloured copy (`None`: unchanged). + images: HashMap>, + /// Pattern name → the name of its recoloured copy (`None`: unchanged, or being recoloured). + patterns: HashMap>, + new_images: Vec<(String, ImageBlob)>, + new_patterns: Vec, + changed: usize, +} + +impl<'a> Recolor<'a> { + pub(crate) fn new(scope: Scope, f: &'a dyn Fn(Color) -> Color) -> Self { + Self { f, scope, images: HashMap::new(), patterns: HashMap::new(), new_images: vec![], new_patterns: vec![], changed: 0 } + } + + /// Recolour the objects `ids` and everything inside them (each once), add the new images and + /// pattern swatches, and return the number of paints, meshes and images changed. + pub(crate) fn run(mut self, d: &mut Document, ids: &[NodeId]) -> usize { + let targets: HashSet = ids.iter().copied().collect(); + let layers: Vec<(usize, Node)> = d.layers.iter().enumerate().filter_map(|(i, l)| Some((i, self.find(d, l, &targets)?))).collect(); + for (i, l) in layers { + d.layers[i] = Arc::new(l); + } + for (k, b) in self.new_images { + d.images.entry(k).or_insert(b); + } + for mut def in self.new_patterns { + def.art = def.art.iter().map(|n| Arc::new(d.reid(n))).collect(); + super::patterncmds::add_pattern(d, def); + } + self.changed + } + + /// `n` with the targets inside it recoloured, or `None` when nothing changed. + fn find(&mut self, d: &Document, n: &Arc, targets: &HashSet) -> Option { + if targets.contains(&n.id) { + return self.subtree(d, n); + } + let mut out: Option = None; + for (i, c) in n.children()?.iter().enumerate() { + if let Some(new) = self.find(d, c, targets) { + out.get_or_insert_with(|| (**n).clone()).children_mut()?[i] = Arc::new(new); } } - Ok(n) - })?; - Ok(json!({ "changed": n })) + out + } + + /// `n` recoloured, or `None` when nothing changed. + fn subtree(&mut self, d: &Document, n: &Node) -> Option { + let before = self.changed; + let mut m = n.clone(); + self.node(d, &mut m); + (self.changed > before).then_some(m) + } + + fn node(&mut self, d: &Document, n: &mut Node) { + let Scope { fill, stroke, images, .. } = self.scope; + for it in &mut n.appearance.items { + match it { + AppearanceItem::Fill(l) if fill => self.paint(d, &mut l.paint), + AppearanceItem::Stroke(l) if stroke => self.paint(d, &mut l.paint), + _ => {} + } + } + match &mut n.kind { + NodeKind::Text(t) => { + for r in &mut t.runs { + if fill { + self.paint(d, &mut r.style.fill); + } + if stroke { + self.paint(d, &mut r.style.stroke); + } + } + } + NodeKind::Mesh(m) if fill => { + let mut ch = false; + for p in &mut m.points { + let c = (self.f)(p.color); + ch |= c != p.color; + p.color = c; + } + self.changed += ch as usize; + } + NodeKind::Image(im) if images && im.link.is_none() => { + if let Some(k) = self.image(d, &im.key) { + im.key = k; + self.changed += 1; + } + } + _ => { + for c in n.children_mut().into_iter().flatten() { + if let Some(new) = self.subtree(d, c) { + *c = Arc::new(new); + } + } + } + } + } + + fn paint(&mut self, d: &Document, p: &mut Paint) { + let changed = match p { + Paint::Pattern { pattern, .. } if self.scope.patterns => self.pattern(d, pattern).map(|new| *pattern = new).is_some(), + _ => map_paint(p, self.f), + }; + self.changed += changed as usize; + } + + /// The key of image `key` recoloured (one copy per pass), `None` when no pixel changes. + fn image(&mut self, d: &Document, key: &str) -> Option { + if let Some(done) = self.images.get(key) { + return done.clone(); + } + let f = self.f; + let new = d.images.get(key)?.map_rgb(|[r, g, b]| { + let [r, g, b, _] = f(Color::rgb8(r, g, b)).to_rgba8(1.0); + [r, g, b] + }); + let new = new.map(|blob| { + let k = blob.content_key(); + self.new_images.push((k.clone(), blob)); + k + }); + self.images.insert(key.to_string(), new.clone()); + new + } + + /// The name of pattern `name` recoloured (one copy per pass), `None` when its colours stay. + fn pattern(&mut self, d: &Document, name: &str) -> Option { + if let Some(done) = self.patterns.get(name) { + return done.clone(); + } + // A pattern inside its own art is left as it is. + self.patterns.insert(name.to_string(), None); + let mut def = d.pattern(name)?.clone(); + // The whole tile is recoloured, whichever of the object's paints uses the pattern. + let (scope, before) = (self.scope, self.changed); + self.scope = Scope { fill: true, stroke: true, ..scope }; + for n in &mut def.art { + if let Some(new) = self.subtree(d, n) { + *n = Arc::new(new); + } + } + let changed = self.changed > before; + (self.scope, self.changed) = (scope, before); + if !changed { + return None; + } + def.name = unique_name(name, |n| d.swatch_name_taken(n) || d.pattern(n).is_some() || self.new_patterns.iter().any(|p| p.name == n)); + let new = def.name.clone(); + self.new_patterns.push(def); + self.patterns.insert(name.to_string(), Some(new.clone())); + Some(new) + } } pub(crate) fn recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> Color) -> Result { @@ -189,7 +352,9 @@ pub(crate) fn recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) Some(v) => v, None => selected_roots(s)?, }; - recolor_ids(s, label, ids, bool_or(p, "fill", true), bool_or(p, "stroke", true), f) + let scope = Scope::of(p); + let n = s.edit(label, |d, _| Ok(Recolor::new(scope, f).run(d, &ids)))?; + Ok(json!({ "changed": n })) } fn saturate(s: &mut Session, p: &Value) -> Result { @@ -204,29 +369,47 @@ fn saturate(s: &mut Session, p: &Value) -> Result { } fn adjust_balance(s: &mut Session, p: &Value) -> Result { + const C: &str = "edit.colors.adjustBalance"; + let given = |keys: &[&str]| keys.iter().any(|k| p.get(*k).is_some()); + let mode = match str_param(p, "mode") { + Some(m) => m.to_ascii_lowercase(), + None if given(&["c", "m", "y", "k"]) => "cmyk".into(), + None if given(&["gray"]) => "gray".into(), + None if given(&["r", "g", "b"]) => "rgb".into(), + None => return Err(bad(C, "give a mode or r/g/b, c/m/y/k or gray adjustments")), + }; let g = |k: &str| (f64_or(p, k, 0.0).clamp(-100.0, 100.0) / 100.0) as f32; - let convert = bool_or(p, "convert", false); - let cmyk = ["c", "m", "y", "k"].iter().any(|k| p.get(*k).is_some()); - let gray = p.get("gray").is_some(); - let rgb = ["r", "g", "b"].iter().any(|k| p.get(*k).is_some()); - if !(cmyk || gray || rgb) { - return Err(bad("edit.colors.adjustBalance", "give r/g/b, c/m/y/k or gray adjustments")); - } - let (dr, dg, db) = (g("r"), g("g"), g("b")); - let (dc, dm, dy, dk) = (g("c"), g("m"), g("y"), g("k")); - let dgray = g("gray"); let cl = |v: f32| v.clamp(0.0, 1.0); + let adjust: Box Color> = match mode.as_str() { + "rgb" => { + let (dr, dg, db) = (g("r"), g("g"), g("b")); + Box::new(move |c| { + let [r, gg, b] = c.to_rgb(); + Color::rgb(cl(r + dr), cl(gg + dg), cl(b + db)) + }) + } + "cmyk" => { + let (dc, dm, dy, dk) = (g("c"), g("m"), g("y"), g("k")); + Box::new(move |c| { + let [cc, m, y, k] = c.to_cmyk(); + Color::cmyk(cl(cc + dc), cl(m + dm), cl(y + dy), cl(k + dk)) + }) + } + "gray" => { + let dgray = g("gray"); + Box::new(move |c| { + let Color::Gray { k } = to_gray(c) else { unreachable!() }; + Color::gray(cl(k + dgray)) + }) + } + "global" => { + return Err(bad(C, "mode `global` shifts the tints of global and spot colours, which aren't supported yet: use rgb, cmyk or gray")); + } + other => return Err(bad(C, format!("unknown mode `{other}` (rgb|cmyk|gray|global)"))), + }; + let convert = bool_or(p, "convert", false); recolor(s, p, "Adjust Colors", &move |c| { - let out = if cmyk { - let [cc, m, y, k] = c.to_cmyk(); - Color::cmyk(cl(cc + dc), cl(m + dm), cl(y + dy), cl(k + dk)) - } else if gray { - let Color::Gray { k } = to_gray(c) else { unreachable!() }; - Color::gray(cl(k + dgray)) - } else { - let [r, gg, b] = c.to_rgb(); - Color::rgb(cl(r + dr), cl(gg + dg), cl(b + db)) - }; + let out = adjust(c); if convert { out } else { keep_model(c, out) } }) } @@ -237,7 +420,7 @@ enum BlendOrder { Vertical, } -/// Leaf objects with a solid fill among the selection (paint order). +/// Leaf objects with a solid fill, and gradient meshes, among the selection (paint order). fn filled_leaves(d: &Document, roots: &[NodeId]) -> Vec { fn visit(n: &Node, out: &mut Vec) { match &n.kind { @@ -246,13 +429,8 @@ fn filled_leaves(d: &Document, roots: &[NodeId]) -> Vec { visit(c, out); } } - NodeKind::Text(t) => { - if t.first_style().fill.color().is_some() { - out.push(n.id); - } - } _ => { - if n.appearance.fill_paint().color().is_some() { + if fill_color(n).is_some() { out.push(n.id); } } @@ -267,23 +445,19 @@ fn filled_leaves(d: &Document, roots: &[NodeId]) -> Vec { out } +/// The colour an object blends by: its solid fill, or the average of a mesh's points. fn fill_color(n: &Node) -> Option { match &n.kind { NodeKind::Text(t) => t.first_style().fill.color(), + NodeKind::Mesh(m) => { + let k = m.points.len() as f32; + let sum = m.points.iter().map(|p| p.color.to_rgb()).reduce(|a, c| [a[0] + c[0], a[1] + c[1], a[2] + c[2]])?; + Some(Color::rgb(sum[0] / k, sum[1] / k, sum[2] / k)) + } _ => n.appearance.fill_paint().color(), } } -/// Interpolate within the endpoints' model when they share one. -pub(crate) fn lerp_model(a: Color, b: Color, t: f32) -> Color { - let l = |x: f32, y: f32| x + (y - x) * t; - match (a, b) { - (Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => Color::cmyk(l(c, c2), l(m, m2), l(y, y2), l(k, k2)), - (Color::Gray { k }, Color::Gray { k: k2 }) => Color::gray(l(k, k2)), - _ => a.lerp(&b, t), - } -} - fn blend(s: &mut Session, order: BlendOrder) -> Result { let roots = selected_roots(s)?; let d = &s.doc()?.doc; @@ -310,15 +484,48 @@ fn blend(s: &mut Session, order: BlendOrder) -> Result { BlendOrder::Horizontal => "Blend Horizontally", BlendOrder::Vertical => "Blend Vertically", }; + let fills = Scope { fill: true, stroke: false, images: false, patterns: false }; let changed = s.edit(label, |d, _| { let mut changed = 0; for (i, id) in ids.iter().enumerate().take(n - 1).skip(1) { - let c = lerp_model(first, last, i as f32 / (n - 1) as f32).in_model(model); - if let Some(node) = d.node_mut(*id) { - changed += map_node_colors(node, &|_| c, true, false); - } + let c = lerp_color(&first, &last, i as f32 / (n - 1) as f32).in_model(model); + // A mesh moves its average to the blended colour and keeps its shading. + let f: Box Color> = match d.node(*id).map(|n| (&n.kind, fill_color(n))) { + Some((NodeKind::Mesh(_), Some(mean))) => { + let ([cr, cg, cb], [mr, mg, mb]) = (c.to_rgb(), mean.to_rgb()); + Box::new(move |p| { + let [r, g, b] = p.to_rgb(); + keep_model(p, Color::rgb((r + cr - mr).clamp(0.0, 1.0), (g + cg - mg).clamp(0.0, 1.0), (b + cb - mb).clamp(0.0, 1.0))) + }) + } + _ => Box::new(move |_| c), + }; + changed += Recolor::new(fills, &*f).run(d, &[*id]); } Ok(changed) })?; Ok(json!({ "changed": changed })) } + +fn harmony(_: &mut Session, p: &Value) -> Result { + const C: &str = "color.harmony"; + let base = p.get("color").and_then(color_value).ok_or_else(|| bad(C, "missing or invalid `color`"))?; + let rule = str_param(p, "rule").ok_or_else(|| bad(C, "missing `rule`"))?; + let rule = Harmony::parse(rule).ok_or_else(|| { + let ids: Vec = Harmony::ALL.iter().map(|h| h.id()).collect(); + bad(C, format!("unknown rule `{rule}` ({})", ids.join("|"))) + })?; + let mut opts = GuideOptions::default(); + if let Some(n) = p.get("steps").and_then(Value::as_u64) { + opts.steps = n.min(u32::MAX as u64) as u32; + } + if let Some(v) = str_param(p, "variation") { + opts.variation = Variation::parse(v).ok_or_else(|| bad(C, format!("unknown variation `{v}` (tintsShades|warmCool|vividMuted)")))?; + } + if let Some(a) = p.get("amount").and_then(Value::as_f64) { + opts.amount = a as f32; + } + let g = Guide::new(base, rule, &opts); + let hex = |cs: &[Color]| cs.iter().map(Color::to_hex).collect::>(); + Ok(json!({ "rule": rule.id(), "colors": hex(&g.colors), "grid": g.grid.iter().map(|r| hex(r)).collect::>() })) +} diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index b47a74fd..19ed7014 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_doc::{CharStyle, ColorMode, Guide, Node, NodeId, NodeKind, TextKind}; -use super::colorcmds::{map_node_colors, to_cmyk}; +use super::colorcmds::{Recolor, Scope, to_cmyk}; use super::edit::selected_roots; use super::*; @@ -466,9 +466,8 @@ fn color_mode(s: &mut Session, p: &Value) -> Result { s.edit("Document Color Mode", |d, _| { d.color_mode = mode; if convert { - for l in &mut d.layers { - map_node_colors(Arc::make_mut(l), &f, true, true); - } + let layers: Vec = d.layers.iter().map(|l| l.id).collect(); + Recolor::new(Scope { fill: true, stroke: true, images: false, patterns: false }, &f).run(d, &layers); for sw in d.swatches_iter_mut() { if let vectorcraft_color::Paint::Solid { color, .. } = &mut sw.paint { *color = f(*color); diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 65a06f26..ad05470e 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -143,7 +143,8 @@ pub fn raster_image(bytes: &[u8]) -> Result { if width == 0 || height == 0 { return Err(err("the image is empty")); } - Ok(RasterImage { key: format!("img{:016x}", fnv1a(&bytes)), blob: ImageBlob { mime: mime.into(), bytes: Arc::new(bytes) }, width, height }) + let blob = ImageBlob { mime: mime.into(), bytes: Arc::new(bytes) }; + Ok(RasterImage { key: blob.content_key(), blob, width, height }) } /// An image as a document of its pixel size (1 px = 1 pt), the image named after the file. @@ -164,8 +165,3 @@ fn gunzip(bytes: &[u8]) -> Result> { flate2::read::GzDecoder::new(bytes).read_to_end(&mut out).map_err(|e| err(format!("not a valid SVGZ file: {e}")))?; Ok(out) } - -/// FNV-1a: a stable content key for embedded images. -fn fnv1a(b: &[u8]) -> u64 { - b.iter().fold(0xcbf29ce484222325, |h, x| (h ^ *x as u64).wrapping_mul(0x100000001b3)) -} diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index fa45d6e7..2ada9291 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -435,7 +435,8 @@ fn proxy_recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> C s.remember_paint(&shown); return Ok(json!({ "changed": changed as usize })); } - let q = json!({"fill": !stroke, "stroke": stroke, "ids": ids.iter().map(|i| i.0).collect::>()}); + // The proxy's colours only: images and pattern tiles stay as they are. + let q = json!({"fill": !stroke, "stroke": stroke, "includeImages": false, "includePatterns": false, "ids": ids.iter().map(|i| i.0).collect::>()}); let r = super::colorcmds::recolor(s, &q, label, f)?; let first = leaf_targets(s, &ids)?.first().and_then(|id| s.doc().ok()?.doc.node(*id).map(|n| proxy_paint(n, stroke, None))); if let Some(shown) = first { diff --git a/crates/engine/src/cmd/patterncmds.rs b/crates/engine/src/cmd/patterncmds.rs index 35b0e9b7..7da22bf9 100644 --- a/crates/engine/src/cmd/patterncmds.rs +++ b/crates/engine/src/cmd/patterncmds.rs @@ -219,6 +219,12 @@ fn next_pattern_name(d: &Document) -> String { (1..).map(|i| format!("New Pattern {i}")).find(|n| d.pattern(n).is_none() && d.swatch(n).is_none()).unwrap() } +/// Add pattern `def` and its swatch. +pub(crate) fn add_pattern(d: &mut Document, def: PatternDef) { + d.swatches.push(vectorcraft_color::Swatch { name: def.name.clone(), paint: pattern_paint(&def.name), global: false, spot: false }); + d.patterns.push(def); +} + /// Fresh-id deep copies of `nodes`. fn copies(d: &mut Document, nodes: &[Arc]) -> Vec { nodes.iter().map(|n| d.reid(n)).collect() @@ -299,8 +305,7 @@ fn pattern_make(s: &mut Session, p: &Value) -> Result { def.tile_type = tt; let c = def.tile.center(); def.tile = Rect::from_center_size(c, (w.unwrap_or(def.tile.width()).max(1e-3), h.unwrap_or(def.tile.height()).max(1e-3))); - d.patterns.push(def); - d.swatches.push(vectorcraft_color::Swatch { name: name.clone(), paint: pattern_paint(&name), global: false, spot: false }); + add_pattern(d, def); if edit { enter_edit(d, sel, &name, None)?; } @@ -393,8 +398,7 @@ fn pattern_save_copy(s: &mut Session, p: &Value) -> Result { if def.size_tile_to_art { def.fit_tile_to_art(); } - d.patterns.push(def); - d.swatches.push(vectorcraft_color::Swatch { name: name.clone(), paint: pattern_paint(&name), global: false, spot: false }); + add_pattern(d, def); Ok(name) })?; Ok(json!({ "name": name })) diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index d76f9b1e..e458789b 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -4,62 +4,64 @@ use std::collections::BTreeMap; use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint, keep_model}; -use vectorcraft_doc::{AppearanceItem, Node, NodeKind}; +use vectorcraft_doc::swatches::node_colors; +use vectorcraft_doc::{AppearanceItem, NodeKind}; +use super::colorcmds::{Recolor, Scope}; use super::*; pub fn specs() -> Vec { vec![ - cmd!(query "recolor.colors", "Artwork Colors", [], None, "{} → {colors: [{hex, count}]} unique colours used by the selection (fills, strokes, gradient stops, text)", has_selection, colors), + cmd!( + query "recolor.colors", + "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", + has_selection, + colors + ), cmd!( "recolor.apply", "Recolor Artwork", ["Edit", "Edit Colors"], None, - "{map: {\"#rrggbb\": \"#rrggbb\", …}} replace colours across the selection (gradients and text included); each new colour keeps the model of the one it replaces", + "{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}", has_selection, apply ), ] } -fn visit_paints(n: &mut Node, f: &mut impl FnMut(&mut Color)) { - let mut paint = |p: &mut Paint| match p { - Paint::Solid { color, .. } => f(color), - Paint::Gradient(g) => { - for s in &mut g.gradient.stops { - f(&mut s.color); - } - } - _ => {} - }; - for it in &mut n.appearance.items { - match it { - AppearanceItem::Fill(fl) => paint(&mut fl.paint), - AppearanceItem::Stroke(st) => paint(&mut st.paint), - } - } - if let NodeKind::Text(t) = &mut n.kind { - for r in &mut t.runs { - paint(&mut r.style.fill); - paint(&mut r.style.stroke); - } - } - if let Some(ch) = n.children_mut() { - for c in ch.iter_mut() { - visit_paints(std::sync::Arc::make_mut(c), f); - } - } -} - fn colors(s: &mut Session, _: &Value) -> Result { let st = s.doc()?; + let d = &st.doc; let mut counts: BTreeMap = BTreeMap::new(); - for id in &st.selection.objects { - if let Some(n) = st.doc.node(*id) { - let mut n = n.clone(); - visit_paints(&mut n, &mut |c| *counts.entry(c.to_hex()).or_default() += 1); - } + let mut count = |c: &Color, _: Option<&str>| *counts.entry(c.to_hex()).or_default() += 1; + // 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)) { + node_colors(n, &mut count); + n.walk(&mut |m| { + let runs = match &m.kind { + NodeKind::Text(t) => t.runs.as_slice(), + _ => &[], + }; + let items = m.appearance.items.iter().map(|it| match it { + AppearanceItem::Fill(l) => &l.paint, + AppearanceItem::Stroke(l) => &l.paint, + }); + for p in items.chain(runs.iter().flat_map(|r| [&r.style.fill, &r.style.stroke])) { + if let Paint::Pattern { pattern, .. } = p + && !patterns.contains(&pattern.as_str()) + { + patterns.push(pattern); + } + } + }); + } + 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))); @@ -75,19 +77,8 @@ fn apply(s: &mut Session, p: &Value) -> Result { .filter_map(|(k, v)| Some((Color::from_hex(k)?.to_hex(), color_value(v)?))) .collect(); let ids = s.doc()?.selection.objects.clone(); - let mut changed = 0usize; - s.edit("Recolor Artwork", |d, _| { - for id in &ids { - if let Some(n) = d.node_mut(*id) { - visit_paints(n, &mut |c| { - if let Some(to) = map.get(&c.to_hex()) { - *c = keep_model(*c, *to); - changed += 1; - } - }); - } - } - Ok(()) - })?; + let f = |c: Color| map.get(&c.to_hex()).map_or(c, |to| keep_model(c, *to)); + 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 })) } diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index 63b35f35..3be1648b 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -17,7 +17,7 @@ pub fn specs() -> Vec { "New Swatch", ["Window", "Swatches"], None, - "{name?, color? | swatch? | gradient? | pattern?: name (default: the current fill), mode?: \"gray\"|\"rgb\"|\"hsb\"|\"cmyk\"|\"web\" (convert the colour), global?, spot? (a spot colour, always global), group?: colour group name (solid colours only; default: ungrouped)} save a colour, gradient or pattern as a swatch. Names are unique (\"Sky 2\"); a colour's default name is its values (\"C=10 M=20 Y=30 K=0\", \"R=255 G=128 B=0\", \"Gray K=40\") → {name}", + "{name?, color? | colors?: [colour] (one swatch each, in one undo step) | swatch? | gradient? | pattern?: name (default: the current fill), mode?: \"gray\"|\"rgb\"|\"hsb\"|\"cmyk\"|\"web\" (convert the colour), global?, spot? (a spot colour, always global), group?: colour group name (solid colours only; default: ungrouped)} save a colour, gradient or pattern as a swatch. Names are unique (\"Sky 2\"); a colour's default name is its values (\"C=10 M=20 Y=30 K=0\", \"R=255 G=128 B=0\", \"Gray K=40\") → {name, names: [every new swatch]}", has_doc, swatch_new ), @@ -118,41 +118,57 @@ pub fn specs() -> Vec { fn swatch_new(s: &mut Session, p: &Value) -> Result { const C: &str = "swatch.new"; - let paint = match str_param(p, "pattern") { - Some(name) if s.doc()?.doc.pattern(name).is_some() => pattern_paint(name), - Some(name) => return Err(bad(C, format!("no pattern `{name}`"))), - None => paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone()), + let paints = match (p.get("colors").and_then(Value::as_array), str_param(p, "pattern")) { + (Some(cs), _) => { + let cs: Vec = + cs.iter().map(|c| color_value(c).map(Paint::solid)).collect::>().ok_or_else(|| bad(C, "bad colour in `colors`"))?; + if cs.is_empty() { + return Err(bad(C, "`colors` is empty")); + } + cs + } + (None, Some(name)) if s.doc()?.doc.pattern(name).is_some() => vec![pattern_paint(name)], + (None, Some(name)) => return Err(bad(C, format!("no pattern `{name}`"))), + (None, None) => vec![paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone())], }; let spot = bool_or(p, "spot", false); let global = spot || bool_or(p, "global", false); let mode = str_param(p, "mode"); let group = str_param(p, "group"); - let paint = match paint { - // The swatch holds the colour itself, not a link to the swatch it may have come from. - Paint::Solid { color, .. } => Paint::solid(match mode { - Some(m) => convert_to_mode(color, m, C)?, - None => color, - }), - Paint::None => return Err(bad(C, "a swatch needs a colour, gradient or pattern")), - _ if spot || mode.is_some() || group.is_some() => return Err(bad(C, "spot, mode and group apply to solid colours only")), - other => other, - }; - let requested = name_param(p, "name"); - let name = s.edit("New Swatch", |d, _| { - let name = match requested { - Some(n) => d.free_swatch_name(&n), - None => d.new_swatch_name(&paint), - }; - let swatch = Swatch { name: name.clone(), paint, global, spot }; - match group { - Some(g) => { - d.swatch_groups.iter_mut().find(|x| x.name == g).ok_or_else(|| bad(C, format!("no colour group `{g}`")))?.swatches.push(swatch) + let paints = paints + .into_iter() + .map(|paint| match paint { + // The swatch holds the colour itself, not a link to the swatch it may have come from. + Paint::Solid { color, .. } => Ok(Paint::solid(match mode { + Some(m) => convert_to_mode(color, m, C)?, + None => color, + })), + Paint::None => Err(bad(C, "a swatch needs a colour, gradient or pattern")), + _ if spot || mode.is_some() || group.is_some() => Err(bad(C, "spot, mode and group apply to solid colours only")), + other => Ok(other), + }) + .collect::>>()?; + // A name applies to a single new swatch. + let requested = name_param(p, "name").filter(|_| paints.len() == 1); + let names = s.edit("New Swatch", |d, _| { + let mut names = vec![]; + for paint in paints { + let name = match &requested { + Some(n) => d.free_swatch_name(n), + None => d.new_swatch_name(&paint), + }; + let swatch = Swatch { name: name.clone(), paint, global, spot }; + match group { + Some(g) => { + d.swatch_groups.iter_mut().find(|x| x.name == g).ok_or_else(|| bad(C, format!("no colour group `{g}`")))?.swatches.push(swatch) + } + None => d.swatches.push(swatch), } - None => d.swatches.push(swatch), + names.push(name); } - Ok(name) + Ok(names) })?; - Ok(json!({ "name": name })) + Ok(json!({ "name": names[0], "names": names })) } /// The `names` list and `name` parameters, without repeats; at least one is required. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 88e86f1c..436db81c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -748,6 +748,8 @@ mod tests_clip; #[cfg(test)] mod tests_cmdsplit; #[cfg(test)] +mod tests_colorguide; +#[cfg(test)] mod tests_colormgmt; #[cfg(test)] mod tests_dashalign; @@ -756,6 +758,8 @@ mod tests_distort; #[cfg(test)] mod tests_draw2; #[cfg(test)] +mod tests_editcolors; +#[cfg(test)] mod tests_effectedit; #[cfg(test)] mod tests_file; diff --git a/crates/engine/src/tests_colorguide.rs b/crates/engine/src/tests_colorguide.rs new file mode 100644 index 00000000..e5b97971 --- /dev/null +++ b/crates/engine/src/tests_colorguide.rs @@ -0,0 +1,72 @@ +//! The Color Guide: `color.harmony` and saving several colours as swatches at once. + +use serde_json::{Value, json}; +use vectorcraft_color::Color; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + s +} + +fn hexes(v: &Value) -> Vec { + v.as_array().unwrap().iter().map(|h| h.as_str().unwrap().to_string()).collect() +} + +#[test] +fn harmony_query_returns_the_group_and_its_grid() { + let mut s = Session::new(); + let r = s.execute("color.harmony", &json!({"color": "#ff0000", "rule": "triad", "steps": 2})).unwrap(); + assert_eq!(r["rule"], "triad"); + assert_eq!(hexes(&r["colors"]), ["#ff0000", "#00ff00", "#0000ff"], "the base comes first"); + let grid = r["grid"].as_array().unwrap(); + assert_eq!(grid.len(), 3, "a row per harmony colour"); + for (row, c) in grid.iter().zip(hexes(&r["colors"])) { + let row = hexes(row); + assert_eq!(row.len(), 5, "2·steps + 1 columns"); + assert_eq!(row[2], c, "step 0 is the colour itself"); + } + // Tints and shades by default: darker on the left, lighter on the right. + let first = hexes(&grid[0]); + let lum = |h: &str| Color::from_hex(h).unwrap().to_rgb().iter().sum::(); + assert!(lum(&first[0]) < lum(&first[2]) && lum(&first[4]) > lum(&first[2])); + // Labels work as rule names, other variations and amounts change the grid. + let r2 = + s.execute("color.harmony", &json!({"color": "#ff0000", "rule": "Split Complementary", "variation": "vividMuted", "amount": 100})).unwrap(); + assert_eq!(r2["rule"], "splitComplementary"); + assert_eq!(r2["grid"][0].as_array().unwrap().len(), 9, "4 steps by default"); + // Every rule answers, base first. + for h in vectorcraft_color::harmony::Harmony::ALL { + let r = s.execute("color.harmony", &json!({"color": "#3366cc", "rule": h.id()})).unwrap(); + assert_eq!(r["colors"][0], "#3366cc", "{}", h.label()); + } + assert!(s.execute("color.harmony", &json!({"color": "#ff0000", "rule": "square"})).is_err()); + assert!(s.execute("color.harmony", &json!({"rule": "triad"})).is_err()); + assert!(s.execute("color.harmony", &json!({"color": "#ff0000", "rule": "triad", "variation": "loud"})).is_err()); +} + +#[test] +fn new_swatch_saves_several_colours_in_one_step() { + let mut s = session(); + let before = s.doc().unwrap().doc.swatches.len(); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("swatch.new", &json!({"colors": ["#112233", "#445566", {"c": 10, "m": 20, "y": 30, "k": 0}]})).unwrap(); + assert_eq!(hexes(&r["names"]).len(), 3); + let st = s.doc().unwrap(); + assert_eq!(st.doc.swatches.len(), before + 3); + assert_eq!(st.history.undo.len(), undo + 1, "one undo step"); + assert_eq!(r["name"], "R=17 G=34 B=51"); + assert!(st.doc.swatch("C=10 M=20 Y=30 K=0").is_some()); + // Into a colour group, as global swatches. + s.execute("swatch.newGroup", &json!({"name": "Guide"})).unwrap(); + let r = s.execute("swatch.new", &json!({"colors": ["#010203", "#040506"], "group": "Guide", "global": true})).unwrap(); + let d = &s.doc().unwrap().doc; + let g = d.swatch_groups.iter().find(|g| g.name == "Guide").unwrap(); + assert_eq!(g.swatches.len(), 2); + assert!(g.swatches.iter().all(|w| w.global)); + assert_eq!(hexes(&r["names"]), g.swatches.iter().map(|w| w.name.clone()).collect::>()); + assert!(s.execute("swatch.new", &json!({"colors": []})).is_err()); + assert!(s.execute("swatch.new", &json!({"colors": ["nope"]})).is_err()); +} diff --git a/crates/engine/src/tests_editcolors.rs b/crates/engine/src/tests_editcolors.rs new file mode 100644 index 00000000..b0d60de2 --- /dev/null +++ b/crates/engine/src/tests_editcolors.rs @@ -0,0 +1,230 @@ +//! Edit → Edit Colors: Adjust Color Balance modes and the live preview. + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::Node; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + s +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +/// A selected rectangle filled with `fill`. +fn rect(s: &mut Session, fill: Value) -> NodeId { + let id = id_of(&s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()); + s.execute("paint.setFill", &json!({"ids": [id.0], "color": fill})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + id +} + +fn fill_of(s: &Session, id: NodeId) -> Color { + s.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint().color().unwrap() +} + +#[test] +fn adjust_balance_modes() { + let mut s = session(); + let a = rect(&mut s, json!({"c": 20, "m": 40, "y": 0, "k": 10})); + // CMYK channels on a CMYK colour stay CMYK. + s.execute("edit.colors.adjustBalance", &json!({"mode": "cmyk", "c": 10, "k": -10})).unwrap(); + let Color::Cmyk { c, m, k, .. } = fill_of(&s, a) else { panic!("kept CMYK") }; + assert!((c - 0.3).abs() < 1e-5 && (m - 0.4).abs() < 1e-5 && k.abs() < 1e-5, "{c} {m} {k}"); + // RGB channels keep the colour's model unless Convert is on. + s.execute("edit.colors.adjustBalance", &json!({"mode": "rgb", "r": 10})).unwrap(); + assert!(matches!(fill_of(&s, a), Color::Cmyk { .. })); + s.execute("edit.colors.adjustBalance", &json!({"mode": "rgb", "convert": true})).unwrap(); + assert!(matches!(fill_of(&s, a), Color::Rgb { .. }), "converted to RGB"); + // Gray with Convert makes a grey of the colour darkened by the shift. + let b = rect(&mut s, json!("#808080")); + s.execute("edit.colors.adjustBalance", &json!({"mode": "gray", "gray": 20, "convert": true})).unwrap(); + let Color::Gray { k } = fill_of(&s, b) else { panic!("converted to grey") }; + assert!((k - 0.698).abs() < 0.01, "{k}"); + // Mode follows the channels given when it is left out. + s.execute("edit.colors.adjustBalance", &json!({"gray": -20})).unwrap(); + assert!(matches!(fill_of(&s, b), Color::Gray { .. })); + assert!(s.execute("edit.colors.adjustBalance", &json!({"mode": "lab"})).is_err()); +} + +#[test] +fn global_mode_needs_tints_and_says_so() { + let mut s = session(); + s.execute("swatch.new", &json!({"name": "Brand", "color": "#3366cc", "global": true})).unwrap(); + let a = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + s.execute("paint.setFill", &json!({"ids": [a.0], "swatch": "Brand"})).unwrap(); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let e = s.execute("edit.colors.adjustBalance", &json!({"mode": "global"})).unwrap_err().to_string(); + assert!(e.contains("global and spot"), "{e}"); + assert_eq!(*s.doc().unwrap().doc, *before, "nothing changed"); + let p = s.doc().unwrap().doc.node(a).unwrap().appearance.fill_paint(); + assert!(matches!(p, Paint::Solid { swatch: Some(ref n), .. } if n == "Brand"), "the link is kept"); +} + +#[test] +fn balance_previews_and_commits_one_step() { + let mut s = session(); + let a = rect(&mut s, json!("#808080")); + let undo = s.doc().unwrap().history.undo.len(); + s.begin_interaction("Adjust Colors").unwrap(); + for r in [10, 30, 20] { + s.preview("edit.colors.adjustBalance", &json!({"mode": "rgb", "r": r})).unwrap(); + } + assert_eq!(fill_of(&s, a).to_hex(), "#b38080", "each preview starts from the original"); + s.commit_interaction().unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(fill_of(&s, a).to_hex(), "#808080"); +} + +// ---------- meshes, images and patterns ---------- + +fn mesh_colors(s: &Session, id: NodeId) -> Vec { + match &s.doc().unwrap().doc.node(id).unwrap().kind { + NodeKind::Mesh(m) => m.points.iter().map(|p| p.color).collect(), + _ => panic!("not a mesh"), + } +} + +/// A selected 1×1 gradient mesh: red, with one green corner. +fn mesh(s: &mut Session) -> NodeId { + let a = rect(s, json!("#ff0000")); + s.execute("object.mesh.create", &json!({"rows": 1, "cols": 1})).unwrap(); + s.execute("object.mesh.setPointColor", &json!({"id": a.0, "index": 0, "color": "#00ff00"})).unwrap(); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + a +} + +#[test] +fn edit_colors_reach_mesh_points() { + let mut s = session(); + let m = mesh(&mut s); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("edit.colors.toGrayscale", &json!({})).unwrap(); + let pts = mesh_colors(&s, m); + assert!(pts.iter().all(|c| matches!(c, Color::Gray { .. })), "a grey mesh: {pts:?}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + s.execute("edit.colors.invert", &json!({"fill": false})).unwrap(); + assert_eq!(mesh_colors(&s, m), pts, "a mesh's points are fill colours"); +} + +#[test] +fn blend_moves_a_mesh_to_the_blended_colour() { + let mut s = session(); + let left = rect(&mut s, json!("#000000")); + let m = mesh(&mut s); + s.execute("object.transform", &json!({"ids": [m.0], "matrix": [1, 0, 0, 1, 100, 0]})).unwrap(); + let right = rect(&mut s, json!("#ffffff")); + s.execute("object.transform", &json!({"ids": [right.0], "matrix": [1, 0, 0, 1, 200, 0]})).unwrap(); + s.execute("select.set", &json!({"ids": [left.0, m.0, right.0]})).unwrap(); + let before = mesh_colors(&s, m); + s.execute("edit.colors.blendHorizontally", &json!({})).unwrap(); + let after = mesh_colors(&s, m); + let mean = |cs: &[Color]| cs.iter().map(|c| c.to_rgb().iter().sum::() / 3.0).sum::() / cs.len() as f32; + assert!((mean(&after) - 0.5).abs() < 0.03, "{}", mean(&after)); + assert_ne!(after[0], after[1], "the shading is kept"); + assert_ne!(before, after); +} + +/// A 2×2 RGBA image as a PNG. +fn png(px: [[u8; 4]; 4]) -> Vec { + let img = image::RgbaImage::from_raw(2, 2, px.concat()).unwrap(); + let mut out = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +fn pixels(s: &Session, id: NodeId) -> Vec<[u8; 4]> { + let d = &s.doc().unwrap().doc; + let NodeKind::Image(im) = &d.node(id).unwrap().kind else { panic!("not an image") }; + image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_rgba8().pixels().map(|p| p.0).collect() +} + +/// A selected embedded 2×2 image. +fn image_node(s: &mut Session) -> NodeId { + let bytes = png([[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [255, 255, 255, 128]]); + s.edit("Place", |d, sel| { + d.images.insert("pic".into(), vectorcraft_doc::ImageBlob::png(bytes)); + let id = d.alloc_id(); + let im = vectorcraft_doc::ImageObject { key: "pic".into(), width: 2, height: 2, xf: Default::default(), link: None }; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(im)))?; + sel.set([id]); + Ok(id) + }) + .unwrap() +} + +#[test] +fn invert_recolours_an_embedded_image() { + let mut s = session(); + let id = image_node(&mut s); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("edit.colors.invert", &json!({})).unwrap(); + assert_eq!(r["changed"], 1); + assert_eq!(pixels(&s, id), [[0, 255, 255, 255], [255, 0, 255, 255], [255, 255, 0, 255], [0, 0, 0, 128]], "inverted, alpha kept"); + let d = &s.doc().unwrap().doc; + assert!(d.images.contains_key("pic"), "the original image stays for undo and other objects"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(pixels(&s, id)[0], [255, 0, 0, 255]); + // Without images, nothing changes. + let r = s.execute("edit.colors.invert", &json!({"includeImages": false})).unwrap(); + assert_eq!(r["changed"], 0); + // Recolor maps exact pixel colours. + s.execute("recolor.apply", &json!({"map": {"#ff0000": "#000000"}})).unwrap(); + assert_eq!(pixels(&s, id)[..2], [[0, 0, 0, 255], [0, 255, 0, 255]]); +} + +/// "Dots": a red square tile; a selected rectangle filled with it. +fn pattern_fill(s: &mut Session) -> NodeId { + rect(s, json!("#ff0000")); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("object.pattern.make", &json!({"name": "Dots", "width": 20, "height": 20})).unwrap(); + s.execute("object.pattern.done", &json!({})).unwrap(); + let r = id_of(&s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 50, "height": 50})).unwrap()); + s.execute("paint.setFill", &json!({"ids": [r.0], "swatch": "Dots"})).unwrap(); + s.execute("select.set", &json!({"ids": [r.0]})).unwrap(); + r +} + +fn tile_fill(s: &Session, pattern: &str) -> String { + let d = &s.doc().unwrap().doc; + d.pattern(pattern).unwrap().art[0].appearance.fill_paint().color().unwrap().to_hex() +} + +fn fill_pattern(s: &Session, id: NodeId) -> String { + match s.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint() { + Paint::Pattern { pattern, .. } => pattern, + p => panic!("not a pattern fill: {p:?}"), + } +} + +#[test] +fn recolor_writes_a_new_pattern_swatch() { + let mut s = session(); + let r = pattern_fill(&mut s); + let colors = s.execute("recolor.colors", &json!({})).unwrap(); + assert!(colors["colors"].as_array().unwrap().iter().any(|c| c["hex"] == "#ff0000"), "the tile's colours are listed: {colors}"); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("recolor.apply", &json!({"map": {"#ff0000": "#0000ff"}})).unwrap(); + assert_eq!(fill_pattern(&s, r), "Dots 2"); + assert_eq!(tile_fill(&s, "Dots 2"), "#0000ff"); + assert_eq!(tile_fill(&s, "Dots"), "#ff0000", "the original pattern is untouched"); + let d = &s.doc().unwrap().doc; + assert!(d.swatch("Dots 2").is_some_and(|w| matches!(w.paint, Paint::Pattern { .. })), "a new pattern swatch"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(fill_pattern(&s, r), "Dots"); + assert!(s.doc().unwrap().doc.pattern("Dots 2").is_none()); + // Edit Colors recolour pattern fills the same way; patterns can be left out. + s.execute("edit.colors.invert", &json!({"includePatterns": false})).unwrap(); + assert_eq!(fill_pattern(&s, r), "Dots"); + s.execute("edit.colors.invert", &json!({})).unwrap(); + assert_eq!((fill_pattern(&s, r), tile_fill(&s, "Dots 2")), ("Dots 2".to_string(), "#00ffff".to_string())); +} diff --git a/crates/ui-egui/src/dialogs/color_balance.rs b/crates/ui-egui/src/dialogs/color_balance.rs new file mode 100644 index 00000000..b99a3523 --- /dev/null +++ b/crates/ui-egui/src/dialogs/color_balance.rs @@ -0,0 +1,180 @@ +//! Edit → Edit Colors → Adjust Color Balance: the colour mode (Gray, RGB, CMYK or Global), a slider +//! per channel (−100..100 %), Convert, Fill and Stroke, previewed live on the canvas; OK keeps the +//! preview as one undo step (`edit.colors.adjustBalance`). +//! +//! Fields: `mode` (`gray`, `rgb`, `cmyk` or `global`), the channels `r`, `g`, `b`, `c`, `m`, `y`, +//! `k`, `gray`, then `convert`, `fill`, `stroke` and `preview`. + +use serde_json::{Value, json}; +use vectorcraft_color::Color; + +use super::{DialogSpec, form}; +use crate::VectorcraftApp; +use crate::panels::c32; +use crate::state::Dialog; +use crate::widgets; + +/// The dialog kind of Adjust Color Balance. +pub const KIND: &str = "colorBalance"; + +const CMD: &str = "edit.colors.adjustBalance"; + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |_| "Adjust Colors".into(), body, confirm, preview: true, min_width: 340.0, ..DialogSpec::FORM }; + +/// A colour mode: its `mode` id, label and channels as (field, label). +struct Mode { + id: &'static str, + label: &'static str, + channels: &'static [(&'static str, &'static str)], +} + +const MODES: [Mode; 4] = [ + Mode { id: "gray", label: "Grayscale", channels: &[("gray", "Black")] }, + Mode { id: "rgb", label: "RGB", channels: &[("r", "Red"), ("g", "Green"), ("b", "Blue")] }, + Mode { id: "cmyk", label: "CMYK", channels: &[("c", "Cyan"), ("m", "Magenta"), ("y", "Yellow"), ("k", "Black")] }, + Mode { id: "global", label: "Global", channels: &[] }, +]; + +/// Open the dialog with every channel at 0, adjusting fills and strokes. +pub fn open(app: &mut VectorcraftApp) { + let fields = json!({ + "mode": "rgb", "r": 0, "g": 0, "b": 0, "c": 0, "m": 0, "y": 0, "k": 0, "gray": 0, + "convert": false, "fill": true, "stroke": true, "preview": true, + }); + app.ui.dialog = Some(Dialog::new(KIND, fields)); +} + +fn mode(d: &Dialog) -> &'static Mode { + let id = d.str("mode"); + MODES.iter().find(|m| m.id == id).unwrap_or(&MODES[1]) +} + +/// `edit.colors.adjustBalance` parameters: the mode, its channels and the options. +fn params(d: &Dialog) -> Value { + let m = mode(d); + let mut p = json!({"mode": m.id, "convert": d.bool("convert"), "fill": d.bool("fill"), "stroke": d.bool("stroke")}); + for (k, _) in m.channels { + p[*k] = json!(d.f64(k, 0.0)); + } + p +} + +/// The rail of channel `key`: the channel from −100 % to +100 % over a mid colour. +fn track(key: &str, x: f32) -> egui::Color32 { + let v = x.clamp(0.0, 1.0); + let c = match key { + "r" => Color::rgb(v, 0.5, 0.5), + "g" => Color::rgb(0.5, v, 0.5), + "b" => Color::rgb(0.5, 0.5, v), + "c" => Color::cmyk(v, 0.0, 0.0, 0.0), + "m" => Color::cmyk(0.0, v, 0.0, 0.0), + "y" => Color::cmyk(0.0, 0.0, v, 0.0), + "k" => Color::cmyk(0.0, 0.0, 0.0, v), + _ => Color::gray(v), + }; + c32(&c) +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let m = mode(d); + ui.horizontal(|ui| { + widgets::dim_label(ui, "Color Mode:"); + let labels: Vec<&str> = MODES.iter().map(|m| m.label).collect(); + if let Some(i) = widgets::dropdown(ui, "balance-mode", m.label, &labels, 120.0) { + d.fields.insert("mode".into(), json!(MODES[i].id)); + } + ui.add_space(12.0); + if widgets::check(ui, "Convert", d.bool("convert"), !m.channels.is_empty()) { + d.fields.insert("convert".into(), json!(!d.bool("convert"))); + } + }); + ui.add_space(8.0); + let channels = mode(d).channels; + if channels.is_empty() { + widgets::dim_label(ui, "Global shifts the tints of global and spot colors (not available yet)."); + } + for (k, label) in channels { + form::slider(ui, d, k, label, -100.0..=100.0, "%", &|x| track(k, x)); + } + ui.add_space(8.0); + ui.horizontal(|ui| { + widgets::dim_label(ui, "Adjust Options:"); + for (k, label) in [("fill", "Fill"), ("stroke", "Stroke")] { + if widgets::check(ui, label, d.bool(k), true) { + d.fields.insert(k.into(), json!(!d.bool(k))); + } + } + }); + let p = params(d); + form::preview(app, ui, d, "Adjust Colors", CMD, p); + false +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + form::commit_preview(app, CMD, params(d)) +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + + fn fill_hex(app: &VectorcraftApp, id: u64) -> String { + let n = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone(); + n.appearance.fill_paint().color().unwrap().to_hex() + } + + fn frame(app: &mut VectorcraftApp) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); + } + + #[test] + fn previews_live_and_commits_one_undo_step() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + app.run("paint.setFill", json!({"color": "#808080"})).unwrap(); + let undo = app.session.doc().unwrap().history.undo.len(); + app.run("ui.colorBalanceDialog", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(KIND)); + app.ui.dialog.as_mut().unwrap().fields.insert("r".into(), json!(20)); + frame(&mut app); + assert!(app.session.in_interaction(), "the preview runs"); + assert_eq!(fill_hex(&app, id), "#b38080", "previewed on the canvas"); + // A field set from outside (`ui.dialog.set`) previews on the next frame. + app.ui.dialog.as_mut().unwrap().fields.insert("r".into(), json!(40)); + frame(&mut app); + assert_eq!(fill_hex(&app, id), "#e68080"); + // Cancel rolls the preview back. + super::super::cancel(&mut app); + assert_eq!(fill_hex(&app, id), "#808080"); + // OK keeps it as one undo step. + app.run("ui.colorBalanceDialog", json!({})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("mode".into(), json!("cmyk")); + d.fields.insert("k".into(), json!(-50)); + frame(&mut app); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none() && !app.session.in_interaction()); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); + let lighter = vectorcraft_color::Color::from_hex(&fill_hex(&app, id)).unwrap().to_rgb()[0]; + assert!(lighter > 0.6, "{lighter}"); + } + + #[test] + fn global_mode_reports_why_and_stays_open() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap(); + app.run("ui.colorBalanceDialog", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("mode".into(), json!("global")); + frame(&mut app); + let e = super::super::confirm(&mut app).unwrap_err(); + assert!(e.contains("global"), "{e}"); + assert!(app.ui.dialog.is_some()); + } +} diff --git a/crates/ui-egui/src/dialogs/color_guide_options.rs b/crates/ui-egui/src/dialogs/color_guide_options.rs new file mode 100644 index 00000000..55975166 --- /dev/null +++ b/crates/ui-egui/src/dialogs/color_guide_options.rs @@ -0,0 +1,92 @@ +//! Color Guide Options: the variation steps on each side of the harmony colours (1–20) and how far +//! they reach (Less … More), with the base colour's variations as a preview. OK sets the Color +//! Guide panel's options (`UiState::color_guide`). +//! +//! Fields: `steps` (1–20) and `amount` (0–100). + +use egui::{Rect, Sense, vec2}; +use serde_json::{Value, json}; +use vectorcraft_color::harmony::{GuideOptions, variation}; + +use super::{DialogSpec, form}; +use crate::VectorcraftApp; +use crate::panels::{c32, color_guide::base_color}; +use crate::state::Dialog; +use crate::theme::Tokens; + +/// The dialog kind of Color Guide Options. +pub const KIND: &str = "colorGuideOptions"; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Color Guide Options".into(), body, confirm, min_width: 320.0, ..DialogSpec::FORM }; + +/// Open the dialog with the panel's current options. +pub fn open(app: &mut VectorcraftApp) { + let o = app.ui.color_guide; + app.ui.dialog = Some(Dialog::new(KIND, json!({"steps": o.steps, "amount": o.amount}))); +} + +/// The panel's options with the dialog's steps and amount. +fn options(app: &VectorcraftApp, d: &Dialog) -> GuideOptions { + let o = app.ui.color_guide; + GuideOptions { steps: d.f64("steps", o.steps as f64).round().max(1.0) as u32, amount: d.f64("amount", o.amount as f64) as f32, ..o }.clamped() +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let t = Tokens::get(ui.ctx()); + let max = GuideOptions::MAX_STEPS as f64; + let ramp = |x: f32| { + let v = (60.0 + 160.0 * x) as u8; + egui::Color32::from_rgb(v, v, v) + }; + form::slider(ui, d, "steps", "Steps:", 1.0..=max, "", &ramp); + form::slider(ui, d, "amount", "Variation:", 0.0..=100.0, "%", &ramp); + ui.horizontal(|ui| { + ui.add_space(form::SLIDER_LABEL + ui.spacing().item_spacing.x); + let (r, _) = ui.allocate_exact_size(vec2(form::SLIDER_WIDTH, 14.0), Sense::hover()); + let font = egui::FontId::proportional(11.0); + ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, "Less", font.clone(), t.text_dim); + ui.painter().text(r.right_center(), egui::Align2::RIGHT_CENTER, "More", font, t.text_dim); + }); + ui.add_space(10.0); + // Preview: the base colour's row of variations. + let o = options(app, d); + let base = base_color(app, ui.ctx()); + let n = o.steps as i32; + let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::hover()); + let w = row.width() / (2 * n + 1) as f32; + for (i, step) in (-n..=n).enumerate() { + let r = Rect::from_min_size(row.min + vec2(i as f32 * w, 0.0), vec2(w, row.height())); + ui.painter().rect_filled(r, 0.0, c32(&variation(base, &o, step))); + } + ui.painter().rect_stroke(row, 0.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Outside); + false +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + app.ui.color_guide = options(app, d); + app.ui.dialog = None; + Ok(json!(app.ui.color_guide)) +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + + #[test] + fn ok_sets_the_panel_options() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("ui.colorGuideOptions", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(KIND)); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::super::show(&mut app, ui.ctx())); + out.textures_delta.clear(); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("steps".into(), json!(30)); + d.fields.insert("amount".into(), json!(80)); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!((app.ui.color_guide.steps, app.ui.color_guide.amount), (20, 80.0), "steps are clamped to 20"); + } +} diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index 5a2744d7..d2f03d49 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -137,3 +137,79 @@ pub(super) fn humanize(k: &str) -> String { _ => format!("{s}:"), } } + +/// Widths of [`slider`]'s label column and rail. +pub(super) const SLIDER_LABEL: f32 = 64.0; +pub(super) const SLIDER_WIDTH: f32 = 180.0; + +/// A labelled slider with a value field for the number `d.fields[key]` in `range` (whole numbers, +/// `suffix` after the value); `track(t)` colours the rail at 0..1. +pub(super) fn slider( + ui: &mut egui::Ui, + d: &mut Dialog, + key: &str, + label: &str, + range: std::ops::RangeInclusive, + suffix: &str, + track: &dyn Fn(f32) -> egui::Color32, +) { + let t = Tokens::get(ui.ctx()); + let (min, max) = (*range.start(), *range.end()); + let v = d.f64(key, 0.0).clamp(min, max); + let mut new = None; + ui.horizontal(|ui| { + ui.add_sized([SLIDER_LABEL, 22.0], egui::Label::new(egui::RichText::new(label).color(t.text))); + if let (Some(x), _) = crate::widgets::color_slider(ui, ("dlg-slider", key), ((v - min) / (max - min)) as f32, SLIDER_WIDTH, track) { + new = Some((min + x as f64 * (max - min)).round()); + } + if let Some(x) = crate::widgets::plain_field(ui, ("dlg-field", key), v, suffix, 0, 52.0) { + new = Some(x.round().clamp(min, max)); + } + }); + if let Some(n) = new.filter(|n| *n != v) { + d.fields.insert(key.into(), json!(n)); + } +} + +/// The Preview checkbox of a dialog that previews `cmd` on the canvas: while it is on, `params` run +/// again on the interaction's snapshot whenever they differ from the last preview (whether a widget +/// or `ui.dialog.set` changed them); turning it off rolls back. +pub(super) fn preview(app: &mut crate::VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, label: &str, cmd: &str, params: Value) { + const LAST: &str = "__previewed"; + ui.add_space(8.0); + let mut on = d.bool("preview"); + if crate::widgets::check(ui, "Preview", on, true) { + on = !on; + d.fields.insert("preview".into(), json!(on)); + } + if !on { + if d.fields.remove(LAST).is_some() { + let _ = app.session.cancel_interaction(); + } + return; + } + if d.fields.get(LAST) != Some(¶ms) || !app.session.in_interaction() { + let _ = app.session.begin_interaction(label); + if let Err(e) = app.session.preview(cmd, ¶ms) { + app.status(e.to_string()); + } + d.fields.insert(LAST.into(), params); + } +} + +/// OK of a dialog using [`preview`]: keep the previewed `cmd` as one undo step (or run it when no +/// preview runs) and close. On an error the dialog stays open. +pub(super) fn commit_preview(app: &mut crate::VectorcraftApp, cmd: &str, params: Value) -> Result { + let r = if app.session.in_interaction() { + match app.session.preview(cmd, ¶ms) { + Ok(_) => app.session.commit_interaction().map(|_| Value::Null).map_err(|e| e.to_string()), + Err(e) => Err(e.to_string()), + } + } else { + app.run(cmd, params) + }; + if r.is_ok() { + app.ui.dialog = None; + } + r +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 0b12280e..51e9077e 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -9,6 +9,8 @@ mod about; mod all_tools; mod artboard_options; +pub mod color_balance; +pub mod color_guide_options; mod color_picker; mod command; pub mod confirm; @@ -23,6 +25,7 @@ mod new_document; pub mod new_swatch; mod path_ops; mod recolor; +pub mod saturate; mod save_changes; mod shapes; pub mod swatch_options; @@ -144,6 +147,9 @@ registry! { ColorPicker: [color_picker::KIND] => color_picker::SPEC, GraphicStyleOptions: [graphic_style_options::KIND] => graphic_style_options::SPEC, EffectExists: [effect::EXISTS] => effect::EXISTS_SPEC, + ColorGuideOptions: [color_guide_options::KIND] => color_guide_options::SPEC, + ColorBalance: [color_balance::KIND] => color_balance::SPEC, + Saturate: [saturate::KIND] => saturate::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/dialogs/saturate.rs b/crates/ui-egui/src/dialogs/saturate.rs new file mode 100644 index 00000000..37f47230 --- /dev/null +++ b/crates/ui-egui/src/dialogs/saturate.rs @@ -0,0 +1,61 @@ +//! Edit → Edit Colors → Saturate: an intensity slider (−100..100 %) previewed live on the canvas; +//! OK keeps the preview as one undo step (`edit.colors.saturate`). +//! +//! Fields: `intensity` and `preview`. + +use serde_json::{Value, json}; +use vectorcraft_color::Color; + +use super::{DialogSpec, form}; +use crate::VectorcraftApp; +use crate::panels::c32; +use crate::state::Dialog; + +/// The dialog kind of Saturate. +pub const KIND: &str = "saturate"; + +const CMD: &str = "edit.colors.saturate"; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Saturate".into(), body, confirm, preview: true, ..DialogSpec::FORM }; + +pub fn open(app: &mut VectorcraftApp) { + app.ui.dialog = Some(Dialog::new(KIND, json!({"intensity": 0, "preview": true}))); +} + +fn params(d: &Dialog) -> Value { + json!({"intensity": d.f64("intensity", 0.0)}) +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + // The rail runs from grey to a fully saturated colour. + form::slider(ui, d, "intensity", "Intensity:", -100.0..=100.0, "%", &|x| c32(&Color::from_hsb(12.0, x, 0.85))); + let p = params(d); + form::preview(app, ui, d, "Saturate", CMD, p); + false +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + form::commit_preview(app, CMD, params(d)) +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + + #[test] + fn commits_without_a_drawn_frame_as_one_step() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + app.run("paint.setFill", json!({"color": "#cc6666"})).unwrap(); + let undo = app.session.doc().unwrap().history.undo.len(); + app.run("ui.saturateDialog", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("intensity".into(), json!(-100)); + super::super::confirm(&mut app).unwrap(); + let st = app.session.doc().unwrap(); + assert_eq!(st.history.undo.len(), undo + 1); + let c = st.doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.fill_paint().color().unwrap(); + assert!(c.to_hsb()[1] < 1e-3, "fully desaturated"); + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5c682e7b..ef51a5cc 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -201,6 +201,24 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{tool: id} what double-clicking a tool button opens: gradient → the Gradient panel (window.panel), eyedropper → Eyedropper Options (dialog `command` running eyedropper.setOptions, fields appearance, transparency)", ), + ( + "ui.colorGuideOptions", + "Color Guide Options…", + "", + "{} open Color Guide Options (dialog `colorGuideOptions`, fields `steps` 1–20 and `amount` 0–100): OK sets the Color Guide panel's variation grid (read back with `ui.inspect`: ui.color_guide; engine: color.harmony)", + ), + ( + "ui.colorBalanceDialog", + "Adjust Color Balance…", + "", + "{} open Adjust Colors for the selection (dialog `colorBalance`, fields `mode` gray|rgb|cmyk|global, channels r g b / c m y k / gray −100..100, `convert`, `fill`, `stroke`, `preview`): previews live, OK runs edit.colors.adjustBalance as one undo step", + ), + ( + "ui.saturateDialog", + "Saturate…", + "", + "{} open Saturate for the selection (dialog `saturate`, fields `intensity` −100..100, `preview`): previews live, OK runs edit.colors.saturate as one undo step", + ), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -522,6 +540,18 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::open_color_picker(app, p), "ui.graphicStyleOptions" => crate::dialogs::graphic_style_options::open(app, s("name").as_deref()), "tool.options" => crate::toolbar::open_options(app, &s("tool").unwrap_or_default()), + "ui.colorGuideOptions" => { + crate::dialogs::color_guide_options::open(app); + Ok(Value::Null) + } + "ui.colorBalanceDialog" => { + crate::dialogs::color_balance::open(app); + Ok(Value::Null) + } + "ui.saturateDialog" => { + crate::dialogs::saturate::open(app); + Ok(Value::Null) + } _ => return None, }; Some(r) @@ -686,6 +716,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { "file.export.pdf" => app.session.active().is_some(), "ui.swatchOptions" | "ui.newSwatch" | "ui.newColorGroup" => app.session.active().is_some(), "ui.graphicStyleOptions" => app.session.active().is_some(), + "ui.colorBalanceDialog" | "ui.saturateDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), _ => true, } } @@ -780,7 +811,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { "Edit Colors", vec![ c("Recolor Artwork…", "ui.recolorDialog"), - cp("Adjust Color Balance…", "edit.colors.adjustBalance", json!({"r": 0, "g": 0, "b": 0})), + c("Adjust Color Balance…", "ui.colorBalanceDialog"), c("Blend Front to Back", "edit.colors.blendFrontToBack"), c("Blend Horizontally", "edit.colors.blendHorizontally"), c("Blend Vertically", "edit.colors.blendVertically"), @@ -793,7 +824,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { "edit.colors.overprintBlack", json!({"remove": false, "percentage": 100, "fill": true, "stroke": true, "includeCmyBlacks": false, "includeSpotBlacks": false}), ), - cp("Saturate…", "edit.colors.saturate", json!({"intensity": 20})), + c("Saturate…", "ui.saturateDialog"), ], ), todo("Edit Original"), diff --git a/crates/ui-egui/src/panels/color_guide.rs b/crates/ui-egui/src/panels/color_guide.rs index d124c477..abdda9c2 100644 --- a/crates/ui-egui/src/panels/color_guide.rs +++ b/crates/ui-egui/src/panels/color_guide.rs @@ -1,124 +1,144 @@ -//! Color Guide panel: base colour, harmony-rule dropdown with the harmony strip, and a variation -//! grid (tints/shades, warm/cool or vivid/muted) whose cells apply to the active proxy. +//! Color Guide panel: the base colour, the harmony-rule dropdown with the harmony strip, and the +//! variation grid: a row per harmony colour with the colour itself in the centre column, +//! shades/cool/muted to the left and tints/warm/vivid to the right (steps and reach from Color +//! Guide Options). The base colour stays put until "Set base color" takes the current colour. A +//! click on a cell applies it to the active proxy and selects it; Shift- or Cmd/Ctrl-click adds +//! cells to the selection that Save Colors as Swatches saves. use egui::{Rect, Sense, Ui, vec2}; -use serde_json::json; -use vectorcraft_color::harmony::Harmony; +use serde_json::{Value, json}; +use vectorcraft_color::harmony::{Guide, Harmony, Variation}; use vectorcraft_color::{Color, Paint}; -use super::{active_paint, apply_click, color_json, pstate, set_pstate}; +use super::{active_paint, apply_click, c32, color_json, pstate, set_pstate}; use crate::VectorcraftApp; use crate::widgets::{self, menu_item}; -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum Variation { - #[default] - TintsShades, - WarmCool, - VividMuted, +/// The base colour before one is set: an orange. +const DEFAULT_BASE: Color = Color::Rgb { r: 230.0 / 255.0, g: 120.0 / 255.0, b: 40.0 / 255.0 }; + +/// The guide's base colour: fixed once set; the current colour when the panel first shows. +pub(crate) fn base_color(app: &VectorcraftApp, ctx: &egui::Context) -> Color { + if let Some(c) = pstate::>(ctx, "cg-base") { + return c; + } + let c = active_paint(app).color().unwrap_or(DEFAULT_BASE); + set_pstate(ctx, "cg-base", Some(c)); + c } -/// Rows above/below the base row in the variation grid. -pub const STEPS: i32 = 3; - -/// Variation of `c` at `step` (−STEPS..=STEPS; 0 = the colour itself). -pub fn variation(c: &Color, mode: Variation, step: i32) -> Color { - if step == 0 { - return *c; - } - let f = step.unsigned_abs() as f32 / (STEPS as f32 + 1.0); - match mode { - Variation::TintsShades => { - if step < 0 { - c.lerp(&Color::BLACK, f) - } else { - c.lerp(&Color::WHITE, f) - } - } - Variation::WarmCool => { - let target = if step < 0 { Color::rgb(0.2, 0.45, 1.0) } else { Color::rgb(1.0, 0.55, 0.1) }; - c.lerp(&target, f * 0.6) - } - Variation::VividMuted => { - let [h, s, b] = c.to_hsb(); - let s2 = if step < 0 { s * (1.0 - f) } else { s + (1.0 - s) * f }; - Color::from_hsb(h, s2.clamp(0.0, 1.0), b) - } - } -} - -fn harmony(ui: &Ui) -> Harmony { - let i: usize = pstate(ui.ctx(), "cg-harmony"); +fn rule(ctx: &egui::Context) -> Harmony { + let i: usize = pstate(ctx, "cg-harmony"); Harmony::ALL[i.min(Harmony::ALL.len() - 1)] } +/// Selected grid cells: (row, column). +fn selected(ctx: &egui::Context) -> Vec<(usize, usize)> { + pstate(ctx, "cg-sel") +} + +fn clear_selection(ctx: &egui::Context) { + set_pstate(ctx, "cg-sel", Vec::<(usize, usize)>::new()); +} + +fn guide(app: &VectorcraftApp, ctx: &egui::Context) -> Guide { + Guide::new(base_color(app, ctx), rule(ctx), &app.ui.color_guide) +} + +/// Save the selected cells' colours as swatches (one undo step). +fn save_selected(app: &mut VectorcraftApp, ctx: &egui::Context) { + let g = guide(app, ctx); + let colors: Vec = selected(ctx).into_iter().filter_map(|(r, c)| g.grid.get(r)?.get(c)).map(color_json).collect(); + if !colors.is_empty() { + app.run("swatch.new", json!({ "colors": colors })).ok(); + } +} + pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = crate::theme::Tokens::get(ui.ctx()); - let base = match active_paint(app) { - Paint::Solid { color, .. } => color, - _ => pstate::>(ui.ctx(), "cg-base").unwrap_or(Color::rgb8(230, 120, 40)), - }; - let h = harmony(ui); - let colors = h.apply(base); - let mode: Variation = pstate(ui.ctx(), "cg-mode"); + let ctx = ui.ctx().clone(); + let h = rule(&ctx); + let g = guide(app, &ctx); + let base = g.colors[0]; let mut chosen: Option = None; ui.horizontal(|ui| { let (r, resp) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::click()); widgets::swatch_tile(ui, r, &Paint::solid(base), false, resp.hovered()); - if resp.on_hover_text("Set base color to the current color").clicked() { - set_pstate(ui.ctx(), "cg-base", Some(base)); + if resp.on_hover_text("Set base color to the current color").clicked() + && let Some(c) = active_paint(app).color() + { + set_pstate(&ctx, "cg-base", Some(c)); + clear_selection(&ctx); } let labels: Vec<&str> = Harmony::ALL.iter().map(|h| h.label()).collect(); if let Some(i) = widgets::dropdown(ui, "cg-rule", h.label(), &labels, ui.available_width() - 4.0) { - set_pstate(ui.ctx(), "cg-harmony", i); + set_pstate(&ctx, "cg-harmony", i); + clear_selection(&ctx); } }); // Harmony strip. let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 20.0), Sense::hover()); - let w = strip.width() / colors.len() as f32; - for (i, c) in colors.iter().enumerate() { + let w = strip.width() / g.colors.len() as f32; + for (i, c) in g.colors.iter().enumerate() { let r = Rect::from_min_size(strip.min + vec2(i as f32 * w, 0.0), vec2(w, strip.height())); let resp = ui.interact(r, ui.id().with(("cg-h", i)), Sense::click()); - ui.painter().rect_filled(r, 0.0, super::c32(c)); + ui.painter().rect_filled(r, 0.0, c32(c)); if resp.on_hover_text(c.to_hex()).clicked() { chosen = Some(*c); } } ui.add_space(6.0); - // Variation grid: columns = harmony colours, rows = variations. - let cols = colors.len().max(1) as f32; - let cell_w = (ui.available_width() / cols).floor(); - for step in -STEPS..=STEPS { - let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 18.0), Sense::hover()); - for (i, c) in colors.iter().enumerate() { - let v = variation(c, mode, step); - let r = Rect::from_min_size(row.min + vec2(i as f32 * cell_w, 0.0), vec2(cell_w - 1.0, 17.0)); - let resp = ui.interact(r, ui.id().with(("cg-v", step, i)), Sense::click()); - ui.painter().rect_filled(r, 0.0, super::c32(&v)); - if step == 0 { + // Variation grid: a row per harmony colour, the colour itself in the centre column. + let mut sel = selected(&ctx); + let cols = g.grid.first().map_or(1, Vec::len); + let centre = g.centre(); + let cell_w = ui.available_width() / cols as f32; + let gap = if cell_w >= 8.0 { 1.0 } else { 0.0 }; + // Cell edges on whole pixels across the full width. + let x = |i: usize| (i as f32 * cell_w).round(); + let multi = ui.input(|i| i.modifiers.shift || i.modifiers.command); + for (ri, row) in g.grid.iter().enumerate() { + let (rr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 18.0), Sense::hover()); + for (ci, v) in row.iter().enumerate() { + let r = Rect::from_min_size(rr.min + vec2(x(ci), 0.0), vec2(x(ci + 1) - x(ci) - gap, 17.0)); + let resp = ui.interact(r, ui.id().with(("cg-v", ri, ci)), Sense::click()); + ui.painter().rect_filled(r, 0.0, c32(v)); + if sel.contains(&(ri, ci)) { + ui.painter().rect_stroke(r.shrink(1.0), 0.0, egui::Stroke::new(1.0, egui::Color32::WHITE), egui::StrokeKind::Inside); + ui.painter().rect_stroke(r, 0.0, egui::Stroke::new(1.5, t.accent), egui::StrokeKind::Outside); + } else if ci == centre { ui.painter().rect_stroke(r, 0.0, egui::Stroke::new(1.0, t.text_strong), egui::StrokeKind::Inside); } if resp.on_hover_text(v.to_hex()).clicked() { - chosen = Some(v); + if !multi { + sel.clear(); + chosen = Some(*v); + } + match sel.iter().position(|s| *s == (ri, ci)) { + Some(i) => { + sel.remove(i); + } + None => sel.push((ri, ci)), + } + set_pstate(&ctx, "cg-sel", sel.clone()); } } } - let label = match mode { - Variation::TintsShades => ("Shades", "Tints"), - Variation::WarmCool => ("Cool", "Warm"), - Variation::VividMuted => ("Muted", "Vivid"), - }; + let (left, right) = app.ui.color_guide.variation.sides(); ui.horizontal(|ui| { - widgets::dim_label(ui, label.0); + widgets::dim_label(ui, left); ui.add_space((ui.available_width() - 40.0).max(0.0)); - widgets::dim_label(ui, label.1); + widgets::dim_label(ui, right); }); 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() - 2.0 * 28.0).max(0.0)); - widgets::icon_button_enabled(ui, "palette", "Edit or Apply Colors (Recolor Artwork is 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); + 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); + } if widgets::icon_button(ui, "dc-folder", "Save color group to Swatch panel", false, 24.0).clicked() { - let cs: Vec<_> = colors.iter().map(color_json).collect(); + let cs: Vec<_> = g.colors.iter().map(color_json).collect(); app.run("swatch.newGroup", json!({"name": h.label(), "colors": cs})).ok(); } }); @@ -127,32 +147,75 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } } -pub fn menu(_app: &mut VectorcraftApp, ui: &mut Ui) { - let mode: Variation = pstate(ui.ctx(), "cg-mode"); +pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { + let mode = app.ui.color_guide.variation; for (m, l) in [(Variation::TintsShades, "Show Tints/Shades"), (Variation::WarmCool, "Show Warm/Cool"), (Variation::VividMuted, "Show Vivid/Muted")] { if menu_item(ui, l, true, m == mode) { - set_pstate(ui.ctx(), "cg-mode", m); + app.ui.color_guide.variation = m; } } ui.separator(); - menu_item(ui, "Color Guide Options…", false, false); + let ctx = ui.ctx().clone(); + if menu_item(ui, "Save Colors as Swatches", !selected(&ctx).is_empty(), false) { + save_selected(app, &ctx); + } + if menu_item(ui, "Color Guide Options…", true, false) { + app.run("ui.colorGuideOptions", json!({})).ok(); + } } #[cfg(test)] mod tests { use super::*; + use vectorcraft_engine::Session; + + fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app + } + + fn frame(app: &mut VectorcraftApp, ctx: &egui::Context) { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + show(app, ui); + menu(app, ui); + }); + out.textures_delta.clear(); + } #[test] - fn variations() { - let c = Color::rgb(0.5, 0.5, 0.5); - assert_eq!(variation(&c, Variation::TintsShades, 0), c); - assert!(variation(&c, Variation::TintsShades, STEPS).to_rgb()[0] > 0.5); - assert!(variation(&c, Variation::TintsShades, -STEPS).to_rgb()[0] < 0.5); - let red = Color::rgb(1.0, 0.2, 0.2); - assert!(variation(&red, Variation::VividMuted, -2).to_hsb()[1] < red.to_hsb()[1]); - let warm = variation(&c, Variation::WarmCool, 2).to_rgb(); - assert!(warm[0] > warm[2]); + fn base_stays_until_set_and_the_grid_follows_the_options() { + let mut app = app(); + app.run("paint.setFill", json!({"color": "#3366cc"})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx); + assert_eq!(base_color(&app, &ctx).to_hex(), "#3366cc", "the first show takes the current colour"); + // Another current colour leaves the base alone. + app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap(); + frame(&mut app, &ctx); + assert_eq!(base_color(&app, &ctx).to_hex(), "#3366cc"); + app.ui.color_guide.steps = 6; + frame(&mut app, &ctx); + let g = guide(&app, &ctx); + assert_eq!(g.grid[0].len(), 13); + assert_eq!(g.grid[0][6].to_hex(), "#3366cc", "the base sits in the centre of the first row"); + } + + #[test] + fn save_colors_as_swatches_saves_each_selected_cell() { + let mut app = app(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx); + let before = app.session.doc().unwrap().doc.swatches.len(); + set_pstate(&ctx, "cg-sel", vec![(0usize, 0usize), (1, 4), (0, 8)]); + let undo = app.session.doc().unwrap().history.undo.len(); + save_selected(&mut app, &ctx); + let st = app.session.doc().unwrap(); + assert_eq!(st.doc.swatches.len(), before + 3); + assert_eq!(st.history.undo.len(), undo + 1, "one undo step"); + let g = guide(&app, &ctx); + assert_eq!(st.doc.swatches.last().unwrap().paint.color().map(|c| c.to_hex()), Some(g.grid[0][8].to_hex())); } } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 5cdbbcdd..3ccc56c0 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -246,6 +246,9 @@ pub struct UiState { /// Engine preferences (Edit → Preferences), persisted alongside the UI state. #[serde(default)] pub engine_prefs: Value, + /// Color Guide panel: variation kind, steps and amount (Color Guide Options). + #[serde(default)] + pub color_guide: vectorcraft_color::harmony::GuideOptions, } impl UiState { @@ -297,6 +300,7 @@ impl Default for UiState { recent_files: vec![], recent_fonts: vec![], engine_prefs: Value::Null, + color_guide: Default::default(), } } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 5369c64a..f648f91c 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -64,3 +64,16 @@ value: true}` then confirm opens a fresh one that adds another, `ui.dialog.cance Overprint Black: the Edit → Edit Colors → Overprint Black… menu item opens a `command` parameter dialog for `edit.colors.overprintBlack` (fields `remove`, `percentage`, `fill`, `stroke`, `includeCmyBlacks`, `includeSpotBlacks`); `ui.dialog.set` then `ui.dialog.confirm` runs it on the selection. + +Color Guide: `color.harmony {color, rule, steps?, variation?, amount?}` returns the harmony group (base first) and +its variation grid; the panel draws the same grid. `ui.colorGuideOptions` opens the `colorGuideOptions` dialog +(fields `steps` 1–20, `amount` 0–100) and `ui.dialog.confirm` sets the panel's options, which `ui.inspect` reports +as `ui.color_guide` (`variation`, `steps`, `amount`). Save Colors as Swatches runs `swatch.new {colors: [...]}` +(one swatch per colour, one undo step). + +Edit Colors dialogs: `ui.colorBalanceDialog` opens Adjust Colors (`colorBalance`: fields `mode` `gray`/`rgb`/`cmyk`/ +`global`, the channels `r` `g` `b` / `c` `m` `y` `k` / `gray` from −100 to 100, `convert`, `fill`, `stroke`, +`preview`) and `ui.saturateDialog` opens Saturate (`saturate`: `intensity` −100..100, `preview`). Both preview on the +canvas while open; `ui.dialog.confirm` keeps the result as one undo step (`edit.colors.adjustBalance` / +`edit.colors.saturate`) and `ui.dialog.cancel` rolls it back. Global mode answers with an error until tints of global +and spot colours exist; the dialog then stays open. diff --git a/docs/mcp.md b/docs/mcp.md index c429fe9d..102d6b40 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -104,6 +104,9 @@ Document colour mode: `file.new {colorMode: "cmyk"}` starts a CMYK document with colours applied to it (`paint.setFill`/`setStroke` colours and gradient stops, `swatch.new`) are stored as CMYK; Gray stays Gray, and `keepModel: true` keeps a colour as given. RGB documents keep colours as given. Harmonies, Edit Colors blends, inversions and Recolor Artwork keep each colour's model (a blend between models takes the document's). +`swatch.new {colors: [...]}` saves several colours as swatches in one undo step. `color.harmony {color, rule, steps?, variation?, amount?}` answers what the Color +Guide panel shows: the harmony rule's colours, base first, and per colour its row of `2·steps+1` variations +(shades, cool or muted on the left, tints, warm or vivid on the right). ## Resources @@ -256,3 +259,18 @@ it by multiplying. Older files that listed Overprint Black objects in the docume {"name":"run_command","arguments":{"command":"attributes.info","params":{"ids":[12]}}} {"name":"run_command","arguments":{"command":"view.overprintPreview","params":{"on":true}}} ``` + +## Edit Colors and Recolor Artwork + +`edit.colors.invert`, `edit.colors.toCMYK`, `edit.colors.toGrayscale`, `edit.colors.toRGB`, `edit.colors.saturate`, +`edit.colors.adjustBalance` and `recolor.apply` recolour everything inside the selection (or `ids`) in one undo step: +fills, strokes, text, gradient stops, gradient-mesh points, embedded images (a recoloured copy of the image; linked +images are left alone) and the tiles of pattern fills and strokes (a recoloured copy saved as a new pattern swatch, +e.g. "Dots 2"; the original pattern is untouched). `includeImages: false` / `includePatterns: false` leave images or +patterns out. The Blend commands also grade gradient meshes, which keep their shading. + +```json +{"name":"run_command","arguments":{"command":"recolor.colors","params":{}}} +{"name":"run_command","arguments":{"command":"recolor.apply","params":{"map":{"#ff0000":"#0055ff"},"includeImages":false}}} +{"name":"run_command","arguments":{"command":"edit.colors.adjustBalance","params":{"mode":"cmyk","m":-20,"k":10}}} +```