mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 19:27:21 -06:00
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:
@@ -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;
|
||||
|
||||
@@ -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, ¢res)).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, ¢res)).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");
|
||||
}
|
||||
}
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
@@ -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}))
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(®).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");
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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..]);
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user