Merge pull request #56 from storytold/pr/m3-recolor

M3: Recolor Artwork reduction, methods, presets and Edit tab; Edit Color Group (M3.78, M3.79)
This commit is contained in:
BFlat
2026-10-05 04:28:14 +07:00
committed by GitHub
17 changed files with 2221 additions and 116 deletions
+1
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
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");
}
}
+5 -3
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")) {
+302 -25
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}",
has_selection,
"{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), group?: colour group rewritten with groupColors?: [colour] (default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces) and the group, as one undo step → {changed}",
has_doc,
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,258 @@ 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 group = str_param(p, "group");
if ids.is_empty() && group.is_none() {
return Err(bad(C, "select artwork to recolour, or give a colour `group`"));
}
let map = ColorMap::new(&rows, method);
let f = |c: Color| map.map(c);
let scope = Scope { fill: true, stroke: true, ..Scope::of(p) };
let changed = s.edit("Recolor Artwork", |d, _| Ok(Recolor::new(scope, &f).run(d, &ids)))?;
let label = if ids.is_empty() { "Edit Color Group" } else { "Recolor Artwork" };
let new: Vec<Color> = match p.get("groupColors").and_then(Value::as_array) {
Some(cs) => cs
.iter()
.map(|v| {
color_value(v).map(|c| palette.as_ref().map_or(c, |pl| pl.nearest(c))).ok_or_else(|| bad(C, format!("bad colour {v} in groupColors")))
})
.collect::<Result<_>>()?,
None => rows.iter().filter_map(Row::new_color).collect(),
};
let rename = str_param(p, "rename");
let (changed, edit) = s.edit(label, |d, _| {
let changed = Recolor::new(scope, &f).run(d, &ids);
let edit = group.map(|g| super::swatch::rewrite_group(d, g, &new, rename, C)).transpose()?;
Ok((changed, edit))
})?;
if let Some(e) = edit {
e.defaults(s);
}
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 }))
}
+84
View File
@@ -122,6 +122,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
swatch_spot_options
),
cmd!(
"swatch.editGroup",
"Edit Color Group",
["Window", "Swatches"],
None,
"{group: colour group, colors: [colour] (hex, key, {c,m,y,k} or {l,a,b}; the group's swatches take them in order, extra colours become new swatches, swatches past the end are removed; built-in swatches can't change), rename?: new group name} rewrite a colour group as one undo step; art linked to its global swatches takes their new colours (and keeps its colour, unlinked, when its swatch goes) → {name, swatches: [names], relinked}",
has_doc,
swatch_edit_group
),
]
}
@@ -770,3 +779,78 @@ fn follow_bases(bases: &[(String, Color)]) -> impl FnMut(&mut Color, &mut Option
std::mem::replace(c, new) != new
}
}
/// A colour group rewrite: its name afterwards and the swatch links it changed.
pub(super) struct GroupEdit {
name: String,
swatches: Vec<String>,
relinks: Vec<Relink>,
relinked: usize,
}
impl GroupEdit {
/// Apply the link changes to the default fill and stroke for new art.
pub(super) fn defaults(&self, s: &mut Session) {
self.relinks.iter().for_each(|r| r.defaults(s));
}
}
/// Rewrite colour group `group` with `colors`: its swatches take them in order (keeping their
/// names, kinds and global or spot flags), extra colours are added as new swatches and swatches
/// past the end are removed. Paints linked to its global swatches follow (a removed swatch's are
/// unlinked); `rename` renames the group. A built-in swatch (None, Registration) it would change or
/// remove is refused.
pub(super) fn rewrite_group(d: &mut Document, group: &str, colors: &[Color], rename: Option<&str>, cmd: &str) -> Result<GroupEdit> {
let gi = d.swatch_groups.iter().position(|g| g.name == group).ok_or_else(|| bad(cmd, format!("no colour group `{group}`")))?;
let name = match rename.map(str::trim).filter(|n| !n.is_empty() && *n != group) {
Some(n) => d.free_swatch_name(n),
None => group.to_string(),
};
let old = &d.swatch_groups[gi].swatches;
if let Some(w) = old.iter().enumerate().find(|(i, w)| w.is_reserved() && colors.get(*i).is_none_or(|c| w.paint.color() != Some(*c))).map(|x| x.1)
{
return Err(bad(cmd, format!("`{}` can't be edited or removed", w.name)));
}
let mut relinks = vec![];
let mut swatches = std::mem::take(&mut d.swatch_groups[gi].swatches);
for gone in swatches.split_off(colors.len().min(swatches.len())) {
if gone.global {
relinks.push(Relink { from: gone.name.clone(), to: gone.name, color: None, keep: false });
}
}
for (w, c) in swatches.iter_mut().zip(colors) {
if w.paint.color() != Some(*c) {
// A tint swatch becomes a colour of its own.
w.paint = Paint::solid(*c);
if w.global {
// Art linked to a Lab spot shows it as the Spot Colors options say.
relinks.push(Relink { from: w.name.clone(), to: w.name.clone(), color: Some(d.linked_color(*c, w.spot)), keep: true });
}
}
}
for c in colors.iter().skip(swatches.len()) {
let nm = unique_name(&color_name(*c), |n| n == name || d.swatch_name_taken(n) || swatches.iter().any(|w| w.name == n));
swatches.push(Swatch { name: nm, paint: Paint::solid(*c), global: false, spot: false });
}
let g = &mut d.swatch_groups[gi];
(g.name, g.swatches) = (name.clone(), swatches);
let names = g.swatches.iter().map(|w| w.name.clone()).collect();
let relinked = if relinks.is_empty() { 0 } else { d.map_solid_paints(&mut |c, l, t| relinks.iter().any(|r| r.apply(c, l, t))) };
Ok(GroupEdit { name, swatches: names, relinks, relinked })
}
fn swatch_edit_group(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "swatch.editGroup";
let group = str_param(p, "group").ok_or_else(|| bad(C, "missing `group`"))?;
let colors: Vec<Color> = p
.get("colors")
.and_then(Value::as_array)
.ok_or_else(|| bad(C, "missing `colors`"))?
.iter()
.map(|v| color_value(v).ok_or_else(|| bad(C, format!("bad colour {v}"))))
.collect::<Result<_>>()?;
let rename = str_param(p, "rename");
let e = s.edit("Edit Color Group", |d, _| rewrite_group(d, group, &colors, rename, C))?;
e.defaults(s);
Ok(json!({"name": e.name, "swatches": e.swatches, "relinked": e.relinked}))
}
+2
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;
+305
View File
@@ -0,0 +1,305 @@
//! Recolor Artwork (M3.78): colour reduction, preserve rules, recolour methods, Limit to Library,
//! randomize; Edit Color Group (M3.79).
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());
run(&mut s, "select.none", json!({}));
assert!(s.execute("recolor.apply", &json!({"map": {}})).is_err(), "needs art or a group");
}
#[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");
}
#[test]
fn edit_group_rewrites_in_order_as_one_undo_step() {
let mut s = session();
let g = run(&mut s, "swatch.newGroup", json!({"name": "Theme", "colors": ["#ff0000", "#00ff00", "#0000ff"]}));
let names = keys(&g["swatches"]);
let group = |s: &Session| {
let d = &s.doc().unwrap().doc;
let g = d.swatch_groups.iter().find(|g| g.name == "Theme" || g.name == "Brand Theme").unwrap();
(g.name.clone(), g.swatches.iter().map(|w| (w.name.clone(), w.paint.color().unwrap().to_hex())).collect::<Vec<_>>())
};
let before = group(&s);
let r = run(
&mut s,
"swatch.editGroup",
json!({"group": "Theme", "colors": ["#111111", "cmyk 0 0 0 50", "#333333", "#444444"], "rename": "Brand Theme"}),
);
assert_eq!(r["name"], "Brand Theme");
let (name, sw) = group(&s);
assert_eq!(name, "Brand Theme");
assert_eq!(sw.iter().map(|w| w.1.as_str()).collect::<Vec<_>>()[..1], ["#111111"]);
assert_eq!(sw.len(), 4, "an extra colour becomes a new swatch");
assert_eq!(sw[..3].iter().map(|w| w.0.clone()).collect::<Vec<_>>(), names, "the swatches keep their names, in order");
assert!(matches!(s.doc().unwrap().doc.swatch(&names[1]).unwrap().paint.color(), Some(Color::Cmyk { .. })));
run(&mut s, "edit.undo", json!({}));
assert_eq!(group(&s), before, "one undo step");
// Fewer colours drop the swatches past the end.
run(&mut s, "swatch.editGroup", json!({"group": "Theme", "colors": ["#ababab"]}));
assert_eq!(group(&s).1.len(), 1);
assert!(s.execute("swatch.editGroup", &json!({"group": "Nope", "colors": []})).is_err());
}
#[test]
fn edit_group_relinks_art_and_apply_rewrites_the_group_with_the_art() {
let mut s = session();
run(&mut s, "swatch.newGroup", json!({"name": "Theme", "colors": ["#ff0000"]}));
let name = s.doc().unwrap().doc.swatch_groups.iter().find(|g| g.name == "Theme").unwrap().swatches[0].name.clone();
run(&mut s, "swatch.edit", json!({"name": name, "global": true}));
let linked = rect(&mut s, json!("#000000"));
run(&mut s, "paint.setFill", json!({"ids": [linked.0], "swatch": name}));
let r = run(&mut s, "swatch.editGroup", json!({"group": "Theme", "colors": ["#00ff00"]}));
assert_eq!(r["relinked"], 1);
assert_eq!(fill(&s, linked).to_hex(), "#00ff00", "linked art follows its swatch");
// Recolor with a group: the art and the group change in one undo step.
let plain = rect(&mut s, json!("#0000ff"));
run(&mut s, "select.set", json!({"ids": [plain.0]}));
let undo = s.doc().unwrap().history.undo.len();
run(&mut s, "recolor.apply", json!({"map": [{"from": ["#0000ff"], "to": "#ffff00"}], "group": "Theme"}));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
assert_eq!(fill(&s, plain).to_hex(), "#ffff00");
assert_eq!(s.doc().unwrap().doc.swatch(&name).unwrap().paint.color().unwrap().to_hex(), "#ffff00");
// Without a selection, only the group.
run(&mut s, "select.none", json!({}));
run(&mut s, "recolor.apply", json!({"map": [{"from": ["#ffff00"], "to": "#ff00ff"}], "group": "Theme"}));
assert_eq!(s.doc().unwrap().doc.swatch(&name).unwrap().paint.color().unwrap().to_hex(), "#ff00ff");
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Edit Color Group");
// Explicit group colours.
run(&mut s, "recolor.apply", json!({"map": [], "group": "Theme", "groupColors": ["#010203", "cmyk 0 0 0 20"]}));
let g = s.doc().unwrap().doc.swatch_groups.iter().find(|g| g.name == "Theme").unwrap().clone();
assert_eq!(g.swatches.iter().map(|w| w.paint.color().unwrap()).collect::<Vec<_>>(), [Color::rgb8(1, 2, 3), Color::cmyk(0.0, 0.0, 0.0, 0.2)]);
}
#[test]
fn edit_group_refuses_to_change_built_in_swatches() {
let mut s = session();
run(&mut s, "swatch.newGroup", json!({"name": "Marks", "colors": ["#ff0000"]}));
// A group holding the built-in Registration swatch (as an older file might).
s.edit("test", |d, _| {
d.swatch_groups.iter_mut().find(|g| g.name == "Marks").unwrap().swatches.insert(0, vectorcraft_color::swatch::registration().clone());
Ok(())
})
.unwrap();
let reg = vectorcraft_color::swatch::registration().paint.color().unwrap();
assert!(s.execute("swatch.editGroup", &json!({"group": "Marks", "colors": ["#00ff00", "#0000ff"]})).is_err(), "changed");
assert!(s.execute("swatch.editGroup", &json!({"group": "Marks", "colors": []})).is_err(), "removed");
let key = vectorcraft_color::recolor::ColorKey::of(&reg).to_string();
let ok = run(&mut s, "swatch.editGroup", json!({"group": "Marks", "colors": [key, "#0000ff"]}));
assert_eq!(ok["swatches"][0], vectorcraft_color::swatch::REGISTRATION, "kept as it is");
}
+2 -2
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,
+648 -69
View File
@@ -1,105 +1,684 @@
//! 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. Opened on a colour group (Edit or Apply Color Group), OK also rewrites
//! the group with the new colours (renamed by `groupName`); without art only the group changes.
//!
//! 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), `group`, `groupName`, `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?, group?}`) on the selected art and,
/// with `group`, on that colour group: art is reduced to as many rows as the group has colours,
/// which become their new colours; without art the rows are the group's colours. `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();
let group = p.get("group").and_then(Value::as_str);
let group_colors: Option<Vec<Color>> = match group {
Some(g) => {
let g = st.doc.swatch_groups.iter().find(|x| x.name == g).ok_or_else(|| format!("no colour group `{g}`"))?;
Some(g.swatches.iter().filter_map(|w| w.paint.color()).collect())
}
None => None,
};
if !art && group.is_none() {
return Err("select artwork (or a colour group) to recolor".into());
}
let assign: Vec<Value> = match (&group_colors, p.get("colors")) {
(Some(cs), _) => cs.iter().map(key).collect(),
(None, 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(),
};
// Without art the rows are the group's colours, as they are.
let rows: Vec<Value> = if art { vec![] } else { assign.iter().map(|k| json!({"from": [k], "to": k})).collect() };
let fields = json!({
"rows": rows, "colors": count, "method": Method::ScaleTints.id(),
"preserveWhite": true, "preserveBlack": true, "preserveGrays": false, "limitTo": limit,
"group": group, "groupName": group, "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 a group's colours or 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, library and the group with its new name and
/// colours: the rows' new colours, then (with art) the group's colours no row took.
fn apply_params(d: &Dialog) -> Value {
let rs = rows(d);
let group = d.fields.get("group").and_then(Value::as_str).map(|g| {
let rest =
d.fields.get("__assign").and_then(Value::as_array).filter(|_| d.bool("__art")).map_or(&[][..], |a| a.get(rs.len()..).unwrap_or_default());
let colors: Vec<&Value> = rs.iter().map(|r| &r["to"]).chain(rest).collect();
let name = d.str("groupName");
let rename = (!name.trim().is_empty() && name != g).then_some(name);
json!({"group": g, "groupColors": colors, "rename": rename})
});
let mut p = json!({"map": rs, "method": d.str("method"), "limitTo": d.str("limitTo")});
if let (Some(Value::Object(g)), Some(p)) = (group, p.as_object_mut()) {
p.extend(g.into_iter().filter(|(_, v)| !v.is_null()));
}
p
}
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));
}
if d.fields.get("group").is_some_and(Value::is_string) {
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Color Group:").color(t.text_dim));
form::text(ui, d, "groupName", 200.0);
});
}
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 (or apply it, e.g. to a colour group only).
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();
// Reduction and the preserve rules apply to art (a colour group alone has a row per colour).
if d.bool("__art") {
ui.horizontal(|ui| {
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, true) {
d.fields.insert(k.into(), json!(!on));
}
}
});
ui.add_space(6.0);
}
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Current Colors").color(t.text_dim));
ui.add_space(70.0);
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"]);
}
}
+1
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();
+2
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};
+16 -9
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), group?: colour group (Edit or Apply Color Group: OK rewrites the group with the new colours and recolours the selected art, if any)} 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"),
+5 -1
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);
}
+27 -7
View File
@@ -396,8 +396,9 @@ fn drag_of(e: &Entry, names: Vec<String>) -> PanelDrag {
#[derive(Default)]
struct TileEvents {
clicked: Option<(Entry, egui::Modifiers)>,
/// Double-click opens the swatch's editor (Swatch Options, Gradient panel or pattern editing).
edit: Option<String>,
/// Double-click opens the swatch's editor (Swatch Options, Gradient panel or pattern editing),
/// or Edit or Apply Color Group on a colour group.
edit: Option<Entry>,
drop: Option<Drop>,
/// A drag is held over a tile or row.
over: bool,
@@ -408,8 +409,8 @@ struct TileEvents {
/// starts a [`PanelDrag`] carrying the rows it moves; see [`tile_drop`] for drags held over it.
fn tile_input(ui: &Ui, resp: Response, e: &Entry, items: &[Entry], sel: &[String], list: bool, ev: &mut TileEvents) {
let name = e.name();
if matches!(e, Entry::Swatch { .. }) && resp.double_clicked() {
ev.edit = Some(name.to_string());
if matches!(e, Entry::Swatch { .. } | Entry::Folder(_)) && resp.double_clicked() {
ev.edit = Some(e.clone());
}
if resp.drag_started() {
let with_sel = sel.iter().any(|s| s == name);
@@ -685,8 +686,12 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, salt: &'static str) {
let zone = ui.interact(out.inner_rect, ui.id().with("swatch-drop"), Sense::hover());
zone_input(ui, &zone, &mut ev);
});
if let Some(name) = ev.edit {
app.run("ui.swatchOptions", json!({"name": name})).ok();
match ev.edit {
Some(Entry::Folder(g)) => edit_group(app, &g),
Some(e) => {
app.run("ui.swatchOptions", json!({"name": e.name()})).ok();
}
None => {}
}
if let Some(drop) = ev.drop {
apply_drop(app, drop);
@@ -815,6 +820,11 @@ fn selected_group(app: &VectorcraftApp, sel: &[String]) -> Option<String> {
sel.iter().find(|n| d.swatch_groups.iter().any(|g| g.name == **n)).cloned()
}
/// Edit or Apply Color Group: Recolor Artwork on colour group `group` (and the selected art).
fn edit_group(app: &mut VectorcraftApp, group: &str) {
app.run("ui.recolorDialog", json!({ "group": group })).ok();
}
/// Delete `names` (swatches and colour groups) after asking, or at once with `now` (Alt-click).
fn delete(app: &mut VectorcraftApp, names: Vec<String>, now: bool) {
let params = json!({"names": names});
@@ -846,8 +856,13 @@ fn bottom(app: &mut VectorcraftApp, ui: &mut Ui, sel: &[String]) {
}
}
});
// With a colour group selected the options button edits or applies the group.
let opts = editable(app, sel);
if widgets::icon_button_enabled(ui, "dc-options", "Swatch Options", false, opts.is_some(), 24.0).clicked()
if let Some(g) = selected_group(app, sel).filter(|_| opts.is_none()) {
if widgets::icon_button(ui, "palette", "Edit or Apply Color Group", false, 24.0).clicked() {
edit_group(app, &g);
}
} else if widgets::icon_button_enabled(ui, "dc-options", "Swatch Options", false, opts.is_some(), 24.0).clicked()
&& let Some(n) = opts
{
app.run("ui.swatchOptions", json!({"name": n})).ok();
@@ -917,6 +932,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
delete(app, del, false);
}
let group = selected_group(app, &selected);
if menu_item(ui, "Edit or Apply Color Group…", group.is_some(), false)
&& let Some(g) = &group
{
edit_group(app, g);
}
if menu_item(ui, "Ungroup Color Group", group.is_some(), false)
&& let Some(g) = group
{
+204
View File
@@ -0,0 +1,204 @@
//! Recolor Artwork (M3.78) and Edit or Apply Color Group (M3.79), drawn headlessly.
use egui::{Event, Modifiers, PointerButton, Pos2, Rect, vec2};
use serde_json::{Value, json};
use vectorcraft_color::Color;
use vectorcraft_color::recolor::ColorKey;
use vectorcraft_engine::Session;
use crate::{VectorcraftApp, dialogs, panels};
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()));
}
fn press(at: Pos2, clicks: usize) -> Vec<Event> {
let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
std::iter::once(Event::PointerMoved(at)).chain((0..clicks).flat_map(|_| [button(true), button(false)])).collect()
}
/// 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 group_keys(app: &VectorcraftApp, name: &str) -> Vec<Value> {
let d = &app.session.doc().unwrap().doc;
let g = d.swatch_groups.iter().find(|g| g.name == name).unwrap();
g.swatches.iter().filter_map(|w| w.paint.color()).map(|c| json!(ColorKey::of(&c).to_string())).collect()
}
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);
}
#[test]
fn double_clicking_a_colour_group_opens_it_and_ok_rewrites_it() {
let mut app = app();
let ctx = context();
let show: fn(&mut VectorcraftApp, &mut egui::Ui) = panels::swatches::show;
frame(&mut app, &ctx, vec![], 0.0, show);
let folder = ctx.read_response(egui::Id::new(("swatch-tile", "Brights"))).expect("the Brights folder").rect.center();
frame(&mut app, &ctx, press(folder, 2), 1.0, show);
let d = app.ui.dialog.as_ref().expect("a dialog opened");
assert_eq!((d.kind.as_str(), d.str("group")), (dialogs::recolor::KIND, "Brights".to_string()));
let keys = group_keys(&app, "Brights");
let from: Vec<Value> = field(&app, "rows").as_array().unwrap().iter().map(|r| r["from"][0].clone()).collect();
assert_eq!(from, keys, "a row per group colour, in order");
dialog_frame(&mut app, &ctx);
// A new first colour and a new name; nothing is selected, so only the group changes.
let mut rows = field(&app, "rows");
rows[0]["to"] = json!("cmyk 100 0 0 0");
set(&mut app, "rows", rows);
set(&mut app, "groupName", json!("Brights 2026"));
let undo = app.session.doc().unwrap().history.undo.len();
dialogs::confirm(&mut app).unwrap();
let mut want = keys.clone();
want[0] = json!("cmyk 100 0 0 0");
assert_eq!(group_keys(&app, "Brights 2026"), want);
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
}
#[test]
fn the_options_button_edits_a_selected_group_and_applies_it_to_the_art() {
let mut app = app();
let ctx = context();
art(&mut app, &["#ff0000", "#00ff00", "#0000ff", "#ffff00", "#ff00ff"]);
let show: fn(&mut VectorcraftApp, &mut egui::Ui) = panels::swatches::show;
frame(&mut app, &ctx, vec![], 0.0, show);
let folder = ctx.read_response(egui::Id::new(("swatch-tile", "Brights"))).unwrap().rect.center();
frame(&mut app, &ctx, press(folder, 1), 1.0, show);
frame(&mut app, &ctx, vec![], 2.0, show);
// The bottom bar's third button: Swatch Options, or Edit or Apply Color Group for a group.
let mut bar: Vec<Rect> = ctx.viewport(|vp| {
vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == vec2(24.0, 24.0)).map(|w| w.rect).collect()
});
let bottom = bar.iter().map(|r| r.top()).fold(f32::MIN, f32::max);
bar.retain(|r| r.top() == bottom);
bar.sort_by(|a, b| a.left().total_cmp(&b.left()));
frame(&mut app, &ctx, press(bar[2].center(), 1), 3.0, show);
assert_eq!(field(&app, "group"), "Brights");
dialog_frame(&mut app, &ctx);
let group = group_keys(&app, "Brights");
let tos: Vec<Value> = field(&app, "rows").as_array().unwrap().iter().map(|r| r["to"].clone()).collect();
assert_eq!(tos, group, "the art's colours in as many rows as the group has, taking its colours");
dialogs::confirm(&mut app).unwrap();
let used: Vec<String> = app.session.execute("recolor.colors", &json!({})).unwrap()["colors"]
.as_array()
.unwrap()
.iter()
.map(|c| c["key"].as_str().unwrap().to_string())
.collect();
assert!(used.iter().all(|k| group.contains(&json!(k))), "the art uses the group's colours: {used:?}");
assert!(matches!(ColorKey::parse(&used[0]).unwrap().color(), Color::Rgb { .. }));
}
#[test]
fn a_group_keeps_the_colours_no_art_row_took() {
let mut app = app();
let ctx = context();
art(&mut app, &["#ff0000", "#0000ff"]);
let before = group_keys(&app, "Brights");
app.run("ui.recolorDialog", json!({"group": "Brights"})).unwrap();
dialog_frame(&mut app, &ctx);
assert_eq!(field(&app, "rows").as_array().unwrap().len(), 2, "two art colours");
let mut rows = field(&app, "rows");
rows[1]["to"] = json!("rgb 1 2 3");
set(&mut app, "rows", rows);
dialogs::confirm(&mut app).unwrap();
let after = group_keys(&app, "Brights");
assert_eq!(after.len(), before.len());
assert_eq!((&after[0], &after[1]), (&before[0], &json!("rgb 1 2 3")));
assert_eq!(after[2..], before[2..]);
}
+10
View File
@@ -82,6 +82,16 @@ 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?, group?}` 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; `group`: a colour group, Edit or Apply Color Group). Fields: `rows` (`[{from: [keys], to: key, exclude?}]`
as `recolor.reduce` returns them), `colors` (null for Auto), `method`, `preserveWhite`, `preserveBlack`,
`preserveGrays`, `limitTo`, `groupName`, `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 (with a group, the group is rewritten in the same
step; with no art selected only the group changes). In the Swatches panel, double-clicking a colour group's folder,
or the options button while a group is selected, opens it.
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
+21
View File
@@ -317,6 +317,27 @@ 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?, group?, groupColors?, rename?}`.
`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. `swatch.editGroup {group, colors, rename?}` rewrites a colour group in one undo
step (art linked to its global swatches follows), and `recolor.apply` with `group` recolours the art and rewrites the
group together.
```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"}}}
{"name":"run_command","arguments":{"command":"swatch.editGroup","params":{"group":"Brights","colors":["#ff0000","cmyk 0 0 100 0"]}}}
```
## 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