M3.78: Recolor Artwork with colour reduction, methods, presets and Limit to Library

Recolor Artwork keys colours by identity (model and exact values), so CMYK
colours stay CMYK and lookalikes stay apart. recolor.reduce groups the
selection's colours into rows (k-means in Lab, weighted by use; tints of a
global swatch share its row) with Preserve White/Black/Grays and Limit to
Library; recolor.apply maps rows by Exact, Preserve Tints, Scale Tints, Tints
and Shades or Hue Shift; recolor.randomize shuffles or varies new colours.
The dialog has Assign (rows with merge, separate, exclude, randomize, method,
library) and Edit (hue/saturation wheel with linked or unlinked handles,
brightness, harmony rules) tabs, and Edit Colors gains Recolor with Preset
(1/2/3 Color Job, Color Library). Colour parameters accept colour keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-05 03:11:03 +07:00
parent 0c950aae0e
commit fca068a0a7
15 changed files with 1868 additions and 107 deletions

View File

@ -11,6 +11,7 @@ pub mod gradient;
pub mod harmony;
pub mod libraries;
pub mod palette_io;
pub mod recolor;
pub mod swatch;
pub use blend::BlendMode;

586
crates/color/src/recolor.rs Normal file
View File

@ -0,0 +1,586 @@
//! Recolor Artwork's colour math (our own): colour identity keys, the White/Black/Grays preserve
//! rules, the methods that map a row of current colours to its new colour, colour reduction by
//! clustering in Lab, the nearest colour of a library and the randomizers.
use std::fmt;
use crate::cms::{Lab, delta_e2000};
use crate::{Color, keep_model};
/// A colour's identity: its model and exact components (to a hundredth of an RGB level, a percent
/// or a Lab unit), so colours that only look alike (a CMYK red and an RGB red) stay apart and keep
/// their model. Written as `rgb 255 128 0` (0–255), `cmyk 0 100 100 0`, `gray 40` (percent) or
/// `lab 55 60 -40` (L 0–100, a and b).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct ColorKey {
model: u8,
v: [i32; 4],
}
/// Key resolution: RGB components are kept to 1/RGB_Q, CMYK and Gray ones to 1/INK_Q and Lab ones
/// to 1/LAB_Q (hundredths of the written units).
const RGB_Q: f32 = 25_500.0;
const INK_Q: f32 = 10_000.0;
const LAB_Q: f32 = 100.0;
impl ColorKey {
pub fn of(c: &Color) -> Self {
let rgb = |x: f32| (x.clamp(0.0, 1.0) * RGB_Q).round() as i32;
let ink = |x: f32| (x.clamp(0.0, 1.0) * INK_Q).round() as i32;
match *c {
Color::Rgb { r, g, b } => Self { model: 0, v: [rgb(r), rgb(g), rgb(b), 0] },
Color::Cmyk { c, m, y, k } => Self { model: 1, v: [ink(c), ink(m), ink(y), ink(k)] },
Color::Gray { k } => Self { model: 2, v: [ink(k), 0, 0, 0] },
Color::Lab { l, a, b } => {
let lab = |x: f32| (x * LAB_Q).round() as i32;
Self { model: 3, v: [lab(l.clamp(0.0, 100.0)), lab(a), lab(b), 0] }
}
}
}
/// The colour this key names.
pub fn color(&self) -> Color {
let f = |i: usize| self.v[i] as f32 / INK_Q;
match self.model {
0 => Color::rgb(self.v[0] as f32 / RGB_Q, self.v[1] as f32 / RGB_Q, self.v[2] as f32 / RGB_Q),
1 => Color::cmyk(f(0), f(1), f(2), f(3)),
2 => Color::gray(f(0)),
_ => Color::lab(self.v[0] as f32 / LAB_Q, self.v[1] as f32 / LAB_Q, self.v[2] as f32 / LAB_Q),
}
}
/// Parse the written form (see [`ColorKey`]); extra spaces and case are ignored.
pub fn parse(s: &str) -> Option<Self> {
let mut it = s.split_whitespace();
let model = it.next()?.to_ascii_lowercase();
let nums: Vec<f32> = it.map(|t| t.parse::<f32>().ok().filter(|v| v.is_finite())).collect::<Option<_>>()?;
let c = match (model.as_str(), nums.as_slice()) {
("rgb", [r, g, b]) => Color::rgb(r / 255.0, g / 255.0, b / 255.0),
("cmyk", [c, m, y, k]) => Color::cmyk(c / 100.0, m / 100.0, y / 100.0, k / 100.0),
("gray", [k]) => Color::gray(k / 100.0),
("lab", [l, a, b]) => Color::lab(*l, *a, *b),
_ => return None,
};
Some(Self::of(&c))
}
}
impl fmt::Display for ColorKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// Components are hundredths of the written units: exact with two decimals.
let num = |q: i32| {
let s = format!("{}{}.{:02}", if q < 0 { "-" } else { "" }, q.abs() / 100, q.abs() % 100);
s.trim_end_matches('0').trim_end_matches('.').to_string()
};
let [a, b, c, k] = self.v.map(num);
match self.model {
0 => write!(f, "rgb {a} {b} {c}"),
1 => write!(f, "cmyk {a} {b} {c} {k}"),
2 => write!(f, "gray {a}"),
_ => write!(f, "lab {a} {b} {c}"),
}
}
}
/// The neutral a colour is, if any: paper white, black (rich blacks included) or a grey.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Neutral {
White,
Black,
Gray,
}
impl Neutral {
pub fn of(c: &Color) -> Option<Self> {
const E: f32 = 0.005;
match *c {
Color::Gray { k } if k <= E => Some(Neutral::White),
Color::Gray { k } if k >= 1.0 - E => Some(Neutral::Black),
Color::Gray { .. } => Some(Neutral::Gray),
Color::Cmyk { c, m, y, k } if c.max(m).max(y).max(k) <= E => Some(Neutral::White),
Color::Cmyk { k, .. } if k >= 1.0 - E => Some(Neutral::Black),
Color::Cmyk { c, m, y, .. } => (c.max(m).max(y) <= E).then_some(Neutral::Gray),
// Lab: lightness at the ends, no chroma between them (to half a unit).
Color::Lab { l, a, b } if l >= 99.5 && a.abs().max(b.abs()) <= 0.5 => Some(Neutral::White),
Color::Lab { l, .. } if l <= 0.5 => Some(Neutral::Black),
Color::Lab { a, b, .. } => (a.abs().max(b.abs()) <= 0.5).then_some(Neutral::Gray),
Color::Rgb { r, g, b } => {
let (lo, hi) = (r.min(g).min(b), r.max(g).max(b));
if lo >= 1.0 - E {
Some(Neutral::White)
} else if hi <= E {
Some(Neutral::Black)
} else {
(hi - lo <= E).then_some(Neutral::Gray)
}
}
}
}
}
/// Which neutrals Recolor Artwork leaves alone (White and Black by default).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Preserve {
pub white: bool,
pub black: bool,
pub grays: bool,
}
impl Default for Preserve {
fn default() -> Self {
Self { white: true, black: true, grays: false }
}
}
impl Preserve {
pub const NONE: Self = Self { white: false, black: false, grays: false };
/// Whether `c` stays as it is.
pub fn keeps(&self, c: &Color) -> bool {
match Neutral::of(c) {
Some(Neutral::White) => self.white,
Some(Neutral::Black) => self.black,
Some(Neutral::Gray) => self.grays,
None => false,
}
}
}
/// How the colours of a row take its new colour.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Method {
/// Every colour becomes the new colour.
#[default]
Exact,
/// Tints of the row's darkest colour become the same tints of the new colour; other colours
/// become the new colour.
PreserveTints,
/// Every colour becomes a tint of the new colour as light, relative to the row's darkest colour,
/// as it is.
ScaleTints,
/// The colour of average lightness becomes the new colour; lighter and darker ones become tints
/// and shades of it.
TintsShades,
/// The row's most saturated colour becomes the new colour; the others turn by the same hue and
/// keep their saturation and brightness.
HueShift,
}
impl Method {
pub const ALL: [Method; 5] = [Method::Exact, Method::PreserveTints, Method::ScaleTints, Method::TintsShades, Method::HueShift];
pub fn id(self) -> &'static str {
match self {
Method::Exact => "exact",
Method::PreserveTints => "preserveTints",
Method::ScaleTints => "scaleTints",
Method::TintsShades => "tintsShades",
Method::HueShift => "hueShift",
}
}
pub fn label(self) -> &'static str {
match self {
Method::Exact => "Exact",
Method::PreserveTints => "Preserve Tints",
Method::ScaleTints => "Scale Tints",
Method::TintsShades => "Tints and Shades",
Method::HueShift => "Hue Shift",
}
}
/// A method from its id or label (case and spaces ignored).
pub fn parse(s: &str) -> Option<Self> {
let key = |x: &str| x.chars().filter(|c| c.is_alphanumeric()).collect::<String>().to_ascii_lowercase();
let want = key(s);
Self::ALL.into_iter().find(|m| key(m.id()) == want || key(m.label()) == want)
}
/// The colour of `row` the new colour stands for: the most saturated for Hue Shift, the one
/// of average lightness for Tints and Shades, else the darkest. `None` for an empty row.
pub fn key_color(self, row: &[Color]) -> Option<Color> {
let labs: Vec<(Color, Lab)> = row.iter().map(|c| (*c, c.to_lab())).collect();
let pick = |score: &dyn Fn(&Lab) -> f32| labs.iter().min_by(|a, b| score(&a.1).total_cmp(&score(&b.1))).map(|x| x.0);
match self {
Method::HueShift => pick(&|l| -l.chroma() * 1000.0 + l.l),
Method::TintsShades => {
let mean = mean_l(&labs);
pick(&|l| (l.l - mean).abs())
}
_ => pick(&|l| l.l * 1000.0 - l.chroma()),
}
}
}
fn mean_l(labs: &[(Color, Lab)]) -> f32 {
labs.iter().map(|x| x.1.l).sum::<f32>() / labs.len().max(1) as f32
}
/// Ink of `c` in `model`'s terms: CMYK and Gray components, 1 − RGB, or Lab's distance from paper
/// white (as [`Color::tinted`] scales it).
fn ink(c: Color, model: crate::cms::Model) -> [f32; 4] {
match c.in_model(model) {
Color::Cmyk { c, m, y, k } => [c, m, y, k],
Color::Gray { k } => [k, 0.0, 0.0, 0.0],
Color::Rgb { r, g, b } => [1.0 - r, 1.0 - g, 1.0 - b, 0.0],
Color::Lab { l, a, b } => [(100.0 - l) / 100.0, a / 100.0, b / 100.0, 0.0],
}
}
fn dot(a: [f32; 4], b: [f32; 4]) -> f32 {
a.iter().zip(b).map(|(x, y)| x * y).sum()
}
/// How much of `base`'s ink `c` carries (0 = paper, 1 = `base` or darker), measured in `c`'s model:
/// the tint of `base` closest to `c`.
pub fn tint_of(c: Color, base: Color) -> f32 {
let (a, b) = (ink(c, c.model()), ink(base, c.model()));
let bb = dot(b, b);
if bb <= 1e-9 { 1.0 } else { (dot(a, b) / bb).clamp(0.0, 1.0) }
}
/// Whether `c` is a tint of `base` (its ink a fraction of `base`'s).
pub fn is_tint(c: Color, base: Color) -> bool {
let t = tint_of(c, base);
let (a, b) = (ink(c, c.model()), ink(base, c.model()));
a.iter().zip(b).all(|(x, y)| (x - y * t).abs() <= 0.01)
}
/// `c` darkened by `f` (0..1) toward black: more K for CMYK and Gray, scaled RGB and Lab.
pub fn shaded(c: Color, f: f32) -> Color {
let f = f.clamp(0.0, 1.0);
match c {
Color::Cmyk { c, m, y, k } => Color::cmyk(c, m, y, k + (1.0 - k) * f),
Color::Gray { k } => Color::gray(k + (1.0 - k) * f),
Color::Lab { l, a, b } => Color::lab(l * (1.0 - f), a * (1.0 - f), b * (1.0 - f)),
Color::Rgb { r, g, b } => Color::rgb(r * (1.0 - f), g * (1.0 - f), b * (1.0 - f)),
}
}
/// One row of Recolor Artwork prepared for mapping: its current colours take `to` by `method`.
#[derive(Clone, Copy, Debug)]
pub struct RowMap {
method: Method,
to: Color,
key: Color,
mean_l: f32,
/// Hue turn from the key colour to the new colour (Hue Shift).
turn: f32,
}
impl RowMap {
/// The map of a row of current colours `row` (non-empty) to the new colour `to`.
pub fn new(method: Method, row: &[Color], to: Color) -> Self {
let key = method.key_color(row).unwrap_or(to);
let mean_l = match method {
Method::TintsShades => mean_l(&row.iter().map(|c| (*c, c.to_lab())).collect::<Vec<_>>()),
_ => 0.0,
};
Self { method, to, key, mean_l, turn: to.to_hsb()[0] - key.to_hsb()[0] }
}
/// The colour of the row the new colour stands for.
pub fn key(&self) -> Color {
self.key
}
/// 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);
match self.method {
Method::Exact => to,
Method::PreserveTints if is_tint(c, self.key) => to.tinted(tint_of(c, self.key)),
Method::PreserveTints => to,
Method::ScaleTints => to.tinted(tint_of(c, self.key)),
Method::TintsShades => {
let d = c.to_lab().l - self.mean_l;
if d >= 0.0 { to.tinted(1.0 - d / (100.0 - self.mean_l).max(1e-3)) } else { shaded(to, -d / self.mean_l.max(1e-3)) }
}
Method::HueShift if c == self.key => to,
Method::HueShift => {
let [h, s, v] = c.to_hsb();
if s <= 0.0 { c } else { keep_model(c, Color::from_hsb(h + self.turn, s, v)) }
}
}
}
}
/// Group `colors` into at most `n` rows of similar colours, for colour reduction: k-means in Lab
/// (lightness counting half, so tints and shades of a hue stay together) weighted by `weights`,
/// seeded with the heaviest colour and then farthest-first, so the result is the same on every
/// run. Returns the rows as indices into `colors`, ordered by their first index (each in input
/// order).
pub fn cluster(colors: &[Color], weights: &[f32], n: usize) -> Vec<Vec<usize>> {
let pts: Vec<[f32; 3]> = colors
.iter()
.map(|c| {
let l = c.to_lab();
[l.l * 0.5, l.a, l.b]
})
.collect();
let w = |i: usize| weights.get(i).copied().unwrap_or(1.0).max(1e-6);
let d2 = |a: &[f32; 3], b: &[f32; 3]| (a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2);
let n = n.max(1);
if pts.is_empty() {
return vec![];
}
// Seeds: the heaviest colour, then each time the one farthest from every seed so far.
let first = (0..pts.len()).fold(0, |best, i| if w(i) > w(best) { i } else { best });
let mut centres = vec![pts[first]];
let mut near: Vec<f32> = pts.iter().map(|p| d2(p, &pts[first])).collect();
while centres.len() < n {
let (i, far) = near.iter().enumerate().fold((0, 0.0f32), |b, (i, d)| if *d > b.1 { (i, *d) } else { b });
if far <= 1e-6 {
break;
}
centres.push(pts[i]);
for (j, p) in pts.iter().enumerate() {
near[j] = near[j].min(d2(p, &pts[i]));
}
}
let nearest = |p: &[f32; 3], cs: &[[f32; 3]]| (0..cs.len()).min_by(|a, b| d2(p, &cs[*a]).total_cmp(&d2(p, &cs[*b]))).unwrap_or(0);
let mut of: Vec<usize> = pts.iter().map(|p| nearest(p, &centres)).collect();
for _ in 0..50 {
let mut sum = vec![([0.0f32; 3], 0.0f32); centres.len()];
for (i, p) in pts.iter().enumerate() {
let s = &mut sum[of[i]];
for (acc, v) in s.0.iter_mut().zip(p) {
*acc += v * w(i);
}
s.1 += w(i);
}
for (c, (s, wt)) in centres.iter_mut().zip(&sum) {
if *wt > 0.0 {
*c = s.map(|v| v / wt);
}
}
let next: Vec<usize> = pts.iter().map(|p| nearest(p, &centres)).collect();
if next == of {
break;
}
of = next;
}
let mut rows: Vec<Vec<usize>> = vec![vec![]; centres.len()];
for (i, c) in of.into_iter().enumerate() {
rows[c].push(i);
}
rows.retain(|r| !r.is_empty());
rows.sort_by_key(|r| r[0]);
rows
}
/// A set of colours to snap to (Limit to Library), with their Lab values cached.
#[derive(Clone, Debug, Default)]
pub struct Palette(Vec<(Color, Lab)>);
impl Palette {
pub fn new(colors: impl IntoIterator<Item = Color>) -> Self {
Self(colors.into_iter().map(|c| (c, c.to_lab())).collect())
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
/// The palette colour closest to `c` (CIEDE2000), in its own model; `c` for an empty palette.
pub fn nearest(&self, c: Color) -> Color {
let l = c.to_lab();
self.0.iter().min_by(|a, b| delta_e2000(a.1, l).total_cmp(&delta_e2000(b.1, l))).map_or(c, |x| x.0)
}
}
/// A small deterministic generator (xorshift64*) for the randomizers.
pub struct Rng(u64);
impl Rng {
pub fn new(seed: u64) -> Self {
Self(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1)
}
pub fn next_u64(&mut self) -> u64 {
let mut x = self.0;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.0 = x;
x.wrapping_mul(0x2545_F491_4F6C_DD1D)
}
/// Uniform in `lo..hi`.
pub fn range(&mut self, lo: f32, hi: f32) -> f32 {
lo + (hi - lo) * ((self.next_u64() >> 40) as f32 / (1u64 << 24) as f32)
}
/// Shuffle `v` in place (Fisher–Yates).
pub fn shuffle<T>(&mut self, v: &mut [T]) {
for i in (1..v.len()).rev() {
let j = (self.next_u64() % (i as u64 + 1)) as usize;
v.swap(i, j);
}
}
/// `c` with a random saturation and brightness, its hue kept (greys stay grey), in its model.
pub fn saturation_brightness(&mut self, c: Color) -> Color {
let [h, s, _] = c.to_hsb();
let (s2, v2) = (self.range(0.25, 1.0), self.range(0.3, 1.0));
keep_model(c, Color::from_hsb(h, if s <= 0.0 { 0.0 } else { s2 }, v2))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn close(a: Color, b: Color) -> bool {
let v = |c: Color| match c {
Color::Cmyk { c, m, y, k } => vec![c, m, y, k],
Color::Rgb { r, g, b } => vec![r, g, b],
Color::Gray { k } => vec![k],
Color::Lab { l, a, b } => vec![l / 100.0, a / 100.0, b / 100.0],
};
a.model() == b.model() && v(a).iter().zip(v(b)).all(|(x, y)| (x - y).abs() < 1e-3)
}
#[test]
fn keys_keep_the_model_and_round_trip() {
let colors = [
Color::rgb(1.0, 0.5, 0.0),
Color::rgb(1.0 / 3.0, 0.2, 0.9),
Color::cmyk(0.0, 1.0, 1.0, 0.0),
Color::cmyk(0.1234, 0.5, 0.0, 0.07),
Color::gray(0.4),
];
for c in colors {
let k = ColorKey::of(&c);
assert_eq!(ColorKey::parse(&k.to_string()), Some(k), "{k}");
assert!(close(k.color(), c));
}
assert_eq!(ColorKey::of(&Color::rgb(1.0, 0.5, 0.0)).to_string(), "rgb 255 127.5 0");
assert_eq!(ColorKey::of(&Color::cmyk(0.0, 1.0, 1.0, 0.0)).to_string(), "cmyk 0 100 100 0");
assert_eq!(ColorKey::of(&Color::gray(0.4)).to_string(), "gray 40");
assert_eq!(ColorKey::of(&Color::rgb8(18, 52, 86)).to_string(), "rgb 18 52 86", "8-bit colours read as whole levels");
// A CMYK red and an RGB red are different colours.
assert_ne!(ColorKey::of(&Color::cmyk(0.0, 1.0, 1.0, 0.0)), ColorKey::of(&Color::rgb(1.0, 0.0, 0.0)));
assert_eq!(ColorKey::parse(" CMYK 0 100 100 0 "), ColorKey::parse("cmyk 0 100 100 0"));
let lab = Color::lab(55.0, 60.5, -40.25);
assert_eq!(ColorKey::of(&lab).to_string(), "lab 55 60.5 -40.25");
assert_eq!(ColorKey::parse("lab 55 60.5 -40.25").map(|k| k.color()), Some(lab));
assert_ne!(ColorKey::of(&lab), ColorKey::of(&keep_model(Color::WHITE, lab)), "a Lab colour isn't its RGB lookalike");
assert_eq!(ColorKey::parse("#ff0000"), None);
assert_eq!(ColorKey::parse("rgb 1 2"), None);
}
#[test]
fn neutrals_and_preserve() {
assert_eq!(Neutral::of(&Color::WHITE), Some(Neutral::White));
assert_eq!(Neutral::of(&Color::cmyk(0.0, 0.0, 0.0, 0.0)), Some(Neutral::White));
assert_eq!(Neutral::of(&Color::cmyk(0.6, 0.4, 0.4, 1.0)), Some(Neutral::Black), "rich black");
assert_eq!(Neutral::of(&Color::gray(0.5)), Some(Neutral::Gray));
assert_eq!(Neutral::of(&Color::rgb(0.5, 0.5, 0.5)), Some(Neutral::Gray));
assert_eq!(Neutral::of(&Color::cmyk(0.0, 0.0, 0.0, 0.3)), Some(Neutral::Gray));
assert_eq!(Neutral::of(&Color::rgb(0.5, 0.4, 0.5)), None);
assert_eq!(Neutral::of(&Color::lab(100.0, 0.0, 0.0)), Some(Neutral::White));
assert_eq!(Neutral::of(&Color::lab(0.0, 0.0, 0.0)), Some(Neutral::Black));
assert_eq!(Neutral::of(&Color::lab(50.0, 0.2, -0.3)), Some(Neutral::Gray));
assert_eq!(Neutral::of(&Color::lab(50.0, 20.0, 0.0)), None);
let p = Preserve::default();
assert!(p.keeps(&Color::BLACK) && p.keeps(&Color::WHITE) && !p.keeps(&Color::gray(0.5)));
assert!(!Preserve::NONE.keeps(&Color::BLACK));
}
#[test]
fn methods_parse_by_id_and_label() {
for m in Method::ALL {
assert_eq!(Method::parse(m.id()), Some(m));
assert_eq!(Method::parse(m.label()), Some(m));
}
assert_eq!(Method::parse("tints & shades"), Some(Method::TintsShades));
assert_eq!(Method::parse("nope"), None);
}
#[test]
fn scale_tints_keep_tint_ratios() {
let red = Color::cmyk(0.0, 1.0, 1.0, 0.0);
let pink = red.tinted(0.4);
let row = [pink, red];
let m = RowMap::new(Method::ScaleTints, &row, Color::cmyk(1.0, 0.5, 0.0, 0.0));
assert!(close(m.key(), red), "the darkest colour is the key");
assert!(close(m.map(red), Color::cmyk(1.0, 0.5, 0.0, 0.0)));
assert!(close(m.map(pink), Color::cmyk(0.4, 0.2, 0.0, 0.0)), "{:?}", m.map(pink));
// RGB tints mix toward white.
let blue = Color::rgb(0.0, 0.0, 1.0);
let m = RowMap::new(Method::ScaleTints, &[blue, blue.tinted(0.5)], Color::rgb(1.0, 0.0, 0.0));
assert!(close(m.map(blue.tinted(0.5)), Color::rgb(1.0, 0.5, 0.5)));
// Lab tints too (toward paper white).
let base = Color::lab(40.0, 60.0, -20.0);
let to = Color::lab(50.0, -40.0, 30.0);
let m = RowMap::new(Method::ScaleTints, &[base, base.tinted(0.25)], to);
assert!(close(m.map(base.tinted(0.25)), to.tinted(0.25)), "{:?}", m.map(base.tinted(0.25)));
}
#[test]
fn preserve_tints_only_scales_real_tints() {
let red = Color::cmyk(0.0, 1.0, 1.0, 0.0);
let other = Color::cmyk(0.0, 0.3, 0.8, 0.0);
let to = Color::cmyk(1.0, 0.0, 0.0, 0.0);
let m = RowMap::new(Method::PreserveTints, &[red, red.tinted(0.5), other], to);
assert!(close(m.map(red.tinted(0.5)), Color::cmyk(0.5, 0.0, 0.0, 0.0)));
assert!(close(m.map(other), to), "not a tint: exact");
let s = RowMap::new(Method::ScaleTints, &[red, other], to);
assert!(!close(s.map(other), to), "Scale Tints scales every colour");
}
#[test]
fn exact_tints_shades_and_hue_shift() {
let to = Color::rgb(0.0, 0.4, 1.0);
let row = [Color::rgb(0.9, 0.2, 0.2), Color::rgb(0.5, 0.1, 0.1), Color::rgb(1.0, 0.7, 0.7)];
let e = RowMap::new(Method::Exact, &row, to);
assert!(row.iter().all(|c| e.map(*c) == to));
let ts = RowMap::new(Method::TintsShades, &row, to);
let l = |c: Color| c.to_lab().l;
assert!(l(ts.map(row[2])) > l(ts.map(row[0])) && l(ts.map(row[0])) > l(ts.map(row[1])), "lighter stays lighter");
let hs = RowMap::new(Method::HueShift, &row, to);
assert_eq!(hs.map(hs.key()), to);
let [h, s, v] = hs.map(row[1]).to_hsb();
let [_, s0, v0] = row[1].to_hsb();
assert!((h - to.to_hsb()[0]).abs() < 1.0 && (s - s0).abs() < 1e-3 && (v - v0).abs() < 1e-3, "{h} {s} {v}");
// Results keep the model of the colour they replace.
let m = RowMap::new(Method::Exact, &[Color::gray(0.3)], to);
assert!(matches!(m.map(Color::gray(0.3)), Color::Gray { .. }));
let lab = [Color::lab(40.0, 50.0, 30.0), Color::lab(70.0, 20.0, 10.0)];
for method in Method::ALL {
let m = RowMap::new(method, &lab, to);
assert!(lab.iter().all(|c| matches!(m.map(*c), Color::Lab { .. })), "{}", method.label());
}
assert!(matches!(shaded(lab[0], 0.5), Color::Lab { .. }));
}
#[test]
fn clustering_reduces_to_n_rows_of_similar_colours() {
let colors =
[Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0), Color::rgb(0.9, 0.1, 0.05), Color::rgb(0.1, 0.1, 0.9), Color::rgb(1.0, 0.2, 0.2)];
let rows = cluster(&colors, &[5.0, 4.0, 1.0, 1.0, 1.0], 2);
assert_eq!(rows, vec![vec![0, 2, 4], vec![1, 3]]);
assert_eq!(cluster(&colors, &[1.0; 5], 9).len(), 5, "never more rows than colours");
assert_eq!(cluster(&colors, &[1.0; 5], 1), vec![vec![0, 1, 2, 3, 4]]);
assert_eq!(cluster(&[Color::WHITE, Color::WHITE], &[1.0, 1.0], 2).len(), 1, "identical colours share a row");
assert!(cluster(&[], &[], 3).is_empty());
}
#[test]
fn palette_snaps_to_the_nearest_colour() {
let p = Palette::new([Color::rgb(1.0, 0.0, 0.0), Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::gray(0.5)]);
assert_eq!(p.nearest(Color::rgb(0.9, 0.1, 0.1)), Color::rgb(1.0, 0.0, 0.0));
assert_eq!(p.nearest(Color::rgb(0.45, 0.45, 0.45)), Color::gray(0.5));
let lab = Palette::new([Color::lab(50.0, 70.0, 50.0), Color::lab(50.0, -60.0, 50.0)]);
assert_eq!(lab.nearest(Color::rgb(0.9, 0.1, 0.1)), Color::lab(50.0, 70.0, 50.0), "library colours keep their model");
assert_eq!(Palette::default().nearest(Color::WHITE), Color::WHITE);
}
#[test]
fn randomizers_are_deterministic_and_keep_hue() {
let mut a: Vec<u32> = (0..10).collect();
let mut b = a.clone();
Rng::new(7).shuffle(&mut a);
Rng::new(7).shuffle(&mut b);
assert_eq!(a, b);
assert_ne!(a, (0..10).collect::<Vec<_>>());
let c = Color::cmyk(0.0, 0.8, 0.9, 0.1);
let r = Rng::new(3).saturation_brightness(c);
assert!(matches!(r, Color::Cmyk { .. }));
assert!(matches!(Rng::new(3).saturation_brightness(Color::lab(50.0, 40.0, 20.0)), Color::Lab { .. }));
assert_eq!(Rng::new(3).saturation_brightness(Color::gray(0.4)).to_hsb()[1], 0.0, "greys stay grey");
}
}

View File

@ -253,11 +253,13 @@ pub fn matrix_param(p: &Value, key: &str) -> Option<Affine> {
}
Some(Affine::new(c))
}
/// A colour from `"#rrggbb"`, `[r,g,b]` (0..1), `{"c":..,"m":..,"y":..,"k":..}`, `{"gray":..}` or
/// `{"l":..,"a":..,"b":..}` (CIE Lab: L 0..100, a and b about −128..127).
/// A colour from `"#rrggbb"`, a colour key (`"cmyk 0 100 100 0"`, `"lab 55 60 40"`), `[r,g,b]` (0..1),
/// `{"c":..,"m":..,"y":..,"k":..}`, `{"gray":..}` or `{"l":..,"a":..,"b":..}` (CIE Lab: L 0..100, a and b about
/// −128..127).
pub fn color_value(v: &Value) -> Option<Color> {
match v {
Value::String(s) => Color::from_hex(s),
// A hex, or a colour key ("cmyk 0 100 100 0", as Recolor Artwork lists colours).
Value::String(s) => Color::from_hex(s).or_else(|| vectorcraft_color::recolor::ColorKey::parse(s).map(|k| k.color())),
Value::Array(a) if a.len() >= 3 => Some(Color::rgb(a[0].as_f64()? as f32, a[1].as_f64()? as f32, a[2].as_f64()? as f32)),
Value::Object(o) => {
if let (Some(c), Some(m), Some(y), Some(k)) = (o.get("c"), o.get("m"), o.get("y"), o.get("k")) {

View File

@ -1,11 +1,15 @@
//! Edit → Edit Colors → Recolor Artwork: list the selection's colours and remap them.
//! Edit → Edit Colors → Recolor Artwork: list the selection's colours, reduce them to rows of
//! similar colours, and remap each row to a new colour by a recolour method. Colours are keyed by
//! their identity ([`ColorKey`]: model and exact values), so a CMYK colour stays CMYK and two
//! colours that only look alike stay apart.
use std::collections::BTreeMap;
use std::collections::HashMap;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint, keep_model};
use vectorcraft_color::recolor::{ColorKey, Method, Palette, Preserve, Rng, RowMap, cluster};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::swatches::node_colors;
use vectorcraft_doc::{AppearanceItem, NodeKind};
use vectorcraft_doc::{AppearanceItem, Document, NodeId, NodeKind};
use super::colorcmds::{Recolor, Scope};
use super::*;
@ -17,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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",
"{} → {colors: [{key: colour identity (\"rgb 255 0 0\", \"cmyk 0 100 100 0\", \"gray 40\", \"lab 55 60 -40\"), hex, count, swatch?: the global swatch it is linked to}]} 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
),
@ -26,21 +30,58 @@ pub fn specs() -> Vec<CommandSpec> {
"Recolor Artwork",
["Edit", "Edit Colors"],
None,
"{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}",
"{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 (new colours snap to its nearest colour), 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) as one undo step → {changed}",
has_selection,
apply
),
cmd!(
query "recolor.reduce",
"Reduce Colors",
[],
None,
"{colors?: n (rows; default: one per colour, with the tints of a global swatch in its row), method?: as recolor.apply, default scaleTints (picks each row's colour), preserve?: {white?: true, black?: true, grays?: false} (left out of the rows), limitTo?: swatch library (new colours snap to it)} group the selection's colours into rows of similar colours (k-means in Lab, weighted by use) → {map: [{from: [keys, the row's colour first], to: key}], preserved: [keys], method}",
has_selection,
reduce
),
cmd!(
query "recolor.randomize",
"Randomize Colors",
[],
None,
"{map: rows as recolor.apply, order?: true (shuffle the new colours among the rows that aren't excluded), saturationBrightness?: false (random saturation and brightness, hue kept), seed?: 0} → {map: the rows with their new colours as keys}",
always,
randomize
),
]
}
fn colors(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc()?;
let d = &st.doc;
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
let mut count = |c: &Color, _: Option<&str>| *counts.entry(c.to_hex()).or_default() += 1;
/// A colour the art uses: how often, and the global swatch it is linked to.
struct Used {
color: Color,
count: usize,
swatch: Option<String>,
}
/// The unique colours of the selection, most used first (ties by key): fills, strokes, gradient
/// stops, text, mesh points and the tiles of the patterns used as fills or strokes.
fn used_colors(d: &Document, ids: &[NodeId]) -> Vec<Used> {
let mut index: HashMap<ColorKey, usize> = HashMap::new();
let mut used: Vec<(ColorKey, Used)> = vec![];
let mut count = |c: &Color, link: Option<&str>| {
let k = ColorKey::of(c);
let i = *index.entry(k).or_insert_with(|| {
used.push((k, Used { color: *c, count: 0, swatch: None }));
used.len() - 1
});
let u = &mut used[i].1;
u.count += 1;
if u.swatch.is_none() {
u.swatch = link.filter(|l| d.swatch(l).is_some_and(|w| w.global && w.paint.color().is_some())).map(str::to_string);
}
};
// 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)) {
for n in ids.iter().filter_map(|id| d.node(*id)) {
node_colors(n, &mut count);
n.walk(&mut |m| {
let runs = match &m.kind {
@ -63,22 +104,236 @@ fn colors(s: &mut Session, _: &Value) -> Result<Value> {
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)));
Ok(json!({ "colors": v.iter().map(|(h, n)| json!({"hex": h, "count": n})).collect::<Vec<_>>() }))
used.sort_by(|a, b| b.1.count.cmp(&a.1.count).then(a.0.cmp(&b.0)));
used.into_iter().map(|(_, u)| u).collect()
}
fn colors(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc()?;
let v: Vec<Value> = used_colors(&st.doc, &st.selection.objects)
.iter()
.map(|u| {
let mut v = json!({"key": key_str(&u.color), "hex": u.color.to_hex(), "count": u.count});
if let Some(w) = &u.swatch {
v["swatch"] = json!(w);
}
v
})
.collect();
Ok(json!({ "colors": v }))
}
/// The `method` parameter (`default` without one).
fn method_param(p: &Value, cmd: &str, default: Method) -> Result<Method> {
match str_param(p, "method") {
None => Ok(default),
Some(m) => Method::parse(m).ok_or_else(|| {
let ids: Vec<&str> = Method::ALL.iter().map(|m| m.id()).collect();
bad(cmd, format!("unknown method `{m}` ({})", ids.join("|")))
}),
}
}
/// The colours of the `limitTo` library (`None` without one).
fn palette_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<Palette>> {
let Some(key) = str_param(p, "limitTo").filter(|k| !k.is_empty()) else { return Ok(None) };
let (_, lib) = super::swatchlib::library(s, key).ok_or_else(|| bad(cmd, format!("no swatch library `{key}` (see swatch.library.list)")))?;
let palette = Palette::new(lib.iter().filter_map(|w| w.paint.color()));
if palette.is_empty() {
return Err(bad(cmd, format!("swatch library `{key}` has no colours")));
}
Ok(Some(palette))
}
fn key_str(c: &Color) -> Value {
json!(ColorKey::of(c).to_string())
}
fn reduce(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "recolor.reduce";
let method = method_param(p, C, Method::ScaleTints)?;
let palette = palette_param(s, p, C)?;
let def = Preserve::default();
let flag = |k: &str, d: bool| p.get("preserve").map_or(d, |v| bool_or(v, k, d));
let preserve = Preserve { white: flag("white", def.white), black: flag("black", def.black), grays: flag("grays", def.grays) };
let st = s.doc()?;
let (kept, used): (Vec<Used>, Vec<Used>) = used_colors(&st.doc, &st.selection.objects).into_iter().partition(|u| preserve.keeps(&u.color));
// Units that stay together: the tints of a global swatch, else one colour each.
let mut units: Vec<Vec<&Used>> = vec![];
for u in &used {
match units.iter_mut().find(|g| u.swatch.is_some() && g[0].swatch == u.swatch) {
Some(g) => g.push(u),
None => units.push(vec![u]),
}
}
let unit_colors = |g: &[&Used]| g.iter().map(|u| u.color).collect::<Vec<_>>();
let rows: Vec<Vec<&Used>> = match p.get("colors").and_then(Value::as_u64) {
Some(n) if (n as usize) < units.len() => {
let keys: Vec<Color> = units.iter().map(|g| method.key_color(&unit_colors(g)).unwrap_or_default()).collect();
let weights: Vec<f32> = units.iter().map(|g| g.iter().map(|u| u.count as f32).sum()).collect();
cluster(&keys, &weights, n as usize).into_iter().map(|ix| ix.into_iter().flat_map(|i| units[i].iter().copied()).collect()).collect()
}
_ => units,
};
let map: Vec<Value> = rows
.iter()
.map(|row| {
let mut from = unit_colors(row);
let key = method.key_color(&from).unwrap_or_default();
// The row's colour first.
if let Some(i) = from.iter().position(|c| *c == key) {
from[..=i].rotate_right(1);
}
let to = palette.as_ref().map_or(key, |pl| pl.nearest(key));
json!({"from": from.iter().map(key_str).collect::<Vec<_>>(), "to": key_str(&to)})
})
.collect();
Ok(json!({"map": map, "preserved": kept.iter().map(|u| key_str(&u.color)).collect::<Vec<_>>(), "method": method.id()}))
}
/// A current colour of a row: an exact colour, or every colour shown as a hex.
enum Match {
Key(ColorKey),
Hex(String),
}
impl Match {
/// A colour key, a hex (`#rrggbb`) or a colour value.
fn of(v: &Value) -> Option<Self> {
if let Some(s) = v.as_str() {
if let Some(k) = ColorKey::parse(s) {
return Some(Match::Key(k));
}
return Color::from_hex(s).map(|c| Match::Hex(c.to_hex()));
}
color_value(v).map(|c| Match::Key(ColorKey::of(&c)))
}
fn color(&self) -> Color {
match self {
Match::Key(k) => k.color(),
Match::Hex(h) => Color::from_hex(h).unwrap_or_default(),
}
}
fn to_json(&self) -> Value {
match self {
Match::Key(k) => json!(k.to_string()),
Match::Hex(h) => json!(h),
}
}
}
/// A row of the map: its current colours and its new colour; an excluded row keeps its colours.
struct Row {
from: Vec<Match>,
to: Option<Color>,
exclude: bool,
}
impl Row {
/// The new colour, unless the row is excluded.
fn new_color(&self) -> Option<Color> {
self.to.filter(|_| !self.exclude)
}
}
/// The rows of a `map` parameter, in order: an array of `{from, to, exclude?}` or an object of
/// `current: new` pairs.
fn parse_rows(p: &Value, cmd: &str) -> Result<Vec<Row>> {
let new = |v: Option<&Value>| -> Result<Option<Color>> {
match v {
None | Some(Value::Null) => Ok(None),
Some(v) => color_value(v).map(Some).ok_or_else(|| bad(cmd, format!("bad new colour {v}"))),
}
};
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::Array(rows)) => rows
.iter()
.map(|r| {
let from = match r.get("from") {
Some(Value::Array(a)) => a.iter().map(cur).collect::<Result<Vec<_>>>()?,
Some(v) => vec![cur(v)?],
None => return Err(bad(cmd, "each row needs `from`")),
};
Ok(Row { from, to: new(r.get("to"))?, exclude: bool_or(r, "exclude", false) })
})
.collect(),
_ => Err(bad(cmd, "missing `map` (rows [{from, to}] or {current: new})")),
}
}
/// A parsed map ready to recolour: the row of each current colour.
struct ColorMap {
rows: Vec<RowMap>,
keys: HashMap<ColorKey, usize>,
hexes: HashMap<String, usize>,
}
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()) {
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));
for m in &r.from {
match m {
Match::Key(k) => out.keys.entry(*k).or_insert(i),
Match::Hex(h) => out.hexes.entry(h.clone()).or_insert(i),
};
}
}
out
}
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))
}
}
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
let map: BTreeMap<String, Color> = p
.get("map")
.and_then(Value::as_object)
.ok_or_else(|| bad("recolor.apply", "missing map"))?
.iter()
.filter_map(|(k, v)| Some((Color::from_hex(k)?.to_hex(), color_value(v)?)))
.collect();
const C: &str = "recolor.apply";
let method = method_param(p, C, Method::Exact)?;
let palette = palette_param(s, p, C)?;
let mut rows = parse_rows(p, C)?;
if let Some(pl) = &palette {
for r in &mut rows {
r.to = r.to.map(|c| pl.nearest(c));
}
}
let ids = s.doc()?.selection.objects.clone();
let f = |c: Color| map.get(&c.to_hex()).map_or(c, |to| keep_model(c, *to));
let map = ColorMap::new(&rows, method);
let f = |c: Color| map.map(c);
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 }))
}
fn randomize(_: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "recolor.randomize";
let rows = parse_rows(p, C)?;
let mut rng = Rng::new(p.get("seed").and_then(Value::as_u64).unwrap_or(0));
let mut news: Vec<Color> = rows.iter().filter_map(Row::new_color).collect();
if bool_or(p, "order", true) {
rng.shuffle(&mut news);
}
if bool_or(p, "saturationBrightness", false) {
news = news.into_iter().map(|c| rng.saturation_brightness(c)).collect();
}
let mut news = news.into_iter();
let map: Vec<Value> = rows
.iter()
.map(|r| {
let to = if r.new_color().is_some() { news.next() } else { r.to };
let mut row = json!({"from": r.from.iter().map(Match::to_json).collect::<Vec<_>>(), "to": to.as_ref().map(key_str)});
if r.exclude {
row["exclude"] = json!(true);
}
row
})
.collect();
Ok(json!({ "map": map }))
}

View File

@ -908,6 +908,8 @@ mod tests_previewbounds;
#[cfg(test)]
mod tests_proxyitems;
#[cfg(test)]
mod tests_recolor;
#[cfg(test)]
mod tests_registration;
#[cfg(test)]
mod tests_scalestrokes;

View File

@ -0,0 +1,224 @@
//! Recolor Artwork (M3.78): colour reduction, preserve rules, recolour methods, Limit to Library,
//! randomize.
use serde_json::{Value, json};
use vectorcraft_color::Color;
use super::*;
use crate::cmd::color_value;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 200})).unwrap();
s
}
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
/// A rectangle filled with `fill` (any colour the paint commands take).
fn rect(s: &mut Session, fill: Value) -> NodeId {
let n = s.doc().map_or(0, |st| st.doc.layers[0].children().map_or(0, |c| c.len())) as f64;
let id = NodeId(run(s, "shape.rectangle", json!({"x": 10.0 + 20.0 * n, "y": 10, "width": 15, "height": 15}))["id"].as_u64().unwrap());
run(s, "paint.setFill", json!({"ids": [id.0], "color": fill}));
run(s, "paint.setStroke", json!({"ids": [id.0], "none": true}));
id
}
fn fill(s: &Session, id: NodeId) -> Color {
s.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint().color().unwrap()
}
fn select_all(s: &mut Session) {
run(s, "select.all", json!({}));
}
fn keys(v: &Value) -> Vec<String> {
v.as_array().unwrap().iter().map(|k| k.as_str().unwrap().to_string()).collect()
}
fn close(a: Color, b: Color) -> bool {
let v = |c: Color| match c {
Color::Cmyk { c, m, y, k } => vec![c, m, y, k],
Color::Rgb { r, g, b } => vec![r, g, b],
Color::Gray { k } => vec![k],
Color::Lab { l, a, b } => vec![l / 100.0, a / 100.0, b / 100.0],
};
a.model() == b.model() && v(a).iter().zip(v(b)).all(|(x, y)| (x - y).abs() < 2e-3)
}
#[test]
fn five_colours_reduce_to_two() {
let mut s = session();
for c in ["#ff0000", "#e01010", "#0000ff", "#1010e0", "#ff3030"] {
rect(&mut s, json!(c));
}
select_all(&mut s);
assert_eq!(run(&mut s, "recolor.colors", json!({}))["colors"].as_array().unwrap().len(), 5);
let r = run(&mut s, "recolor.reduce", json!({"colors": 2}));
let map = r["map"].as_array().unwrap();
assert_eq!(map.len(), 2);
let mut sizes: Vec<usize> = map.iter().map(|row| row["from"].as_array().unwrap().len()).collect();
sizes.sort();
assert_eq!(sizes, [2, 3], "blues and reds");
assert_eq!(r["method"], "scaleTints");
// Exact: each row becomes one colour.
run(&mut s, "recolor.apply", json!({"map": r["map"], "method": "exact"}));
let after = run(&mut s, "recolor.colors", json!({}));
assert_eq!(after["colors"].as_array().unwrap().len(), 2, "{after}");
// One undo step.
run(&mut s, "edit.undo", json!({}));
assert_eq!(run(&mut s, "recolor.colors", json!({}))["colors"].as_array().unwrap().len(), 5);
// Without a count every colour is its own row.
assert_eq!(run(&mut s, "recolor.reduce", json!({}))["map"].as_array().unwrap().len(), 5);
}
#[test]
fn preserve_flags_leave_neutrals_out() {
let mut s = session();
for c in [json!("#ffffff"), json!("#000000"), json!({"gray": 50}), json!("#ff0000")] {
rect(&mut s, c);
}
select_all(&mut s);
let r = run(&mut s, "recolor.reduce", json!({}));
assert_eq!(keys(&r["preserved"]), ["rgb 0 0 0", "rgb 255 255 255"]);
assert_eq!(r["map"].as_array().unwrap().len(), 2, "the grey and the red");
let r = run(&mut s, "recolor.reduce", json!({"preserve": {"white": false, "black": false, "grays": true}}));
assert_eq!(keys(&r["preserved"]), ["gray 50"]);
assert_eq!(r["map"].as_array().unwrap().len(), 3);
}
#[test]
fn scale_and_preserve_tints_keep_tint_ratios() {
let mut s = session();
let full = rect(&mut s, json!({"c": 0, "m": 100, "y": 100, "k": 0}));
let tint = rect(&mut s, json!({"c": 0, "m": 40, "y": 40, "k": 0}));
select_all(&mut s);
let r = run(&mut s, "recolor.reduce", json!({"colors": 1}));
let row = &r["map"][0];
assert_eq!(keys(&row["from"]), ["cmyk 0 100 100 0", "cmyk 0 40 40 0"], "the darkest colour leads the row");
for method in ["scaleTints", "preserveTints"] {
run(&mut s, "recolor.apply", json!({"map": [{"from": row["from"], "to": "cmyk 100 50 0 0"}], "method": method}));
assert!(close(fill(&s, full), Color::cmyk(1.0, 0.5, 0.0, 0.0)), "{method}");
assert!(close(fill(&s, tint), Color::cmyk(0.4, 0.2, 0.0, 0.0)), "{method}: {:?}", fill(&s, tint));
run(&mut s, "edit.undo", json!({}));
}
// Exact makes both the new colour.
run(&mut s, "recolor.apply", json!({"map": [{"from": row["from"], "to": "cmyk 100 50 0 0"}]}));
assert!(close(fill(&s, tint), Color::cmyk(1.0, 0.5, 0.0, 0.0)));
}
#[test]
fn cmyk_colours_keep_their_identity_and_model() {
let mut s = session();
let cmyk = rect(&mut s, json!({"c": 0, "m": 100, "y": 100, "k": 0}));
let shown = Color::cmyk(0.0, 1.0, 1.0, 0.0).to_hex();
// An RGB colour that looks the same is a different colour.
let rgb = rect(&mut s, json!(shown));
select_all(&mut s);
let listed: Vec<String> =
run(&mut s, "recolor.colors", json!({}))["colors"].as_array().unwrap().iter().map(|c| c["key"].as_str().unwrap().to_string()).collect();
assert_eq!(listed.len(), 2, "{listed:?}");
assert!(listed.contains(&"cmyk 0 100 100 0".to_string()));
run(&mut s, "recolor.apply", json!({"map": {"cmyk 0 100 100 0": "cmyk 100 0 0 0"}}));
assert_eq!(fill(&s, cmyk), Color::cmyk(1.0, 0.0, 0.0, 0.0), "exact CMYK values survive");
assert!(matches!(fill(&s, rgb), Color::Rgb { .. }) && fill(&s, rgb).to_hex() == shown, "the RGB lookalike is untouched");
// A new colour given in RGB still lands in CMYK.
run(&mut s, "recolor.apply", json!({"map": {"cmyk 100 0 0 0": "#00ff00"}, "method": "scaleTints"}));
assert!(matches!(fill(&s, cmyk), Color::Cmyk { .. }));
// Hex keys still match every colour shown as that hex.
run(&mut s, "recolor.apply", json!({"map": {shown.as_str(): "#0000ff"}}));
assert_eq!(fill(&s, rgb).to_hex(), "#0000ff");
}
#[test]
fn lab_colours_keep_their_identity_and_model() {
let mut s = session();
let lab = rect(&mut s, json!({"l": 55, "a": 60, "b": 40}));
let rgb = rect(&mut s, json!(Color::lab(55.0, 60.0, 40.0).to_hex()));
select_all(&mut s);
let r = run(&mut s, "recolor.reduce", json!({}));
let rows: Vec<Vec<String>> = r["map"].as_array().unwrap().iter().map(|row| keys(&row["from"])).collect();
assert!(rows.contains(&vec!["lab 55 60 40".to_string()]) && rows.len() == 2, "{rows:?}");
run(&mut s, "recolor.apply", json!({"map": {"lab 55 60 40": "#00ff00"}, "method": "scaleTints"}));
assert!(matches!(fill(&s, lab), Color::Lab { .. }), "the Lab colour stays Lab");
assert_eq!(fill(&s, lab).to_hex(), "#00ff00");
assert!(matches!(fill(&s, rgb), Color::Rgb { .. }) && fill(&s, rgb).to_hex() != "#00ff00", "its RGB lookalike is untouched");
}
#[test]
fn tints_of_a_global_swatch_share_its_row() {
let mut s = session();
let brand = Color::cmyk(0.0, 0.8, 0.9, 0.0);
run(&mut s, "swatch.new", json!({"name": "Brand", "color": {"c": 0, "m": 80, "y": 90, "k": 0}, "global": true}));
let full = rect(&mut s, json!("#00ff00"));
let tint = rect(&mut s, json!("#00ff00"));
let other = rect(&mut s, json!("#0000ff"));
// Both linked to the swatch; the second as a 50% tint.
run(&mut s, "paint.setFill", json!({"ids": [full.0], "swatch": "Brand"}));
run(&mut s, "paint.setFill", json!({"ids": [tint.0], "swatch": "Brand", "tint": 50}));
assert!(close(fill(&s, tint), brand.tinted(0.5)));
select_all(&mut s);
let colors = run(&mut s, "recolor.colors", json!({}));
assert!(colors["colors"].as_array().unwrap().iter().filter(|c| c["swatch"] == "Brand").count() == 2, "{colors}");
let r = run(&mut s, "recolor.reduce", json!({}));
let map = r["map"].as_array().unwrap();
assert_eq!(map.len(), 2, "the swatch and its tint in one row, the blue in another");
let brand_row = map.iter().find(|row| row["from"].as_array().unwrap().len() == 2).unwrap();
assert_eq!(brand_row["to"], "cmyk 0 80 90 0", "the swatch's colour leads its row");
let blue_row = map.iter().find(|row| row["from"][0] == "rgb 0 0 255").unwrap();
let rows = json!([{"from": brand_row["from"], "to": "cmyk 100 0 0 0"}, {"from": blue_row["from"], "to": null}]);
run(&mut s, "recolor.apply", json!({"map": rows, "method": "scaleTints"}));
assert!(close(fill(&s, tint), Color::cmyk(0.5, 0.0, 0.0, 0.0)), "{:?}", fill(&s, tint));
assert_eq!(fill(&s, other).to_hex(), "#0000ff", "a row without a new colour is left alone");
}
#[test]
fn limit_to_library_snaps_new_colours() {
let mut s = session();
rect(&mut s, json!("#f01020"));
rect(&mut s, json!("#2040e0"));
select_all(&mut s);
let r = run(&mut s, "recolor.reduce", json!({"limitTo": "web-safe-216"}));
let web = |k: &Value| {
let c = color_value(k).unwrap();
c.to_rgb().iter().all(|v| ((v * 5.0).round() - v * 5.0).abs() < 1e-3)
};
assert!(r["map"].as_array().unwrap().iter().all(|row| web(&row["to"])), "{r}");
run(&mut s, "recolor.apply", json!({"map": {"#f01020": "#f01020"}, "limitTo": "Web Safe 216"}));
let hexes: Vec<Value> = run(&mut s, "recolor.colors", json!({}))["colors"].as_array().unwrap().iter().map(|c| c["hex"].clone()).collect();
assert!(hexes.contains(&json!("#ff0033")) && !hexes.contains(&json!("#f01020")), "{hexes:?}");
assert!(s.execute("recolor.reduce", &json!({"limitTo": "nope"})).is_err());
}
#[test]
fn methods_and_bad_params() {
let mut s = session();
rect(&mut s, json!("#ff0000"));
select_all(&mut s);
assert!(s.execute("recolor.apply", &json!({"map": {"#ff0000": "#00ff00"}, "method": "nope"})).is_err());
assert!(s.execute("recolor.apply", &json!({"map": {"#ff0000": "nonsense"}})).is_err());
assert!(s.execute("recolor.apply", &json!({})).is_err());
}
#[test]
fn randomize_shuffles_new_colours_and_skips_excluded_rows() {
let mut s = session();
let map = json!([
{"from": ["rgb 255 0 0"], "to": "rgb 255 0 0"},
{"from": ["rgb 0 255 0"], "to": "rgb 0 255 0"},
{"from": ["rgb 0 0 255"], "to": "rgb 0 0 255", "exclude": true},
{"from": ["rgb 255 255 0"], "to": "rgb 255 255 0"},
]);
let a = run(&mut s, "recolor.randomize", json!({"map": map, "seed": 4}));
let b = run(&mut s, "recolor.randomize", json!({"map": map, "seed": 4}));
assert_eq!(a, b, "deterministic");
assert_eq!(a["map"][2], map[2], "an excluded row keeps its colour");
let mut tos: Vec<String> = [0, 1, 3].iter().map(|i| a["map"][*i]["to"].as_str().unwrap().to_string()).collect();
tos.sort();
assert_eq!(tos, ["rgb 0 255 0", "rgb 255 0 0", "rgb 255 255 0"], "a permutation");
let sb = run(&mut s, "recolor.randomize", json!({"map": map, "order": false, "saturationBrightness": true}));
let hue = |v: &Value| color_value(v).unwrap().to_hsb()[0];
assert!((hue(&sb["map"][0]["to"]) - 0.0).abs() < 1.0 && (hue(&sb["map"][1]["to"]) - 120.0).abs() < 1.0, "hue kept");
}

View File

@ -28,7 +28,7 @@ pub mod new_color_group;
mod new_document;
pub mod new_swatch;
mod path_ops;
mod recolor;
pub mod recolor;
pub mod saturate;
mod save_changes;
pub mod save_style_library;
@ -140,7 +140,7 @@ registry! {
ArtboardOptions: ["artboardOptions"] => artboard_options::SPEC,
AllTools: ["allTools"] => all_tools::SPEC,
ExportForScreens: ["exportForScreens"] => export_for_screens::SPEC,
Recolor: ["recolor"] => recolor::SPEC,
Recolor: [recolor::KIND] => recolor::SPEC,
Command: ["command"] => command::SPEC,
Effect: ["effect"] => effect::SPEC,
SaveChanges: [crate::unsaved::KIND] => save_changes::SPEC,

View File

@ -1,105 +1,646 @@
//! Edit → Edit Colors → Recolor Artwork: current → new colour pairs, previewed live.
//! Edit → Edit Colors → Recolor Artwork: the selection's colours in rows of current → new colours,
//! previewed live (engine: `recolor.reduce`, `recolor.randomize`, `recolor.apply`).
//!
//! 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 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.
//!
//! Fields (`ui.dialog.set`): `rows` ([{from: [keys], to: key, exclude?}]), `colors` (null: Auto, or a
//! count), `method`, `preserveWhite`, `preserveBlack`, `preserveGrays`, `limitTo` (library id, "" for
//! none), `tab` (`assign` or `edit`), `rule` (harmony id), `linked`, `preview`. Changing `colors`,
//! a preserve flag or `limitTo` reduces the rows again.
use egui::{Pos2, Rect, Sense, Ui, pos2, vec2};
use serde_json::{Value, json};
use vectorcraft_color::harmony::Harmony;
use vectorcraft_color::recolor::{ColorKey, Method, Palette};
use vectorcraft_color::{Color, keep_model};
use vectorcraft_engine::cmd::color_value;
use vectorcraft_engine::cmd::swatchlib;
use super::DialogSpec;
use crate::VectorcraftApp;
use super::{DialogSpec, form};
use crate::panels::c32;
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::theme::{self, Tokens};
use crate::{VectorcraftApp, widgets};
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Recolor Artwork".into(), body, confirm, preview: true, ..DialogSpec::FORM };
/// The dialog kind of Recolor Artwork.
pub const KIND: &str = "recolor";
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
if editor(ui, d) || !app.session.in_interaction() {
let _ = app.session.begin_interaction("Recolor Artwork");
let _ = app.session.preview("recolor.apply", &json!({"map": recolor_map(d)}));
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Recolor Artwork".into(), body, confirm, preview: true, min_width: 540.0, max_width: Some(560.0), ..DialogSpec::FORM };
const CMD: &str = "recolor.apply";
/// Width of the current colours of a row.
const CURRENT_W: f32 = 250.0;
/// Diameter of the Edit tab's colour wheel.
const WHEEL: f32 = 220.0;
/// Height of the rows table, and of either tab (so switching tabs doesn't resize the dialog).
const TABLE_H: f32 = 220.0;
const TAB_H: f32 = 400.0;
const PRESETS: [&str; 5] = ["Custom", "1 Color Job", "2 Color Job", "3 Color Job", "Color Library"];
/// Open Recolor Artwork (`ui.recolorDialog {colors?, library?}`) on the selected art. `colors` is
/// a count (an n-colour job) or the new colours to assign; `library` limits to a library ("" picks
/// the first).
pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let st = app.session.active().ok_or("no document open")?;
let art = !st.selection.is_empty();
if !art {
return Err("select artwork to recolor".into());
}
let assign: Vec<Value> = match p.get("colors") {
Some(Value::Array(cs)) => cs.iter().filter_map(color_value).map(|c| key(&c)).collect(),
_ => vec![],
};
let count = match p.get("colors").and_then(Value::as_u64) {
Some(n) => json!(n.max(1)),
None if !assign.is_empty() => json!(assign.len()),
None => Value::Null,
};
let limit = match p.get("library").and_then(Value::as_str) {
Some("") => first_library(app).unwrap_or_default(),
Some(l) => swatchlib::library(&app.session, l).map(|(info, _)| info.id).ok_or_else(|| format!("no swatch library `{l}`"))?,
None => String::new(),
};
let fields = json!({
"rows": [], "colors": count, "method": Method::ScaleTints.id(),
"preserveWhite": true, "preserveBlack": true, "preserveGrays": false, "limitTo": limit,
"tab": "assign", "rule": Harmony::Complementary.id(), "linked": true,
"preview": true, "__art": art, "__assign": assign,
});
app.ui.dialog = Some(Dialog::new(KIND, fields));
Ok(Value::Null)
}
fn key(c: &Color) -> Value {
json!(ColorKey::of(c).to_string())
}
/// The libraries with colours, for Limit to Library: (id, name).
fn colour_libraries(app: &VectorcraftApp) -> Vec<(String, String)> {
swatchlib::libraries(&app.session).into_iter().filter(|l| l.category != "gradients").map(|l| (l.id, l.name)).collect()
}
fn first_library(app: &VectorcraftApp) -> Option<String> {
colour_libraries(app).into_iter().next().map(|l| l.0)
}
/// `recolor.reduce` parameters (Method only picks each row's colour, so changing it keeps the rows).
fn reduce_params(d: &Dialog) -> Value {
json!({
"colors": d.fields.get("colors").cloned().unwrap_or(Value::Null),
"preserve": {"white": d.bool("preserveWhite"), "black": d.bool("preserveBlack"), "grays": d.bool("preserveGrays")},
"limitTo": d.str("limitTo"),
})
}
/// Reduce the art's colours into rows again when the count, preserve flags or library changed
/// (assigning the opener's colours as the new colours).
fn sync(app: &mut VectorcraftApp, d: &mut Dialog) {
const DONE: &str = "__reduced";
let sig = reduce_params(d);
if !d.bool("__art") || d.fields.get(DONE) == Some(&sig) {
return;
}
let mut p = sig.clone();
p["method"] = json!(d.str("method"));
match app.session.execute("recolor.reduce", &p) {
Ok(r) => {
let mut rows = r["map"].as_array().cloned().unwrap_or_default();
let assign = d.fields.get("__assign").and_then(Value::as_array).cloned().unwrap_or_default();
for (row, to) in rows.iter_mut().zip(assign) {
row["to"] = to;
}
snap(app, d, &mut rows);
d.fields.insert("rows".into(), Value::Array(rows));
d.fields.insert("__sel".into(), json!([]));
}
Err(e) => app.status(e.to_string()),
}
d.fields.insert(DONE.into(), sig);
}
/// With Limit to Library, the rows' new colours snap to the library's nearest colours.
fn snap(app: &VectorcraftApp, d: &Dialog, rows: &mut [Value]) {
let Some((_, lib)) = Some(d.str("limitTo")).filter(|l| !l.is_empty()).and_then(|l| swatchlib::library(&app.session, &l)) else { return };
let palette = Palette::new(lib.iter().filter_map(|w| w.paint.color()));
for r in rows {
if let Some(c) = to_color(r) {
r["to"] = key(&palette.nearest(c));
}
}
}
fn rows(d: &Dialog) -> Vec<Value> {
d.fields.get("rows").and_then(Value::as_array).cloned().unwrap_or_default()
}
fn from_colors(row: &Value) -> Vec<Color> {
row["from"].as_array().map(|a| a.iter().filter_map(color_value).collect()).unwrap_or_default()
}
fn to_color(row: &Value) -> Option<Color> {
color_value(&row["to"])
}
fn excluded(row: &Value) -> bool {
row["exclude"].as_bool().unwrap_or(false)
}
fn selection(d: &Dialog) -> Vec<usize> {
d.fields.get("__sel").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect()).unwrap_or_default()
}
/// `recolor.apply` parameters: the rows, method and library.
fn apply_params(d: &Dialog) -> Value {
json!({"map": rows(d), "method": d.str("method"), "limitTo": d.str("limitTo")})
}
fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
sync(app, d);
let t = Tokens::get(ui.ctx());
ui.horizontal(|ui| {
let tab = d.str("tab");
for (id, label) in [("assign", "Assign"), ("edit", "Edit")] {
if ui.selectable_label(tab == id || (id == "assign" && tab != "edit"), egui::RichText::new(label).font(theme::semibold(12.5))).clicked() {
d.fields.insert("tab".into(), json!(id));
}
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if let Some(i) = widgets::dropdown(ui, "recolor-preset", PRESETS[preset_of(d)], &PRESETS, 130.0) {
set_preset(app, d, i);
}
ui.label(egui::RichText::new("Preset:").color(t.text_dim));
});
});
widgets::divider(ui);
ui.add_space(4.0);
let mut rs = rows(d);
let top = ui.cursor().top();
let changed = if d.str("tab") == "edit" { edit_tab(ui, d, &mut rs) } else { assign_tab(app, ui, d, &mut rs) };
ui.add_space((TAB_H - (ui.cursor().top() - top)).max(0.0));
if changed {
snap(app, d, &mut rs);
d.fields.insert("rows".into(), Value::Array(rs));
}
sync(app, d);
if d.bool("__art") {
form::preview(app, ui, d, "Recolor Artwork", CMD, apply_params(d));
}
false
}
/// Keep the previewed recolour as one undo step (or apply it when no preview is running).
/// OK: keep the previewed recolour as one undo step.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let map = recolor_map(d);
app.ui.dialog = None;
if app.session.in_interaction() {
let _ = app.session.preview("recolor.apply", &json!({"map": map}));
return app.session.commit_interaction().map(|_| Value::Null).map_err(|e| e.to_string());
}
app.run("recolor.apply", json!({"map": map}))
let mut d = d.clone();
sync(app, &mut d);
form::commit_preview(app, CMD, apply_params(&d))
}
fn recolor_map(d: &Dialog) -> Value {
let mut m = serde_json::Map::new();
for p in d.fields.get("pairs").and_then(Value::as_array).cloned().unwrap_or_default() {
if let (Some(a), Some(b)) = (p.get(0).and_then(Value::as_str), p.get(1).and_then(Value::as_str)) {
m.insert(a.to_string(), json!(b));
/// The preset the fields match: an n-colour job (n rows, Scale Tints), a library, or Custom.
fn preset_of(d: &Dialog) -> usize {
match d.fields.get("colors").and_then(Value::as_u64) {
Some(n @ 1..=3) if d.str("method") == Method::ScaleTints.id() => n as usize,
_ if !d.str("limitTo").is_empty() => 4,
_ => 0,
}
}
fn set_preset(app: &VectorcraftApp, d: &mut Dialog, i: usize) {
match i {
1..=3 => {
d.fields.insert("colors".into(), json!(i));
d.fields.insert("method".into(), json!(Method::ScaleTints.id()));
}
4 if d.str("limitTo").is_empty() => {
d.fields.insert("limitTo".into(), json!(first_library(app).unwrap_or_default()));
}
_ => {}
}
Value::Object(m)
}
/// Current → new colour rows, harmony rules, randomize. Returns true when changed.
fn editor(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
use vectorcraft_color::Color;
/// A colour chip with a hairline frame.
fn chip(ui: &Ui, r: Rect, c: Color, dim: bool) {
let t = Tokens::get(ui.ctx());
let fill = c32(&c);
ui.painter().rect_filled(r, 1.0, if dim { fill.gamma_multiply(0.35) } else { fill });
ui.painter().rect_stroke(r, 1.0, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
}
/// The Assign tab. Returns true when the rows changed.
fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut Vec<Value>) -> bool {
let t = Tokens::get(ui.ctx());
let mut pairs: Vec<Value> = d.fields.get("pairs").and_then(Value::as_array).cloned().unwrap_or_default();
let mut changed = false;
let chip = |ui: &mut egui::Ui, hex: &str| {
let (r, _) = ui.allocate_exact_size(egui::vec2(34.0, 22.0), egui::Sense::hover());
let c = Color::from_hex(hex).unwrap_or(Color::BLACK).to_rgba8(1.0);
ui.painter().rect_filled(r, 2.0, egui::Color32::from_rgb(c[0], c[1], c[2]));
ui.painter().rect_stroke(r, 2.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
};
let total: usize = rows.iter().map(|r| r["from"].as_array().map_or(0, Vec::len)).sum();
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Current Colors").color(t.text_dim));
ui.add_space(70.0);
ui.label(egui::RichText::new("Colors:").color(t.text_dim));
let labels: Vec<String> = std::iter::once("Auto".to_string()).chain((1..=total).map(|n| n.to_string())).collect();
let refs: Vec<&str> = labels.iter().map(String::as_str).collect();
let cur = d.fields.get("colors").and_then(Value::as_u64).map_or("Auto".to_string(), |n| n.to_string());
if let Some(i) = widgets::dropdown(ui, "recolor-count", &cur, &refs, 70.0) {
d.fields.insert("colors".into(), if i == 0 { Value::Null } else { json!(i) });
}
ui.add_space(18.0);
ui.label(egui::RichText::new("Preserve:").color(t.text_dim));
for (k, label) in [("preserveWhite", "White"), ("preserveBlack", "Black"), ("preserveGrays", "Grays")] {
let on = d.bool(k);
if widgets::check(ui, label, on, d.bool("__art")) {
d.fields.insert(k.into(), json!(!on));
}
}
});
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.add_space(6.0);
ui.add_sized([CURRENT_W, 16.0], egui::Label::new(egui::RichText::new(format!("Current Colors ({total})")).color(t.text_dim)));
ui.add_space(60.0);
ui.label(egui::RichText::new("New").color(t.text_dim));
});
egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
for p in pairs.iter_mut() {
let from = p[0].as_str().unwrap_or("#000000").to_string();
let mut to = p[1].as_str().unwrap_or("#000000").to_string();
ui.horizontal(|ui| {
chip(ui, &from);
ui.label(egui::RichText::new("→").color(t.text_dim));
chip(ui, &to);
if ui.add(egui::TextEdit::singleline(&mut to).desired_width(80.0)).changed() && Color::from_hex(&to).is_some() {
p[1] = json!(to);
changed = true;
let mut sel = selection(d);
let mut clicked: Option<usize> = None;
widgets::list_box(ui, |ui| {
egui::ScrollArea::vertical().id_salt("recolor-rows").max_height(TABLE_H).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.set_min_height(TABLE_H);
if rows.is_empty() {
ui.add_space(8.0);
widgets::dim_label(ui, "No colors to recolor (preserved colors are left out).");
}
for (i, row) in rows.iter_mut().enumerate() {
let bg = ui.painter().add(egui::Shape::Noop);
let off = excluded(row);
let r = ui.horizontal(|ui| {
ui.add_space(4.0);
let from = from_colors(row);
let (cur, cr) = ui.allocate_exact_size(vec2(CURRENT_W, 20.0), Sense::click());
let w = CURRENT_W / from.len().max(1) as f32;
for (j, c) in from.iter().enumerate() {
let x = cur.left() + j as f32 * w;
chip(ui, Rect::from_min_max(pos2(x, cur.top()), pos2(x + w - if w > 4.0 { 2.0 } else { 0.0 }, cur.bottom())), *c, false);
}
if cr.clicked() {
clicked = Some(i);
}
let (ar, aresp) = ui.allocate_exact_size(vec2(44.0, 20.0), Sense::click());
arrow(ui, ar, !off, aresp.hovered());
if aresp.on_hover_text(if off { "Recolor this row" } else { "Keep this row's colors (exclude)" }).clicked() {
row["exclude"] = json!(!off);
changed = true;
}
let (nr, nresp) = ui.allocate_exact_size(vec2(46.0, 20.0), Sense::click());
let to = to_color(row).unwrap_or_default();
chip(ui, nr, to, off);
if nresp.clicked() {
clicked = Some(i);
}
ui.add_space(6.0);
let hex = crate::panels::color::hex_digits(&to);
if let Some(h) = widgets::hex_field(ui, ("recolor-hex", i), &hex)
&& let Some(c) = crate::panels::color::parse_hex(&h)
{
row["to"] = key(&keep_model(to, c));
changed = true;
}
});
if sel.contains(&i) {
ui.painter().set(bg, egui::Shape::rect_filled(r.response.rect.expand2(vec2(2.0, 1.0)), 2.0, t.row_selected));
}
});
}
});
});
if let Some(i) = clicked {
let m = ui.input(|i| i.modifiers);
match sel.iter().position(|s| *s == i) {
Some(p) if m.shift || m.command => {
sel.remove(p);
}
None if m.shift || m.command => sel.push(i),
_ => sel = vec![i],
}
d.fields.insert("__sel".into(), json!(sel));
}
ui.add_space(4.0);
ui.horizontal(|ui| {
let can_merge = sel.len() > 1;
if widgets::icon_button_enabled(ui, "combine", "Merge colors into one row", false, can_merge, 24.0).clicked() {
merge(rows, &sel);
d.fields.insert("__sel".into(), json!([sel.iter().min()]));
changed = true;
}
let can_split = sel.iter().any(|i| rows.get(*i).is_some_and(|r| r["from"].as_array().is_some_and(|a| a.len() > 1)));
if widgets::icon_button_enabled(ui, "ungroup", "Separate colors into rows", false, can_split, 24.0).clicked() {
separate(rows, &sel);
d.fields.insert("__sel".into(), json!([]));
changed = true;
}
ui.add_space(12.0);
for (icon, tip, sb) in
[("arrow-left-right", "Randomly change color order", false), ("sparkles", "Randomly change saturation and brightness", true)]
{
if widgets::icon_button_enabled(ui, icon, tip, false, rows.len() > 1 || sb, 24.0).clicked() {
let seed = d.f64("__seed", 0.0) as u64 + 1;
d.fields.insert("__seed".into(), json!(seed));
let p = json!({"map": rows, "order": !sb, "saturationBrightness": sb, "seed": seed});
match app.session.execute("recolor.randomize", &p) {
Ok(r) => {
*rows = r["map"].as_array().cloned().unwrap_or_default();
changed = true;
}
Err(e) => app.status(e.to_string()),
}
}
}
if widgets::icon_button(ui, "rotate-ccw", "Reset the rows to the artwork's colors", false, 24.0).clicked() {
d.fields.remove("__reduced");
}
});
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui.button("Randomize").clicked() {
// Deterministic scramble of hues (seeded by the colour itself), keeping brightness.
for (i, p) in pairs.iter_mut().enumerate() {
let c = Color::from_hex(p[0].as_str().unwrap_or("#000")).unwrap_or(Color::BLACK);
let [h, s, v] = c.to_hsb();
let nh = (h + 97.0 * (i as f32 + 1.0) + 41.0) % 360.0;
p[1] = json!(Color::from_hsb(nh, s.max(0.35), v.max(0.3)).to_hex());
}
changed = true;
egui::Grid::new("recolor-options").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| {
ui.label(egui::RichText::new("Method:").color(t.text_dim));
let m = Method::parse(&d.str("method")).unwrap_or_default();
let labels: Vec<&str> = Method::ALL.iter().map(|m| m.label()).collect();
if let Some(i) = widgets::dropdown(ui, "recolor-method", m.label(), &labels, 180.0) {
d.fields.insert("method".into(), json!(Method::ALL[i].id()));
}
for (label, deg) in [("Complement", 180.0), ("Triad", 120.0), ("Analogous", 30.0)] {
if ui.button(label).clicked() {
for (i, p) in pairs.iter_mut().enumerate() {
let c = Color::from_hex(p[0].as_str().unwrap_or("#000")).unwrap_or(Color::BLACK);
let [h, s, v] = c.to_hsb();
let shift = if label == "Complement" { deg } else { deg * (i as f32 + 1.0) };
p[1] = json!(Color::from_hsb(h + shift, s, v).to_hex());
}
ui.end_row();
ui.label(egui::RichText::new("Limit to Library:").color(t.text_dim));
let libs = colour_libraries(app);
let cur = d.str("limitTo");
let name = libs.iter().find(|l| l.0 == cur).map_or("None", |l| l.1.as_str());
let labels: Vec<&str> = std::iter::once("None").chain(libs.iter().map(|l| l.1.as_str())).collect();
if let Some(i) = widgets::dropdown(ui, "recolor-library", name, &labels, 180.0) {
d.fields.insert("limitTo".into(), json!(if i == 0 { String::new() } else { libs[i - 1].0.clone() }));
}
ui.end_row();
});
changed
}
/// The arrow between a row's current and new colours: solid when the row is recoloured, a dashed
/// line without a head when it is excluded.
fn arrow(ui: &Ui, r: Rect, on: bool, hovered: bool) {
let t = Tokens::get(ui.ctx());
let c = if hovered {
t.text_strong
} else if on {
t.text
} else {
t.text_disabled
};
let (a, b) = (pos2(r.left() + 6.0, r.center().y), pos2(r.right() - 6.0, r.center().y));
let stroke = egui::Stroke::new(1.2, c);
if on {
ui.painter().line_segment([a, b], stroke);
ui.painter().line_segment([b, b + vec2(-5.0, -4.0)], stroke);
ui.painter().line_segment([b, b + vec2(-5.0, 4.0)], stroke);
} else {
ui.painter().extend(egui::Shape::dashed_line(&[a, b], stroke, 3.0, 3.0));
}
}
/// Merge the selected rows into the first of them (its new colour stays).
fn merge(rows: &mut Vec<Value>, sel: &[usize]) {
let mut sel: Vec<usize> = sel.iter().copied().filter(|i| *i < rows.len()).collect();
sel.sort_unstable();
let Some((&first, rest)) = sel.split_first() else { return };
let mut moved = vec![];
for &i in rest.iter().rev() {
let row = rows.remove(i);
moved.splice(0..0, row["from"].as_array().cloned().unwrap_or_default());
}
if let Some(dst) = rows[first]["from"].as_array_mut() {
dst.extend(moved);
}
}
/// Split the selected rows into one row per colour, each its own new colour.
fn separate(rows: &mut Vec<Value>, sel: &[usize]) {
let mut out = vec![];
for (i, row) in rows.drain(..).enumerate() {
match row["from"].as_array() {
Some(a) if sel.contains(&i) && a.len() > 1 => out.extend(a.iter().map(|k| json!({"from": [k], "to": k}))),
_ => out.push(row),
}
}
*rows = out;
}
/// Position of hue `h` (degrees) and saturation `s` on a wheel of radius `r` around `c`.
fn wheel_pos(c: Pos2, r: f32, h: f32, s: f32) -> Pos2 {
let a = h.to_radians();
c + vec2(a.cos(), -a.sin()) * r * s
}
/// The hue and saturation wheel at brightness `v`: hue around, saturation outwards.
fn wheel_shape(c: Pos2, r: f32, v: f32) -> egui::Shape {
const SEG: u32 = 72;
const RINGS: u32 = 8;
let mut mesh = egui::Mesh::default();
mesh.colored_vertex(c, c32(&Color::from_hsb(0.0, 0.0, v)));
for ring in 1..=RINGS {
let s = ring as f32 / RINGS as f32;
for k in 0..SEG {
let h = k as f32 * 360.0 / SEG as f32;
mesh.colored_vertex(wheel_pos(c, r, h, s), c32(&Color::from_hsb(h, s, v)));
}
}
let at = |ring: u32, k: u32| if ring == 0 { 0 } else { 1 + (ring - 1) * SEG + k % SEG };
for ring in 0..RINGS {
for k in 0..SEG {
if ring == 0 {
mesh.add_triangle(0, at(1, k), at(1, k + 1));
} else {
mesh.add_triangle(at(ring, k), at(ring + 1, k), at(ring + 1, k + 1));
mesh.add_triangle(at(ring, k), at(ring + 1, k + 1), at(ring, k + 1));
}
}
}
egui::Shape::mesh(mesh)
}
/// Set row `i`'s new colour to hue/saturation/brightness `hsb` (keeping its model); with `linked`
/// every other row turns by the same hue, scales its saturation and shifts its brightness alike.
/// Excluded rows stay.
fn move_color(rows: &mut [Value], i: usize, hsb: [f32; 3], linked: bool) {
let Some(old) = rows.get(i).and_then(to_color) else { return };
let [h0, s0, v0] = old.to_hsb();
let (dh, ratio, dv) = (hsb[0] - h0, if s0 > 0.01 { hsb[1] / s0 } else { 1.0 }, hsb[2] - v0);
for (j, row) in rows.iter_mut().enumerate() {
let Some(c) = to_color(row).filter(|_| !excluded(row)) else { continue };
let new = if j == i {
hsb
} else if linked {
let [h, s, v] = c.to_hsb();
[h + dh, (s * ratio).clamp(0.0, 1.0), (v + dv).clamp(0.0, 1.0)]
} else {
continue;
};
row["to"] = key(&keep_model(c, Color::from_hsb(new[0], new[1], new[2])));
}
}
/// The Edit tab. Returns true when the rows changed.
fn edit_tab(ui: &mut Ui, d: &mut Dialog, rows: &mut [Value]) -> bool {
let t = Tokens::get(ui.ctx());
let mut changed = false;
let linked = d.bool("linked");
let active: Vec<usize> = (0..rows.len()).filter(|i| !excluded(&rows[*i]) && to_color(&rows[*i]).is_some()).collect();
let base = selection(d).into_iter().find(|i| active.contains(i)).or(active.first().copied());
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Harmony Rules:").color(t.text_dim));
let rule = Harmony::parse(&d.str("rule")).unwrap_or(Harmony::Complementary);
let labels: Vec<&str> = Harmony::ALL.iter().map(|h| h.label()).collect();
// Choosing a rule gives the new colours its colours, from the base colour.
if let Some(i) = widgets::dropdown(ui, "recolor-rule", rule.label(), &labels, 170.0) {
d.fields.insert("rule".into(), json!(Harmony::ALL[i].id()));
if let Some((b, c)) = base.and_then(|b| Some((b, to_color(&rows[b])?))) {
apply_rule(rows, Harmony::ALL[i], b, c, &active);
changed = true;
}
}
if ui.button("Reset").clicked() {
for p in pairs.iter_mut() {
p[1] = p[0].clone();
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let tip = if linked { "Unlink harmony colors" } else { "Link harmony colors" };
if widgets::icon_button(ui, if linked { "link" } else { "link-2-off" }, tip, linked, 24.0).clicked() {
d.fields.insert("linked".into(), json!(!linked));
}
});
});
ui.add_space(8.0);
let v = base.and_then(|b| to_color(&rows[b])).map_or(1.0, |c| c.to_hsb()[2]);
ui.horizontal(|ui| {
ui.add_space(((ui.available_width() - WHEEL) / 2.0).max(0.0));
let (rect, resp) = ui.allocate_exact_size(vec2(WHEEL, WHEEL), Sense::click_and_drag());
let (c, r) = (rect.center(), WHEEL / 2.0 - 10.0);
let painter = ui.painter_at(rect);
painter.add(wheel_shape(c, r, v.max(0.15)));
painter.circle_stroke(c, r, egui::Stroke::new(1.0, t.border));
let at = |rows: &[Value], i: usize| {
to_color(&rows[i]).map(|col| {
let [h, s, _] = col.to_hsb();
wheel_pos(c, r, h, s)
})
};
for &i in &active {
let (Some(p), Some(col)) = (at(rows, i), to_color(&rows[i])) else { continue };
let big = Some(i) == base;
painter.line_segment([c, p], egui::Stroke::new(1.0, egui::Color32::from_white_alpha(160)));
painter.circle(p, if big { 8.0 } else { 5.5 }, c32(&col), egui::Stroke::new(1.5, egui::Color32::WHITE));
painter.circle_stroke(p, if big { 9.0 } else { 6.5 }, egui::Stroke::new(1.0, egui::Color32::BLACK));
}
// Drag a marker: the nearest one within reach of the press.
let id = ui.id().with("recolor-wheel-drag");
if (resp.drag_started() || resp.clicked())
&& let Some(p) = resp.interact_pointer_pos()
{
let near = active.iter().filter_map(|&i| Some((i, at(rows, i)?.distance(p)))).filter(|x| x.1 <= 14.0).min_by(|a, b| a.1.total_cmp(&b.1));
ui.data_mut(|m| m.insert_temp(id, near.map(|x| x.0)));
if let Some((i, _)) = near {
d.fields.insert("__sel".into(), json!([i]));
}
}
if resp.dragged()
&& let (Some(i), Some(p)) = (ui.data(|m| m.get_temp::<Option<usize>>(id)).flatten(), resp.interact_pointer_pos())
&& let Some(old) = to_color(&rows[i])
{
let dv = p - c;
let h = (-dv.y).atan2(dv.x).to_degrees().rem_euclid(360.0);
move_color(rows, i, [h, (dv.length() / r).clamp(0.0, 1.0), old.to_hsb()[2]], linked);
changed = true;
}
});
d.fields.insert("pairs".into(), Value::Array(pairs));
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.add_space(((ui.available_width() - WHEEL - 76.0) / 2.0).max(0.0));
ui.add_sized([70.0, 22.0], egui::Label::new(egui::RichText::new("Brightness").color(t.text_dim)));
if let Some(b) = base
&& let Some(col) = to_color(&rows[b])
{
let [h, s, _] = col.to_hsb();
if let (Some(nv), _) = widgets::color_slider(ui, "recolor-brightness", v, WHEEL, &|x| c32(&Color::from_hsb(h, s, x))) {
move_color(rows, b, [h, s, nv], linked);
changed = true;
}
}
});
ui.add_space(6.0);
// The new colours: click one to make it the base colour.
ui.horizontal_wrapped(|ui| {
for &i in &active {
let (r, resp) = ui.allocate_exact_size(vec2(28.0, 20.0), Sense::click());
chip(ui, r, to_color(&rows[i]).unwrap_or_default(), false);
if Some(i) == base {
ui.painter().rect_stroke(r.expand(1.5), 2.0, egui::Stroke::new(1.5, t.accent), egui::StrokeKind::Outside);
}
if resp.on_hover_text(rows[i]["to"].as_str().unwrap_or_default()).clicked() {
d.fields.insert("__sel".into(), json!([i]));
}
}
});
changed
}
/// Give the rows `active` the colours of `rule` from the base colour `b` of row `base` (the base row
/// first, then the others in order, the rule's colours repeating), each in its row's model.
fn apply_rule(rows: &mut [Value], rule: Harmony, base: usize, b: Color, active: &[usize]) {
let colors = rule.apply(b);
let order = std::iter::once(base).chain(active.iter().copied().filter(|i| *i != base));
for (n, i) in order.enumerate() {
let old = to_color(&rows[i]).unwrap_or(b);
rows[i]["to"] = key(&keep_model(old, colors[n % colors.len()]));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rows() -> Vec<Value> {
vec![
json!({"from": ["rgb 255 0 0"], "to": "rgb 255 0 0"}),
json!({"from": ["rgb 0 0 255", "rgb 0 0 128"], "to": "rgb 0 0 255"}),
json!({"from": ["rgb 0 255 0"], "to": "rgb 0 255 0"}),
]
}
#[test]
fn merge_and_separate_rows() {
let mut r = rows();
merge(&mut r, &[2, 0]);
assert_eq!(r.len(), 2);
assert_eq!(r[0]["from"], json!(["rgb 255 0 0", "rgb 0 255 0"]));
assert_eq!(r[0]["to"], "rgb 255 0 0", "the first row's new colour stays");
separate(&mut r, &[1]);
assert_eq!(r.len(), 3);
assert_eq!(r[1], json!({"from": ["rgb 0 0 255"], "to": "rgb 0 0 255"}));
assert_eq!(r[2]["to"], "rgb 0 0 128");
}
#[test]
fn linked_handles_move_together() {
let mut r = rows();
move_color(&mut r, 0, [120.0, 1.0, 1.0], false);
assert_eq!(r[0]["to"], "rgb 0 255 0");
assert_eq!(r[1]["to"], "rgb 0 0 255", "unlinked: only that colour");
let mut r = rows();
move_color(&mut r, 0, [120.0, 1.0, 1.0], true);
assert_eq!(r[1]["to"], "rgb 255 0 0", "linked: every colour turns by the same hue");
assert_eq!(r[2]["to"], "rgb 0 0 255");
// Excluded rows stay.
let mut r = rows();
r[2]["exclude"] = json!(true);
move_color(&mut r, 0, [120.0, 1.0, 1.0], true);
assert_eq!(r[2]["to"], "rgb 0 255 0");
}
#[test]
fn harmony_rules_fill_the_rows_from_the_base() {
let mut r = rows();
apply_rule(&mut r, Harmony::Triad, 0, Color::rgb(1.0, 0.0, 0.0), &[0, 1, 2]);
assert_eq!([&r[0]["to"], &r[1]["to"], &r[2]["to"]], ["rgb 255 0 0", "rgb 0 255 0", "rgb 0 0 255"]);
}
}

View File

@ -218,6 +218,7 @@ fn dialogs_do_not_stretch_to_the_screen() {
("ui.spotColors", json!({})),
("ui.paramDialog", json!({"command": "object.path.simplify", "label": "Simplify", "params": {"tolerance": 2}})),
("effect.dialog", json!({"effect": "distort.roughen"})),
("ui.recolorDialog", json!({})),
];
let height = |id: &str, p: &serde_json::Value, screen_h: f32| -> Option<(String, f32)> {
let mut app = app();

View File

@ -39,6 +39,8 @@ mod tests_overprint;
#[cfg(test)]
mod tests_paintchips;
#[cfg(test)]
mod tests_recolor;
#[cfg(test)]
mod tests_transparencygrid;
use std::sync::mpsc::{Receiver, Sender};

View File

@ -158,7 +158,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"{effect: id, index?: int (edit that applied effect in place, prefilled; OK runs effect.setParams), item?: appearance item index|null (the fill/stroke whose effects, null the object's; default: the Appearance panel's active item)} open the effect's dialog with live preview; without `index`, an effect already in that list first opens `effectExists` (confirm edits it, discard adds another) → {pending: \"effectExists\"}",
),
("ui.paramDialog", "Command Dialog", "", "{command, label?, params} open a parameter dialog for any command"),
("ui.recolorDialog", "Recolor Artwork…", "", "{} open Recolor Artwork (engine: recolor.colors / recolor.apply)"),
(
"ui.recolorDialog",
"Recolor Artwork…",
"",
"{colors?: n (an n-colour job: n rows, Scale Tints) | [colour] (new colours to assign, in order), library?: id or name (Limit to Library; \"\" the first library)} open Recolor Artwork (dialog `recolor`; engine: recolor.reduce / recolor.apply)",
),
("effect.applyLast", "Apply Last Effect", "Cmd+Shift+E", "{}"),
("file.export.pdf", "Save as PDF…", "", "{path?, artboard? | artboards? | range?: \"1-3, 5\"} (document.export options)"),
("help.about", "About VectorCraft", "", "{}"),
@ -604,14 +609,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
Ok(app.session.tool_options())
}
"effect.dialog" => crate::dialogs::open_effect_dialog(app, p),
"ui.recolorDialog" => match app.run("recolor.colors", json!({})) {
Ok(v) => {
let pairs: Vec<Value> = v["colors"].as_array().cloned().unwrap_or_default().iter().map(|c| json!([c["hex"], c["hex"]])).collect();
app.ui.dialog = Some(crate::state::Dialog::new("recolor", json!({"pairs": pairs, "preview": true})));
Ok(Value::Null)
}
Err(e) => Err(e),
},
"ui.recolorDialog" => crate::dialogs::recolor::open(app, p),
"ui.paramDialog" => {
let cmd = s("command").unwrap_or_default();
let mut fields = p.get("params").and_then(Value::as_object).cloned().unwrap_or_default();
@ -990,6 +988,15 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
"Edit Colors",
vec![
c("Recolor Artwork…", "ui.recolorDialog"),
sub(
"Recolor with Preset",
vec![
cp("1 Color Job…", "ui.recolorDialog", json!({"colors": 1})),
cp("2 Color Job…", "ui.recolorDialog", json!({"colors": 2})),
cp("3 Color Job…", "ui.recolorDialog", json!({"colors": 3})),
cp("Color Library…", "ui.recolorDialog", json!({"library": ""})),
],
),
c("Adjust Color Balance…", "ui.colorBalanceDialog"),
c("Blend Front to Back", "edit.colors.blendFrontToBack"),
c("Blend Horizontally", "edit.colors.blendHorizontally"),

View File

@ -133,7 +133,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
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() - 3.0 * 28.0).max(0.0));
widgets::icon_button_enabled(ui, "palette", "Edit or Apply Colors (on the roadmap)", false, false, 24.0);
// Recolor Artwork with the harmony colours as the new colours (the selected art's, if any).
let art = app.session.active().is_some_and(|d| !d.selection.is_empty());
if widgets::icon_button_enabled(ui, "palette", "Edit or Apply Colors", false, art, 24.0).clicked() {
app.run("ui.recolorDialog", json!({ "colors": g.colors.iter().map(color_json).collect::<Vec<_>>() })).ok();
}
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);
}

View File

@ -0,0 +1,111 @@
//! Recolor Artwork (M3.78), drawn headlessly.
use egui::{Event, Pos2, Rect, vec2};
use serde_json::{Value, json};
use vectorcraft_engine::Session;
use crate::{VectorcraftApp, dialogs};
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 200})).unwrap();
app
}
fn context() -> egui::Context {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
ctx
}
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<Event>, time: f64, draw: fn(&mut VectorcraftApp, &mut egui::Ui)) {
let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(900.0, 800.0)));
let input = egui::RawInput { events, time: Some(time), screen_rect, ..Default::default() };
let mut out = ctx.run_ui(input, |ui| draw(app, ui));
out.textures_delta.clear();
}
fn dialog_frame(app: &mut VectorcraftApp, ctx: &egui::Context) {
let time = ctx.input(|i| i.time) + 1.0;
frame(app, ctx, vec![], time, |app, ui| dialogs::show(app, ui.ctx()));
}
/// Rectangles filled with `colors`, all selected.
fn art(app: &mut VectorcraftApp, colors: &[&str]) {
for (i, c) in colors.iter().enumerate() {
let id = app.run("shape.rectangle", json!({"x": 10 + 30 * i, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone();
app.run("paint.setFill", json!({"ids": [id], "color": c})).unwrap();
app.run("paint.setStroke", json!({"ids": [id], "none": true})).unwrap();
}
app.run("select.all", json!({})).unwrap();
}
fn colors(app: &mut VectorcraftApp) -> usize {
app.session.execute("recolor.colors", &json!({})).unwrap()["colors"].as_array().unwrap().len()
}
fn field(app: &VectorcraftApp, k: &str) -> Value {
app.ui.dialog.as_ref().and_then(|d| d.fields.get(k).cloned()).unwrap_or(Value::Null)
}
fn set(app: &mut VectorcraftApp, k: &str, v: Value) {
app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v);
}
#[test]
fn the_dialog_reduces_previews_and_applies_as_one_undo_step() {
let mut app = app();
let ctx = context();
art(&mut app, &["#ff0000", "#e01010", "#0000ff", "#1010e0", "#ff3030"]);
app.run("ui.recolorDialog", json!({})).unwrap();
dialog_frame(&mut app, &ctx);
assert_eq!(field(&app, "rows").as_array().unwrap().len(), 5, "Auto: a row per colour");
assert!(app.session.in_interaction(), "the dialog previews");
// Two colours, Exact; OK without another frame still reduces first.
set(&mut app, "colors", json!(2));
set(&mut app, "method", json!("exact"));
let undo = app.session.doc().unwrap().history.undo.len();
dialogs::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
assert_eq!(colors(&mut app), 2);
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(colors(&mut app), 5);
// Cancel rolls the preview back.
app.run("ui.recolorDialog", json!({"colors": 1})).unwrap();
set(&mut app, "method", json!("exact"));
dialog_frame(&mut app, &ctx);
assert_eq!(colors(&mut app), 1, "previewed");
dialogs::cancel(&mut app);
assert_eq!(colors(&mut app), 5);
}
#[test]
fn presets_open_colour_jobs_and_libraries_and_both_tabs_draw() {
let mut app = app();
let ctx = context();
art(&mut app, &["#ff0000", "#00ff00", "#0000ff"]);
let entries = crate::menus::menu_entries(&app);
let presets: Vec<(&str, &Value)> =
entries.iter().filter(|e| e.path.last().is_some_and(|p| p == "Recolor with Preset")).map(|e| (e.label.as_str(), &e.params)).collect();
assert_eq!(presets.iter().map(|p| p.0).collect::<Vec<_>>(), ["1 Color Job…", "2 Color Job…", "3 Color Job…", "Color Library…"]);
app.run("ui.recolorDialog", presets[1].1.clone()).unwrap();
dialog_frame(&mut app, &ctx);
assert_eq!((field(&app, "colors"), field(&app, "method")), (json!(2), json!("scaleTints")));
assert_eq!(field(&app, "rows").as_array().unwrap().len(), 2);
set(&mut app, "tab", json!("edit"));
dialog_frame(&mut app, &ctx);
dialogs::cancel(&mut app);
app.run("ui.recolorDialog", presets[3].1.clone()).unwrap();
assert_eq!(field(&app, "limitTo"), "web-safe-216", "Color Library starts on the first library");
dialog_frame(&mut app, &ctx);
let web = |k: &Value| vectorcraft_engine::cmd::color_value(k).unwrap().to_rgb().iter().all(|v| ((v * 5.0).round() - v * 5.0).abs() < 1e-3);
assert!(field(&app, "rows").as_array().unwrap().iter().all(|r| web(&r["to"])));
dialogs::cancel(&mut app);
// The Color Guide's colours become the new colours.
app.run("ui.recolorDialog", json!({"colors": ["#123456", "#abcdef"]})).unwrap();
dialog_frame(&mut app, &ctx);
let tos: Vec<Value> = field(&app, "rows").as_array().unwrap().iter().map(|r| r["to"].clone()).collect();
assert_eq!(tos, [json!("rgb 18 52 86"), json!("rgb 171 205 239")]);
dialogs::cancel(&mut app);
}

View File

@ -82,6 +82,13 @@ canvas while open; `ui.dialog.confirm` keeps the result as one undo step (`edit.
`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.
Recolor Artwork: `ui.recolorDialog {colors?, library?}` opens the `recolor` dialog on the selected art (`colors`: a
count for an n-colour job, or colours to assign as the new colours; `library`: Limit to Library, `""` for the first).
Fields: `rows` (`[{from: [keys], to: key, exclude?}]` as `recolor.reduce` returns them), `colors` (null for Auto),
`method`, `preserveWhite`, `preserveBlack`, `preserveGrays`, `limitTo`, `tab` (`assign` or `edit`), `rule` (harmony
id), `linked`, `preview`. Setting `colors`, a preserve flag or `limitTo` reduces the rows again (also on
`ui.dialog.confirm`). The dialog previews `recolor.apply` on the canvas; confirm keeps it as one undo step.
Library panel: `engine.execute {command: "window.swatchLibrary", params: {library}}` opens the read-only library
panel on a swatch library (`ui.inspect` shows it as `library_panel: {kind, id}`; `library: null` closes it).
Clicking a swatch there runs `swatch.library.add {library, names: [name], apply}` (the active proxy, or the other

View File

@ -317,6 +317,24 @@ patterns out. The Blend commands also grade gradient meshes, which keep their sh
{"name":"run_command","arguments":{"command":"edit.colors.adjustBalance","params":{"mode":"cmyk","m":-20,"k":10}}}
```
Recolor Artwork keys colours by identity, not hex: `recolor.colors` lists each colour's `key` (`"rgb 255 0 0"`,
`"cmyk 0 100 100 0"`, `"gray 40"`, `"lab 55 60 -40"`; RGB in 0–255 levels, CMYK and Gray in percent), so CMYK,
Lab and RGB colours that look alike stay apart, and every colour parameter also accepts a key. `recolor.reduce {colors?, method?,
preserve?, limitTo?}` groups the selection's colours into rows of similar colours (k-means in Lab, weighted by use;
tints of a global swatch share its row; White and Black are preserved by default) and returns a map of rows
`[{from: [keys], to: key}]` to edit and pass to `recolor.apply {map, method?, limitTo?}`.
`method` says how a row's colours take its new colour: `exact`, `preserveTints` (tints of the row's darkest colour
stay tints), `scaleTints` (every colour becomes a tint as light, relative to the darkest), `tintsShades` (lighter and
darker than the row's average become tints and shades) or `hueShift` (the most saturated colour takes the new colour,
the others turn by the same hue). `limitTo` snaps new colours to a swatch library's nearest colour;
`recolor.randomize {map, order?, saturationBrightness?, seed?}` shuffles or varies new colours; a row with
`exclude: true` keeps its colours.
```json
{"name":"run_command","arguments":{"command":"recolor.reduce","params":{"colors":2,"preserve":{"grays":true}}}}
{"name":"run_command","arguments":{"command":"recolor.apply","params":{"map":[{"from":["cmyk 0 100 100 0","cmyk 0 40 40 0"],"to":"cmyk 100 50 0 0"}],"method":"scaleTints"}}}
```
## Group, layer and type appearance
Groups and layers carry fills, strokes and effects of their own, as in the reference app: their fills and strokes