M3.32: the document colour mode governs new colours

A CMYK document (file.new {colorMode: "cmyk"}, Document::new_with_mode)
starts with CMYK default swatches (default_swatches(model)), and RGB
colours and gradient stops applied to it are stored as CMYK (Gray stays
Gray) unless paint.setFill/setStroke get keepModel; the Color panel keeps
the model picked on its sliders. RGB documents keep colours as given.
Harmonies and Color Guide variations keep the base colour's model, Recolor
Artwork the replaced colour's, and Edit Colors blends between models take
the document's, through a shared Color::in_model / keep_model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-04 23:10:15 +07:00
parent bdf8f704af
commit f48ba26380
11 changed files with 221 additions and 46 deletions

View File

@ -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<Color> {
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<Color> {
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 { .. })));
}
}

View File

@ -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));

View File

@ -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<Swatch>, Vec<SwatchGroup>) {
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<Swatch>, Vec<SwatchGroup>) {
/// gradients. (The composition is ours; the reference app's layout grammar — None first, then
/// specials — is kept.)
fn base_swatches() -> (Vec<Swatch>, Vec<SwatchGroup>) {
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::<Vec<_>>(), rgb.iter().map(|w| &w.name).collect::<Vec<_>>());
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)));
}
}

View File

@ -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(),

View File

@ -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<Value> {
}
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<Value> {
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);
}

View File

@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Value> {
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);

View File

@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<vectorcraft_color::cms::Model> {
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<Option<Paint>> {
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<Option<Paint>> {
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)
}

View File

@ -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<CommandSpec> {
"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<Value> {
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;
}
});

View File

@ -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::<vectorcraft_doc::Document>(&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 { .. })));
}

View File

@ -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);
}
}
}

View File

@ -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