diff --git a/crates/color/src/harmony.rs b/crates/color/src/harmony.rs index 01336d20..fe170d8f 100644 --- a/crates/color/src/harmony.rs +++ b/crates/color/src/harmony.rs @@ -1,6 +1,6 @@ //! Colour harmony rules (the Color Guide panel). -use crate::Color; +use crate::{Color, keep_model}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Harmony { @@ -40,10 +40,10 @@ impl Harmony { Harmony::Shades => "Shades", } } - /// Colours of the harmony, base colour first. + /// Colours of the harmony, base colour first, in the base colour's model. pub fn apply(self, base: Color) -> Vec { let [h, s, v] = base.to_hsb(); - let hue = |d: f32| Color::from_hsb(h + d, s, v); + let hue = |d: f32| keep_model(base, Color::from_hsb(h + d, s, v)); match self { Harmony::Complementary => vec![base, hue(180.0)], Harmony::SplitComplementary => vec![base, hue(150.0), hue(210.0)], @@ -53,18 +53,19 @@ impl Harmony { 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| Color::from_hsb(h, s, (v * (1.0 - i as f32 * 0.18)).max(0.0))).collect(), + 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(), } } } -/// Tints and shades row for the Color Guide variation grid. +/// 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); - if i < steps / 2 { base.lerp(&Color::BLACK, 1.0 - t * 2.0) } else { base.lerp(&Color::WHITE, (t - 0.5) * 2.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() } @@ -80,4 +81,15 @@ mod tests { 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); } + + #[test] + fn harmonies_keep_the_base_model() { + let base = Color::cmyk(0.0, 1.0, 1.0, 0.0); + for h in Harmony::ALL { + assert!(h.apply(base).iter().all(|c| matches!(c, Color::Cmyk { .. })), "{}", h.label()); + } + 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 { .. }))); + } } diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index a115f88e..a3dc3692 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -205,6 +205,36 @@ impl Color { let b = other.to_rgb(); Color::rgb(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t) } + /// The colour model this colour is authored in. + pub fn model(&self) -> cms::Model { + match self { + Color::Rgb { .. } => cms::Model::Rgb, + Color::Cmyk { .. } => cms::Model::Cmyk, + Color::Gray { .. } => cms::Model::Gray, + } + } + /// This colour expressed in `model` with the profile-free formulas Edit Colors uses (RGB from + /// the display colour, CMYK by [`Color::to_cmyk`], Gray as ink from luminance); unchanged when it + /// is in `model` already. + pub fn in_model(self, model: cms::Model) -> Color { + if self.model() == model { + return self; + } + match model { + cms::Model::Rgb => { + let [r, g, b] = self.to_rgb(); + Color::rgb(r, g, b) + } + cms::Model::Cmyk => { + let [c, m, y, k] = self.to_cmyk(); + Color::cmyk(c, m, y, k) + } + cms::Model::Gray => { + let [r, g, b] = self.to_rgb(); + Color::gray((1.0 - (0.299 * r + 0.587 * g + 0.114 * b)).clamp(0.0, 1.0)) + } + } + } pub fn model_name(&self) -> &'static str { match self { Color::Rgb { .. } => "RGB", @@ -214,6 +244,12 @@ impl Color { } } +/// `new` expressed in the colour model of `orig` ([`Color::in_model`]): results of colour operations +/// (harmonies, blends, inversions, recolouring) keep the model of the colour they came from. +pub fn keep_model(orig: Color, new: Color) -> Color { + new.in_model(orig.model()) +} + /// What fills or strokes an object. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "lowercase")] @@ -322,6 +358,19 @@ mod tests { assert_eq!(Color::rgb(1.0, 1.0, 0.0).invert_keep_model(), Color::rgb(0.0, 0.0, 1.0)); } + #[test] + fn colours_convert_between_models_and_keep_them() { + use cms::Model; + let red = Color::rgb(1.0, 0.0, 0.0); + assert_eq!(red.in_model(Model::Cmyk), Color::cmyk(0.0, 1.0, 1.0, 0.0)); + assert_eq!(red.in_model(Model::Rgb), red, "already RGB: unchanged"); + assert_eq!(Color::gray(0.25).in_model(Model::Gray), Color::gray(0.25)); + assert_eq!(Color::WHITE.in_model(Model::Gray), Color::gray(0.0)); + assert_eq!(keep_model(Color::cmyk(0.1, 0.2, 0.3, 0.0), red).model(), Model::Cmyk); + assert_eq!(keep_model(Color::gray(0.5), red).model(), Model::Gray); + assert_eq!(keep_model(red, Color::rgb(0.0, 0.5, 1.0)), Color::rgb(0.0, 0.5, 1.0)); + } + #[test] fn paint_serde() { let p = Paint::solid(Color::rgb8(10, 20, 30)); diff --git a/crates/color/src/swatch.rs b/crates/color/src/swatch.rs index 8423371a..e87b5555 100644 --- a/crates/color/src/swatch.rs +++ b/crates/color/src/swatch.rs @@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize}; +use crate::cms::Model; use crate::{Color, Gradient, GradientKind, GradientStop, Paint}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -26,9 +27,33 @@ fn solid(name: &str, hex: &str) -> Swatch { Swatch { name: name.into(), paint: Paint::solid(Color::from_hex(hex).unwrap()), global: false, spot: false } } +/// The default swatches of a document in colour `model`: [`base_swatches`] with their RGB colours +/// (gradient stops too) as whole-percent CMYK in a CMYK document. Greys stay Gray. +pub fn default_swatches(model: Model) -> (Vec, Vec) { + let (mut s, mut g) = base_swatches(); + if model == Model::Cmyk { + let cmyk = |c: &mut Color| { + if let Color::Rgb { .. } = c { + let pct = |v: f32| (v.clamp(0.0, 1.0) * 100.0).round() / 100.0; + let [cc, m, y, k] = c.to_cmyk(); + *c = Color::cmyk(pct(cc), pct(m), pct(y), pct(k)); + } + }; + for w in s.iter_mut().chain(g.iter_mut().flat_map(|g| g.swatches.iter_mut())) { + match &mut w.paint { + Paint::Solid { color, .. } => cmyk(color), + Paint::Gradient(gp) => gp.gradient.stops.iter_mut().for_each(|st| cmyk(&mut st.color)), + _ => {} + } + } + } + (s, g) +} + /// The default document swatches: None, Registration-like black, white, black, a spectrum, greys, -/// gradients. (Composition is ours; Illustrator's layout grammar — None first, then specials — is kept.) -pub fn default_swatches() -> (Vec, Vec) { +/// gradients. (The composition is ours; the reference app's layout grammar — None first, then +/// specials — is kept.) +fn base_swatches() -> (Vec, Vec) { let mut s = vec![Swatch { name: "[None]".into(), paint: Paint::None, global: false, spot: false }, solid("White", "#ffffff"), solid("Black", "#000000")]; let spectrum = [ @@ -110,10 +135,28 @@ mod tests { #[test] fn defaults_start_with_none() { - let (s, g) = default_swatches(); + let (s, g) = default_swatches(Model::Rgb); assert!(s[0].paint.is_none()); assert!(s.len() > 30); assert_eq!(g.len(), 2); assert_eq!(g[0].swatches.len(), 9); } + + #[test] + fn cmyk_defaults_are_cmyk_with_the_same_names() { + let (rgb, _) = default_swatches(Model::Rgb); + let (s, g) = default_swatches(Model::Cmyk); + assert_eq!(s.iter().map(|w| &w.name).collect::>(), rgb.iter().map(|w| &w.name).collect::>()); + let colors = s.iter().chain(g.iter().flat_map(|g| &g.swatches)).flat_map(|w| match &w.paint { + Paint::Solid { color, .. } => vec![*color], + Paint::Gradient(gp) => gp.gradient.stops.iter().map(|st| st.color).collect(), + _ => vec![], + }); + assert!(colors.clone().all(|c| !matches!(c, Color::Rgb { .. }))); + assert!(colors.clone().any(|c| matches!(c, Color::Gray { .. })), "greys stay Gray"); + let white = s.iter().find(|w| w.name == "White").and_then(|w| w.paint.color()); + assert_eq!(white, Some(Color::cmyk(0.0, 0.0, 0.0, 0.0))); + let black = s.iter().find(|w| w.name == "Black").and_then(|w| w.paint.color()); + assert_eq!(black, Some(Color::cmyk(0.0, 0.0, 0.0, 1.0))); + } } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index b242ae03..998627d5 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -206,6 +206,16 @@ pub enum ColorMode { Cmyk, } +impl ColorMode { + /// The colour model new colours take in a document of this mode. + pub fn model(self) -> vectorcraft_color::cms::Model { + match self { + ColorMode::Rgb => vectorcraft_color::cms::Model::Rgb, + ColorMode::Cmyk => vectorcraft_color::cms::Model::Cmyk, + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Artboard { pub id: u32, @@ -342,15 +352,20 @@ fn ppi72() -> f64 { pub const FORMAT_VERSION: u32 = 1; impl Document { - /// A new document with one artboard of `size` and one layer ("Layer 1"). + /// A new RGB document with one artboard of `size` and one layer ("Layer 1"). pub fn new(width: f64, height: f64) -> Self { - let (swatches, swatch_groups) = vectorcraft_color::default_swatches(); + Self::new_with_mode(width, height, ColorMode::Rgb) + } + + /// [`Document::new`] in colour `mode`, with that mode's default swatches. + pub fn new_with_mode(width: f64, height: f64, mode: ColorMode) -> Self { + let (swatches, swatch_groups) = vectorcraft_color::default_swatches(mode.model()); let mut d = Self { version: FORMAT_VERSION, title: "Untitled-1".into(), template: false, units: Unit::Points, - color_mode: ColorMode::Rgb, + color_mode: mode, artboards: vec![Artboard { id: 1, name: "Artboard 1".into(), diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index 632c49e8..630bef24 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -6,7 +6,8 @@ use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_color::{Color, Paint}; +use vectorcraft_color::cms::Model; +use vectorcraft_color::{Color, Paint, keep_model}; use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind}; use super::edit::selected_roots; @@ -100,29 +101,12 @@ pub(crate) fn invert(c: Color) -> Color { c.invert_keep_model() } -/// Express `new` in the colour model of `orig`. -fn keep_model(orig: Color, new: Color) -> Color { - match orig { - Color::Rgb { .. } => { - let [r, g, b] = new.to_rgb(); - Color::rgb(r, g, b) - } - Color::Cmyk { .. } => to_cmyk(new), - Color::Gray { .. } => to_gray(new), - } -} - pub(crate) fn to_cmyk(c: Color) -> Color { - let [c, m, y, k] = c.to_cmyk(); - Color::cmyk(c, m, y, k) + c.in_model(Model::Cmyk) } pub(crate) fn to_gray(c: Color) -> Color { - if let Color::Gray { .. } = c { - return c; - } - let [r, g, b] = c.to_rgb(); - Color::gray((1.0 - (0.299 * r + 0.587 * g + 0.114 * b)).clamp(0.0, 1.0)) + c.in_model(Model::Gray) } /// Apply `f` to a paint; returns whether anything changed. @@ -318,6 +302,8 @@ fn blend(s: &mut Session, order: BlendOrder) -> Result { } let first = d.node(ids[0]).and_then(fill_color).unwrap_or_default(); let last = d.node(*ids.last().unwrap()).and_then(fill_color).unwrap_or_default(); + // Ends in one model blend in it; mixed ones give colours in the document's model. + let model = if first.model() == last.model() { first.model() } else { d.color_mode.model() }; let n = ids.len(); let label = match order { BlendOrder::Stack => "Blend Front to Back", @@ -327,7 +313,7 @@ fn blend(s: &mut Session, order: BlendOrder) -> Result { 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); + 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); } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 4f269465..02ba945f 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "New…", ["File"], Some("Cmd+N"), - "{width?: pt=612, height?: pt=792, units?: \"Points\"|\"Inches\"|\"Millimeters\"|\"Pixels\"…, title?, artboards?: n, colorMode?: \"rgb\"|\"cmyk\"}", + "{width?: pt=612, height?: pt=792, units?: \"Points\"|\"Inches\"|\"Millimeters\"|\"Pixels\"…, title?, artboards?: n, colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK)}", always, file_new ), @@ -58,14 +58,15 @@ fn file_new(s: &mut Session, p: &Value) -> Result { if !(w > 0.0 && h > 0.0) || w > 16383.0 * 10.0 || h > 16383.0 * 10.0 { return Err(bad("file.new", "width/height must be positive and within the canvas")); } - let mut d = Document::new(w, h); + let mode = match str_param(p, "colorMode") { + Some(m) if m.eq_ignore_ascii_case("cmyk") => vectorcraft_doc::ColorMode::Cmyk, + _ => vectorcraft_doc::ColorMode::Rgb, + }; + let mut d = Document::new_with_mode(w, h, mode); d.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| s.next_untitled()); if let Some(u) = str_param(p, "units") { d.units = parse_unit(u).ok_or_else(|| bad("file.new", format!("unknown units `{u}`")))?; } - if str_param(p, "colorMode").is_some_and(|m| m.eq_ignore_ascii_case("cmyk")) { - d.color_mode = vectorcraft_doc::ColorMode::Cmyk; - } let n = p.get("artboards").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000) as usize; for i in 1..n { let r = vectorcraft_geom::Rect::new(0.0, 0.0, w, h) + Vec2::new(i as f64 * (w + 20.0), 0.0); diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index a7e27132..32f6834a 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name (a gradient swatch fits each object, keeping its aspect), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy)} sets the selection's fill and the default (new art fits a gradient to itself)", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name (a gradient swatch fits each object, keeping its aspect), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself)", has_doc, |s, p| set_paint(s, p, true) ), @@ -261,8 +261,21 @@ fn stroke_param(s: &Session, p: &Value) -> bool { p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active) } -/// Parse a paint from params (color / none / swatch / gradient). None = no paint keys given. +/// The model RGB colours given in `p` are stored in: the document's in a CMYK document (`None` in +/// an RGB one, or with `keepModel`). +fn new_color_model(s: &Session, p: &Value) -> Option { + let mode = s.active()?.doc.color_mode; + (mode != vectorcraft_doc::ColorMode::Rgb && !bool_or(p, "keepModel", false)).then(|| mode.model()) +} + +/// Parse a paint from params (color / none / swatch / gradient). None = no paint keys given. A +/// colour or gradient given in RGB takes the document's colour model ([`new_color_model`]). pub(crate) fn paint_from(s: &Session, p: &Value) -> Result> { + let model = new_color_model(s, p); + let new_color = |c: Color| match (c, model) { + (Color::Rgb { .. }, Some(m)) => c.in_model(m), + _ => c, + }; if bool_or(p, "none", false) { return Ok(Some(Paint::None)); } @@ -282,12 +295,13 @@ pub(crate) fn paint_from(s: &Session, p: &Value) -> Result> { return Ok(Some(paint)); } if let Some(g) = p.get("gradient") { - let gp = super::gradient::parse_gradient(g).map_err(|e| bad("paint", e))?; + let mut gp = super::gradient::parse_gradient(g).map_err(|e| bad("paint", e))?; + gp.gradient.stops.iter_mut().for_each(|st| st.color = new_color(st.color)); return Ok(Some(Paint::Gradient(Box::new(gp)))); } if let Some(c) = p.get("color") { let c = color_value(c).ok_or_else(|| bad("paint", format!("bad color {c}")))?; - return Ok(Some(Paint::solid(c))); + return Ok(Some(Paint::solid(new_color(c)))); } Ok(None) } diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index 2ab32e3a..d76f9b1e 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -3,7 +3,7 @@ use std::collections::BTreeMap; use serde_json::{Value, json}; -use vectorcraft_color::{Color, Paint}; +use vectorcraft_color::{Color, Paint, keep_model}; use vectorcraft_doc::{AppearanceItem, Node, NodeKind}; use super::*; @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "Recolor Artwork", ["Edit", "Edit Colors"], None, - "{map: {\"#rrggbb\": \"#rrggbb\", …}} replace colours across the selection (gradients and text included)", + "{map: {\"#rrggbb\": \"#rrggbb\", …}} replace colours across the selection (gradients and text included); each new colour keeps the model of the one it replaces", has_selection, apply ), @@ -81,7 +81,7 @@ fn apply(s: &mut Session, p: &Value) -> Result { if let Some(n) = d.node_mut(*id) { visit_paints(n, &mut |c| { if let Some(to) = map.get(&c.to_hex()) { - *c = *to; + *c = keep_model(*c, *to); changed += 1; } }); diff --git a/crates/engine/src/tests_swatchcmds.rs b/crates/engine/src/tests_swatchcmds.rs index 457bc3f8..96ce2936 100644 --- a/crates/engine/src/tests_swatchcmds.rs +++ b/crates/engine/src/tests_swatchcmds.rs @@ -191,3 +191,53 @@ fn ungroup_and_sort_by_kind_are_one_undo_step_each() { assert_eq!((n[0].as_str(), n[1].as_str()), ("[None]", "White"), "the order within a kind is kept"); assert_eq!(n[n.iter().position(|x| x == "Orange").unwrap() + 1], "Ocean", "the spot colour comes after the process ones, then the gradients"); } + +#[test] +fn a_cmyk_document_starts_with_cmyk_swatches_and_stores_new_colours_as_cmyk() { + use vectorcraft_color::Color; + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 100, "height": 100, "colorMode": "cmyk"})); + assert_eq!(doc(&s).color_mode, vectorcraft_doc::ColorMode::Cmyk); + assert!(doc(&s).swatches_iter().filter_map(|w| w.paint.color()).all(|c| !matches!(c, Color::Rgb { .. }))); + assert_eq!(doc(&s).swatch("Black").and_then(|w| w.paint.color()), Some(Color::cmyk(0.0, 0.0, 0.0, 1.0))); + let a = rect_filled(&mut s, json!({"color": "#ff0000"})); + assert_eq!(fill(&s, a), Paint::solid(Color::cmyk(0.0, 1.0, 1.0, 0.0))); + // Gray stays Gray; keepModel keeps RGB; gradient stops follow the document too. + run(&mut s, "paint.setFill", json!({"ids": [a.0], "color": {"gray": 0.4}})); + assert_eq!(fill(&s, a), Paint::solid(Color::gray(0.4))); + run(&mut s, "paint.setFill", json!({"ids": [a.0], "color": "#ff0000", "keepModel": true})); + assert_eq!(fill(&s, a), Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + run( + &mut s, + "paint.setFill", + json!({"ids": [a.0], "gradient": {"stops": [{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#0000ff"}]}}), + ); + let Paint::Gradient(g) = fill(&s, a) else { panic!("a gradient") }; + assert!(g.gradient.stops.iter().all(|st| matches!(st.color, Color::Cmyk { .. }))); + // A swatch made from a hex colour is CMYK as well; the document round-trips natively. + let name = run(&mut s, "swatch.new", json!({"color": "#00ff00"}))["name"].clone(); + assert_eq!(name, "C=100 M=0 Y=100 K=0"); + let json = serde_json::to_string(doc(&s)).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), *doc(&s)); +} + +#[test] +fn rgb_documents_keep_colours_as_given_and_edits_keep_the_source_model() { + use vectorcraft_color::Color; + let mut s = session(); + assert_eq!(doc(&s).swatch("Black").and_then(|w| w.paint.color()), Some(Color::rgb(0.0, 0.0, 0.0))); + let a = rect_filled(&mut s, json!({"color": "#ff0000"})); + assert_eq!(fill(&s, a), Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + let b = rect_filled(&mut s, json!({"color": {"c": 0.0, "m": 1.0, "y": 0.0, "k": 0.0}})); + assert_eq!(fill(&s, b), Paint::solid(Color::cmyk(0.0, 1.0, 0.0, 0.0)), "an RGB document doesn't convert CMYK either"); + // Recolor Artwork keeps the replaced colour's model. + run(&mut s, "select.set", json!({"ids": [b.0]})); + let hex = Color::cmyk(0.0, 1.0, 0.0, 0.0).to_hex(); + run(&mut s, "recolor.apply", json!({"map": {hex: "#00ff00"}})); + assert!(matches!(fill(&s, b).color(), Some(Color::Cmyk { .. }))); + // Blending ends of different models gives the document's model. + let c = rect_filled(&mut s, json!({"color": "#808080"})); + run(&mut s, "select.set", json!({"ids": [a.0, c.0, b.0]})); + run(&mut s, "edit.colors.blendFrontToBack", json!({})); + assert!(matches!(fill(&s, c).color(), Some(Color::Rgb { .. }))); +} diff --git a/crates/ui-egui/src/panels/color.rs b/crates/ui-egui/src/panels/color.rs index 07903388..1d10c87e 100644 --- a/crates/ui-egui/src/panels/color.rs +++ b/crates/ui-egui/src/panels/color.rs @@ -304,7 +304,8 @@ fn apply(app: &mut VectorcraftApp, tgt: &Target, c: Color, phase: Live, behind: } _ => { let cmd = super::proxy_cmd(app, behind); - live_run(app, "Color", cmd, json!({"color": color_json(&c), "focus": !behind}), phase); + // The panel's sliders work in the model picked there, even in a CMYK document. + live_run(app, "Color", cmd, json!({"color": color_json(&c), "focus": !behind, "keepModel": true}), phase); } } } diff --git a/docs/mcp.md b/docs/mcp.md index 2f74c42e..5b95c4c9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -100,6 +100,10 @@ in the app runs `paint.setFill` (or `paint.setStroke`, the active proxy) with `i The Swatches panel menu's commands: `swatch.addUsedColors {selection?, global?}`, `swatch.unused` (a query: the names Select All Unused selects), `swatch.merge {names}` (the first is kept), `swatch.ungroup {name}` and `swatch.sortByKind`. +Document colour mode: `file.new {colorMode: "cmyk"}` starts a CMYK document with CMYK default swatches, and RGB +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). ## Resources