diff --git a/crates/color/src/recolor.rs b/crates/color/src/recolor.rs index 16ad2aa20..ef63fe845 100644 --- a/crates/color/src/recolor.rs +++ b/crates/color/src/recolor.rs @@ -282,6 +282,23 @@ impl RowMap { self.key } + /// The tint (0..1) of the new colour current colour `c` takes, when it is a tint of the new + /// colour: what a colour linked to a global or spot swatch keeps as its tint. `None` when the + /// method makes it something else (a shade, or another hue). + pub fn tint(&self, c: Color) -> Option { + match self.method { + Method::Exact => Some(1.0), + Method::PreserveTints if is_tint(c, self.key) => Some(tint_of(c, self.key)), + Method::PreserveTints => Some(1.0), + Method::ScaleTints => Some(tint_of(c, self.key)), + Method::TintsShades => { + let d = c.to_lab().l - self.mean_l; + (d >= 0.0).then(|| 1.0 - d / (100.0 - self.mean_l).max(1e-3)) + } + Method::HueShift => (c == self.key).then_some(1.0), + } + } + /// The new colour of current colour `c`, in `c`'s model. pub fn map(&self, c: Color) -> Color { let to = keep_model(c, self.to); diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index d14e5a661..6fdc10f40 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -204,6 +204,12 @@ impl<'a> Recolor<'a> { Self { links: Some(links), ..Self::new(Scope { images: false, ..scope }, &same) } } + /// A pass that maps solid colours and gradient stops with their swatch link and tint + /// (`links`), and meshes, images and patterns through `f`. + pub(crate) fn with_links(scope: Scope, f: &'a dyn Fn(Color) -> Color, links: &'a LinkMap<'a>) -> Self { + Self { links: Some(links), ..Self::new(scope, f) } + } + /// 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 { diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index 9b4d83a95..cbab99a88 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; use serde_json::{Value, json}; use vectorcraft_color::recolor::{ColorKey, Method, Preserve, Rng, RowMap, cluster}; -use vectorcraft_color::{Color, Paint}; +use vectorcraft_color::{Color, Paint, Swatch}; use vectorcraft_doc::swatches::node_colors; use vectorcraft_doc::{AppearanceItem, Document, NodeId, NodeKind}; @@ -30,7 +30,7 @@ pub fn specs() -> Vec { "Recolor Artwork", ["Edit", "Edit Colors"], None, - "{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null, exclude?: false (the row's colours are kept)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces) and the group, as one undo step → {changed}", + "{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null, exclude?: false (the row's colours are kept)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces, except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches) and the group, as one undo step → {changed, swatches: [names added to the document's swatches]}", has_doc, apply ), @@ -252,21 +252,34 @@ fn parse_rows(p: &Value, cmd: &str) -> Result> { } } -/// A parsed map ready to recolour: the row of each current colour. +/// The document swatch a row's new colour is: its name, whether it is global (art links to it, +/// with tints) and the colour art linked to it shows (a process colour's own colour). +#[derive(Clone)] +struct Link { + name: String, + global: bool, + color: Color, +} + +/// A parsed map ready to recolour: the row of each current colour, and the swatch of each row's +/// new colour. struct ColorMap { rows: Vec, + links: Vec>, keys: HashMap, hexes: HashMap, } impl ColorMap { - fn new(rows: &[Row], method: Method) -> Self { - let mut out = Self { rows: vec![], keys: HashMap::new(), hexes: HashMap::new() }; - for r in rows.iter().filter(|r| !r.from.is_empty()) { + /// `links`: the swatch of each of `rows`' new colours (as many as `rows`, or none). + fn new(rows: &[Row], method: Method, links: &[Option]) -> Self { + let mut out = Self { rows: vec![], links: vec![], keys: HashMap::new(), hexes: HashMap::new() }; + for (n, r) in rows.iter().enumerate().filter(|(_, r)| !r.from.is_empty()) { let Some(to) = r.new_color() else { continue }; let i = out.rows.len(); let colors: Vec = r.from.iter().map(Match::color).collect(); out.rows.push(RowMap::new(method, &colors, to)); + out.links.push(links.get(n).cloned().flatten()); for m in &r.from { match m { Match::Key(k) => out.keys.entry(*k).or_insert(i), @@ -277,9 +290,39 @@ impl ColorMap { out } + /// The row of current colour `c`. + fn row_of(&self, c: Color) -> Option { + self.keys.get(&ColorKey::of(&c)).or_else(|| if self.hexes.is_empty() { None } else { self.hexes.get(&c.to_hex()) }).copied() + } + fn map(&self, c: Color) -> Color { - let row = self.keys.get(&ColorKey::of(&c)).or_else(|| if self.hexes.is_empty() { None } else { self.hexes.get(&c.to_hex()) }); - row.map_or(c, |i| self.rows[*i].map(c)) + self.row_of(c).and_then(|i| self.rows.get(i)).map_or(c, |r| r.map(c)) + } + + /// Recolour a colour that can link to a swatch: a row whose new colour is a global or spot + /// swatch links its colours to it (at their tints; shades and other hues stay unlinked), a + /// process swatch gives its colour in its own model; other rows map the colour, which loses its + /// link when it changes. True when anything changed. + fn map_link(&self, c: &mut Color, link: &mut Option, tint: &mut f32) -> bool { + let Some(i) = self.row_of(*c) else { return false }; + let (Some(row), Some(l)) = (self.rows.get(i), self.links.get(i)) else { return false }; + let new = match (l, row.tint(*c)) { + (Some(l), Some(t)) if l.global => { + let t = t.clamp(0.0, 1.0); + let shown = l.color.tinted(t); + let changed = link.as_deref() != Some(l.name.as_str()) || *tint != t || *c != shown; + (*c, *link, *tint) = (shown, Some(l.name.clone()), t); + return changed; + } + (Some(l), Some(t)) => l.color.tinted(t), + _ => row.map(*c), + }; + if new == *c { + return false; + } + *c = new; + *link = None; + true } } @@ -293,13 +336,19 @@ fn apply(s: &mut Session, p: &Value) -> Result { r.to = r.to.map(|c| pl.nearest(c)); } } + // With Limit to Library, a row's new colour is a swatch of the library: the art takes that + // swatch (a spot colour stays a spot colour), which joins the document's swatches. + let lib_swatches = match str_param(p, "limitTo").filter(|k| !k.is_empty()) { + Some(k) => super::swatchlib::library_swatches(s, k).unwrap_or_default(), + None => vec![], + }; + let row_swatches: Vec> = + rows.iter().map(|r| r.new_color().and_then(|c| lib_swatches.iter().find(|w| w.paint.color() == Some(c)).cloned())).collect(); let ids = s.doc()?.selection.objects.clone(); let group = str_param(p, "group"); if ids.is_empty() && group.is_none() { return Err(bad(C, "select artwork to recolour, or give a colour `group`")); } - let map = ColorMap::new(&rows, method); - let f = |c: Color| map.map(c); let scope = Scope { fill: true, stroke: true, ..Scope::of(p) }; let label = if ids.is_empty() { "Edit Color Group" } else { "Recolor Artwork" }; let new: Vec = match p.get("groupColors").and_then(Value::as_array) { @@ -312,15 +361,38 @@ fn apply(s: &mut Session, p: &Value) -> Result { None => rows.iter().filter_map(Row::new_color).collect(), }; let rename = str_param(p, "rename"); - let (changed, edit) = s.edit(label, |d, _| { - let changed = Recolor::new(scope, &f).run(d, &ids); + let (changed, edit, added) = s.edit(label, |d, _| { + // The rows' library swatches join the document (one it has with that name and colour is + // used as it is). + let mut added = vec![]; + let links: Vec> = row_swatches + .iter() + .map(|w| { + let w = w.as_ref()?; + let name = match d.swatch(&w.name) { + Some(x) if x.paint == w.paint => w.name.clone(), + _ => { + let name = d.free_swatch_name(&w.name); + d.swatches.push(Swatch { name: name.clone(), ..w.clone() }); + added.push(name.clone()); + name + } + }; + let color = d.global_color(&name).filter(|_| w.global).or(w.paint.color())?; + Some(Link { name, global: w.global, color }) + }) + .collect(); + let map = ColorMap::new(&rows, method, &links); + let f = |c: Color| map.map(c); + let lf = |c: &mut Color, link: &mut Option, tint: &mut f32| map.map_link(c, link, tint); + let changed = Recolor::with_links(scope, &f, &lf).run(d, &ids); let edit = group.map(|g| super::swatch::rewrite_group(d, g, &new, rename, C)).transpose()?; - Ok((changed, edit)) + Ok((changed, edit, added)) })?; if let Some(e) = edit { e.defaults(s); } - Ok(json!({ "changed": changed })) + Ok(json!({ "changed": changed, "swatches": added })) } fn randomize(_: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index ddf8ccd96..3438579a2 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -393,6 +393,17 @@ pub fn library_colors(s: &Session, key: &str) -> Option> { Some(library(s, key)?.1.iter().filter_map(colors).collect()) } +/// The colour swatches of library `key` (an id or name) or, for [`DOCUMENT_SWATCHES`], of the +/// active document (tint swatches left out: they stand for their base). `None` when there is no +/// such library (or document). +pub fn library_swatches(s: &Session, key: &str) -> Option> { + let own = |sw: &&Swatch| matches!(sw.paint, Paint::Solid { swatch: None, .. }); + if key == DOCUMENT_SWATCHES { + return Some(s.active()?.doc.swatches_iter().filter(own).cloned().collect()); + } + Some(library(s, key)?.1.iter().filter(own).cloned().collect()) +} + /// The `limitTo` parameter's palette ([`limit_palette`]); `None` without one (or for ""). pub(crate) fn limit_param(s: &Session, p: &Value, cmd: &str) -> Result> { let Some(key) = str_param(p, "limitTo").filter(|k| !k.is_empty()) else { return Ok(None) }; diff --git a/crates/engine/src/tests_recolor.rs b/crates/engine/src/tests_recolor.rs index a1a3676d6..84d4cc670 100644 --- a/crates/engine/src/tests_recolor.rs +++ b/crates/engine/src/tests_recolor.rs @@ -316,3 +316,54 @@ fn cmyk_object_reads_one_scale_for_every_component() { assert!(close(k(json!({"c": 0.1, "m": 0.5, "y": 0.9, "k": 0.2})), [0.1, 0.5, 0.9, 0.2])); assert!(close(k(json!({"c": 0, "m": 1, "y": 1, "k": 0})), [0.0, 1.0, 1.0, 0.0])); } + +/// Recolouring to a library's spot colour (Limit to Library) applies the spot colour itself, linked +/// to it (tints keep their tint), and adds it to the document's swatches; a process swatch of the +/// library gives its colour in its own model. +#[test] +fn recolor_to_a_library_spot_colour_links_it_and_adds_the_swatch() { + let mut s = session(); + // A library with a Lab spot colour and a CMYK process colour (made here, then removed). + run(&mut s, "swatch.new", json!({"name": "Ink 101", "color": {"l": 50.0, "a": 70.0, "b": 40.0}, "spot": true})); + run(&mut s, "swatch.new", json!({"name": "Leaf", "color": {"c": 0.6, "m": 0.0, "y": 1.0, "k": 0.1}})); + let data = run(&mut s, "swatch.library.save", json!({"names": ["Ink 101", "Leaf"], "name": "Inks"}))["data"].as_str().unwrap().to_string(); + let lib = run(&mut s, "swatch.library.load", json!({"data": data, "name": "inks.vcswatches"}))["library"].as_str().unwrap().to_string(); + run(&mut s, "swatch.delete", json!({"names": ["Ink 101", "Leaf"]})); + let red = rect(&mut s, json!("#e02010")); + let pink = rect(&mut s, json!("#f09088")); + let green = rect(&mut s, json!("#20c040")); + select_all(&mut s); + let map = json!([ + {"from": ["rgb 224 32 16", "rgb 240 144 136"], "to": "#e02010"}, + {"from": ["rgb 32 192 64"], "to": "#20c040"}, + ]); + let r = run(&mut s, "recolor.apply", json!({"map": map, "method": "scaleTints", "limitTo": lib})); + assert_eq!(r["swatches"], json!(["Ink 101", "Leaf"]), "{r}"); + let d = &s.doc().unwrap().doc; + let ink = d.swatch("Ink 101").unwrap(); + assert!(ink.spot && ink.global, "the spot colour joined the document's swatches"); + let paint = |id: NodeId| d.node(id).unwrap().appearance.fill_paint(); + match paint(red) { + vectorcraft_color::Paint::Solid { color, swatch, tint } => { + assert_eq!(swatch.as_deref(), Some("Ink 101"), "linked to the spot colour"); + assert_eq!(tint, 1.0); + assert_eq!(color, Color::lab(50.0, 70.0, 40.0), "in the spot colour's own model"); + } + p => panic!("{p:?}"), + } + match paint(pink) { + vectorcraft_color::Paint::Solid { swatch, tint, .. } => { + assert_eq!(swatch.as_deref(), Some("Ink 101")); + assert!(tint > 0.1 && tint < 0.9, "a tint of the spot colour: {tint}"); + } + p => panic!("{p:?}"), + } + assert_eq!(paint(green), vectorcraft_color::Paint::solid(Color::cmyk(0.6, 0.0, 1.0, 0.1)), "a process swatch's colour, in CMYK"); + // Again: the document's swatch is used, not added twice. + run(&mut s, "edit.undo", json!({})); + select_all(&mut s); + run(&mut s, "swatch.library.add", json!({"library": lib, "names": ["Ink 101"]})); + select_all(&mut s); + let r = run(&mut s, "recolor.apply", json!({"map": map, "method": "scaleTints", "limitTo": lib})); + assert_eq!(r["swatches"], json!(["Leaf"])); +} diff --git a/crates/ui-egui/src/dialogs/recolor.rs b/crates/ui-egui/src/dialogs/recolor.rs index ede5546f0..95768a120 100644 --- a/crates/ui-egui/src/dialogs/recolor.rs +++ b/crates/ui-egui/src/dialogs/recolor.rs @@ -4,7 +4,9 @@ //! Assign tab: Colors (Auto, or a count: similar colours merge into rows), Preserve White, Black and //! Grays, the rows (click to select, Shift or Cmd/Ctrl-click to add; a row's arrow excludes or //! includes it; the hex field edits its new colour), Merge and Separate, Randomize order or -//! saturation and brightness, Reset, the Method and Limit to Library. Edit tab: a hue and +//! saturation and brightness, Reset, the Method and Limit to Library (its folder button imports a +//! library file and limits to it; a library's colours apply as its swatches: a spot colour stays a +//! spot colour, and joins the document's swatches). Edit tab: a hue and //! saturation wheel with a marker per new colour (drag one; with linked handles they all turn and //! scale together), a brightness slider and the harmony rules. The preset menu sets up a 1, 2 or 3 //! colour job or a library. Opened on a colour group (Edit or Apply Color Group), OK also rewrites @@ -443,9 +445,24 @@ fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut let cur = d.str("limitTo"); let at = keys.iter().position(|k| *k == cur).unwrap_or(0); let current = shown.get(at).copied().unwrap_or_default(); - if let Some(k) = widgets::dropdown_names(ui, "recolor-library", current, &shown, 180.0).and_then(|i| keys.get(i)) { - d.fields.insert("limitTo".into(), json!(k)); - } + ui.horizontal(|ui| { + if let Some(k) = widgets::dropdown_names(ui, "recolor-library", current, &shown, 180.0).and_then(|i| keys.get(i)) { + d.fields.insert("limitTo".into(), json!(k)); + } + // Import a library (.ase, .acb, .gpl, .vcswatches or a document's swatches) and limit to it. + if app.services.pick_open.is_some() && widgets::icon_button(ui, "folder-open", tl!("Other Library…"), false, 24.0).clicked() { + let r = crate::picks::in_dialog(app, d, |app, d| { + let pick = crate::FilePick { filters: vectorcraft_engine::cmd::fileio::open_filters().collect(), ..Default::default() }; + let Some(path) = crate::picks::open(app, &pick) else { return Ok(()) }; + let r = app.run("swatch.library.load", json!({ "path": path }))?; + d.fields.insert("limitTo".into(), r["library"].clone()); + Ok(()) + }); + if let Err(e) = r { + app.status(e); + } + } + }); ui.end_row(); }); changed