Recolor Artwork: color sliders edit the selected row's new color in a chosen mode

The Assign tab gets a Color Mode menu (HSB, RGB, CMYK, Lab, and Tint when
the row's new color is a spot color of the Limit to Library library) and
its sliders under the rows. They edit the first selected row's new color
and are disabled with no row selected. Once they have edited a color in a
mode, the preview and OK send recolor.apply with model "new", so the art
takes the color in that mode.

recolor.apply rows take an optional tint (percent, default 100): art linked
to the row's global or spot library swatch takes that share of its tint.
recolor.randomize keeps a row's tint.
This commit is contained in:
dotCooCoo
2026-10-31 21:42:42 -07:00
parent 58e98510d1
commit 46cb088eae
7 changed files with 418 additions and 26 deletions
+25 -9
View File
@@ -33,7 +33,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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 (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), 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; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model)}",
"{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), tint?: 100 (percent; with limitTo, art linked to the global or spot swatch the new color is takes this share of its tint)}] | {\"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 (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), 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; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model)}",
has_doc,
apply
),
@@ -260,6 +260,9 @@ struct Row {
from: Vec<Match>,
to: Option<Color>,
exclude: bool,
/// The tint (0..1) of a global or spot swatch the new color is (with `limitTo`): art linked to
/// the swatch takes this share of the tint the method gives it.
tint: f32,
}
impl Row {
@@ -280,7 +283,9 @@ fn parse_rows(p: &Value, cmd: &str) -> Result<Vec<Row>> {
};
let cur = |v: &Value| Match::of(v).ok_or_else(|| bad(cmd, format!("bad current colour {v}")));
match p.get("map") {
Some(Value::Object(m)) => m.iter().map(|(k, v)| Ok(Row { from: vec![cur(&json!(k))?], to: new(Some(v))?, exclude: false })).collect(),
Some(Value::Object(m)) => {
m.iter().map(|(k, v)| Ok(Row { from: vec![cur(&json!(k))?], to: new(Some(v))?, exclude: false, tint: 1.0 })).collect()
}
Some(Value::Array(rows)) => rows
.iter()
.map(|r| {
@@ -289,7 +294,8 @@ fn parse_rows(p: &Value, cmd: &str) -> Result<Vec<Row>> {
Some(v) => vec![cur(v)?],
None => return Err(bad(cmd, "each row needs `from`")),
};
Ok(Row { from, to: new(r.get("to"))?, exclude: switch_or(r, "exclude", false, cmd)? })
let tint = (super::number_or(r, "tint", 100.0, cmd)? / 100.0).clamp(0.0, 1.0) as f32;
Ok(Row { from, to: new(r.get("to"))?, exclude: switch_or(r, "exclude", false, cmd)?, tint })
})
.collect(),
_ => Err(bad(cmd, "missing `map` (rows [{from, to}] or {current: new})")),
@@ -303,6 +309,8 @@ struct Link {
name: String,
global: bool,
color: Color,
/// The row's tint of the swatch (0..1).
tint: f32,
}
/// A global swatch of the color group `recolor.apply` rewrites: its name, the color it is
@@ -338,9 +346,12 @@ impl ColorMap {
let Some(to) = r.new_color() else { continue };
let i = out.rows.len();
let colors: Vec<Color> = r.from.iter().map(Match::color).collect();
out.rows.push(RowMap::new(method, &colors, to).with_own_model(own_model));
let link = links.get(n).cloned().flatten();
// Colors that can't link to the swatch take the row's tint of it too.
let mapped = link.as_ref().filter(|l| l.global && l.tint < 1.0).map_or(to, |l| to.tinted(l.tint));
out.rows.push(RowMap::new(method, &colors, mapped).with_own_model(own_model));
out.tos.push(to);
out.links.push(links.get(n).cloned().flatten());
out.links.push(link);
for m in &r.from {
match m {
Match::Key(k) => out.keys.entry(*k).or_insert(i),
@@ -365,7 +376,8 @@ impl ColorMap {
}
/// 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
/// swatch links its colours to it (at their tints times the row's tint; shades and other hues
/// stay unlinked), a
/// process swatch gives its colour in its own model; a color linked to a global swatch of the
/// rewritten group that takes the row's new color keeps its link (at its tint in the row) when
/// the row gives it the new color in that color's model;
@@ -388,7 +400,7 @@ impl ColorMap {
}
let new = match (l, row.tint(*c)) {
(Some(l), Some(t)) if l.global => {
let t = t.clamp(0.0, 1.0);
let t = (t * l.tint).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);
@@ -487,7 +499,8 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
let mut added = vec![];
let links: Vec<Option<Link>> = row_swatches
.iter()
.map(|w| {
.zip(&rows)
.map(|(w, row)| {
let w = w.as_ref()?;
let name = match d.swatch(&w.name) {
Some(x) if x.paint == w.paint => w.name.clone(),
@@ -499,7 +512,7 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
}
};
let color = d.global_color(&name).filter(|_| w.global).or(w.paint.color())?;
Some(Link { name, global: w.global, color })
Some(Link { name, global: w.global, color, tint: row.tint })
})
.collect();
// The group's global swatches that its rewrite gives a color: art linked to one of them
@@ -558,6 +571,9 @@ fn randomize(_: &mut Session, p: &Value) -> Result<Value> {
if r.exclude {
row["exclude"] = json!(true);
}
if r.tint < 1.0 {
row["tint"] = json!(f64::from((r.tint * 10_000.0).round()) / 100.0);
}
row
})
.collect();
+27
View File
@@ -537,6 +537,33 @@ fn recolor_to_a_library_spot_colour_links_it_and_adds_the_swatch() {
assert_eq!(r["swatches"], json!(["Leaf"]));
}
/// A row's `tint` gives the art linked to the row's spot color that share of its tint;
/// randomize keeps it on the row, and a tint that is not a number is refused.
#[test]
fn a_rows_tint_tints_the_spot_color_the_art_links_to() {
let mut s = session();
run(&mut s, "swatch.new", json!({"name": "Ink 101", "color": {"c": 0.0, "m": 1.0, "y": 0.0, "k": 0.0}, "spot": true}));
let data = run(&mut s, "swatch.library.save", json!({"names": ["Ink 101"], "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"]}));
let red = rect(&mut s, json!("#e02010"));
select_all(&mut s);
let map = json!([{"from": ["rgb 224 32 16"], "to": "cmyk 0 100 0 0", "tint": 40}]);
run(&mut s, "recolor.apply", json!({"map": map, "limitTo": lib}));
match s.doc().unwrap().doc.node(red).unwrap().appearance.fill_paint() {
vectorcraft_color::Paint::Solid { color, swatch, tint } => {
assert_eq!(swatch.as_deref(), Some("Ink 101"), "linked to the spot color");
assert!((tint - 0.4).abs() < 1e-4, "at the row's tint: {tint}");
assert!(close(color, Color::cmyk(0.0, 1.0, 0.0, 0.0).tinted(0.4)), "{color:?}");
}
p => panic!("{p:?}"),
}
let r = run(&mut s, "recolor.randomize", json!({"map": map, "order": false, "saturationBrightness": false}));
assert_eq!(r["map"][0]["tint"], json!(40.0), "{r}");
let e = s.execute("recolor.apply", &json!({"map": [{"from": ["rgb 224 32 16"], "to": "#ff0000", "tint": "40"}]})).unwrap_err();
assert!(e.to_string().contains("`tint` must be a number"), "{e}");
}
#[test]
fn model_param_chooses_the_model_of_the_new_colour() {
// RGB art in a CMYK document: by default a CMYK replacement comes out RGB (#2170).