continued work; wip

This commit is contained in:
Brandon Thomas 2026-09-30 11:24:38 -04:00
parent 94e72f490e
commit 6dd0d2dcff
47 changed files with 8539 additions and 121 deletions

1
Cargo.lock generated
View File

@ -863,6 +863,7 @@ name = "drawcraft-color"
version = "0.1.0"
dependencies = [
"kurbo 0.13.1",
"moxcms",
"proptest",
"serde",
"serde_json",

View File

@ -68,6 +68,7 @@ fn load_prefs(app: &mut DrawcraftApp) {
{
app.ui = ui.sanitized();
}
drawcraft_ui_egui::prefs_dialog::restore(app);
}
fn save_prefs(app: &DrawcraftApp) {
@ -76,7 +77,9 @@ fn save_prefs(app: &DrawcraftApp) {
}
if let Some(p) = prefs_path() {
let _ = std::fs::create_dir_all(p.parent().unwrap_or(std::path::Path::new(".")));
if let Ok(bytes) = serde_json::to_vec_pretty(&app.ui) {
let mut ui = app.ui.clone();
ui.engine_prefs = app.session.prefs.to_json();
if let Ok(bytes) = serde_json::to_vec_pretty(&ui) {
let _ = std::fs::write(p, bytes);
}
}

View File

@ -20,6 +20,8 @@ pub struct NativeMenu {
_menu: Menu,
items: HashMap<String, (String, Value, Handle, String)>,
last_refresh: f64,
/// Shortcut-override / workspace-list generation the menu was built for.
generation: u64,
}
/// Accelerator for a shortcut like "Cmd+Shift+]" (modifier-less shortcuts stay in the app so they
@ -33,6 +35,9 @@ fn accel(sc: &str) -> Option<Accelerator> {
impl NativeMenu {
pub fn install(app: &mut DrawcraftApp) -> Self {
// Accelerators come from `shortcut_of`, which honours the user's overrides.
drawcraft_ui_egui::shortcut_editor::sync(&app.ui);
let generation = drawcraft_ui_egui::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed);
let menu = Menu::new();
let mut items = HashMap::new();
let mut counter = 0usize;
@ -48,7 +53,7 @@ impl NativeMenu {
.filter(|(_, p, _, c)| (p.is_null() || p.as_object().is_some_and(|o| o.is_empty())) && menus::shortcut_of(c).and_then(accel).is_some())
.map(|(_, _, _, c)| c.clone())
.collect();
Self { _menu: menu, items, last_refresh: 0.0 }
Self { _menu: menu, items, last_refresh: 0.0, generation }
}
/// Dispatch clicked items and refresh state.
@ -64,6 +69,11 @@ impl NativeMenu {
return;
}
self.last_refresh = now;
// Shortcuts edited or workspaces added: rebuild so accelerators and lists are current.
if drawcraft_ui_egui::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed) != self.generation {
*self = NativeMenu::install(app);
return;
}
for (run, rp, h, cmd) in self.items.values() {
let null = Value::Null;
let params = if run == cmd { rp } else { &null };
@ -83,6 +93,7 @@ impl NativeMenu {
| "view.artboards"
| "view.boundingBox"
| "view.transparencyGrid"
| "window.workspace.reset"
) {
i.set_text(menus::dynamic_label(app, cmd, ""));
}

View File

@ -12,6 +12,7 @@ workspace = true
kurbo = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
moxcms = "0.8"
[dev-dependencies]
proptest = { workspace = true }

View File

@ -0,0 +1,453 @@
//! "DrawCraft Generic CMYK (SWOP-like)": our own parametric press model.
//!
//! We can't redistribute Adobe's or ECI's characterisation profiles, so the built-in CMYK space is
//! a small physical model instead of an ICC LUT. It is *ours* and only SWOP-*like*: the numbers
//! are rounded, typical values for coated web offset, not a measured dataset.
//!
//! * **Forward (CMYK → XYZ):** each ink's nominal dot area is widened by a parabolic dot-gain
//! curve (`a + 4·g·a·(1−a)`, i.e. `g` gain at 50%); the effective areas mix the eight CMY
//! Neugebauer primaries with Demichel weights; black is mixed in the same way against
//! "primary + K" colours whose optical densities add but saturate towards a maximum density
//! (normalised so K on paper is exact), so rich blacks don't multiply to an unrealistic depth.
//! * **Inverse (Lab → CMYK):** damped, bounded Gauss–Newton on the forward model solves CMY for a
//! fixed K. Black generation is GCR: solve CMY with K = 0, take the grey component
//! `min(C, M, Y)`, map it through the black-generation curve to K, re-solve CMY. Under-colour
//! removal enforces the total area coverage (TAC) limit by adding K and re-solving, then scaling
//! CMY as a last resort.
//! * **Rendering intents:** colorimetric intents map the source white to paper (relative) or not
//! (absolute) and clip; perceptual applies black-point compensation plus soft chroma compression
//! against a gamut boundary descriptor (segment maxima of the forward model); saturation keeps
//! chroma by pulling out-of-gamut colours towards the hue's cusp lightness.
use std::sync::OnceLock;
use super::Intent;
use super::lab::{D50, Lab, delta_e2000, lab_to_xyz, srgb_to_lab, xyz_to_lab, xyz_to_srgb};
/// Black generation strength.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BlackGeneration {
/// Grey component below which no black is generated.
pub start: f32,
/// Maximum black.
pub max_k: f32,
/// Curve exponent (>1 = lighter black in the midtones).
pub gamma: f32,
}
/// Parameters of the parametric press model.
#[derive(Clone, Debug)]
pub struct GenericCmykParams {
pub name: &'static str,
pub paper: Lab,
/// Solid overprints, Demichel order: C, M, CM, Y, CY, MY, CMY.
pub solids: [Lab; 7],
pub black: Lab,
/// Dot gain at 50% per ink (C, M, Y, K).
pub dot_gain: [f32; 4],
/// Total area coverage limit (sum of inks, 1.0 = 100%).
pub tac: f32,
pub bg: BlackGeneration,
}
/// Rounded, typical SWOP-like values (not measured data).
pub fn swop_like() -> GenericCmykParams {
GenericCmykParams {
name: super::GENERIC_CMYK,
paper: Lab::new(93.0, -0.5, 2.5),
solids: [
Lab::new(55.0, -37.0, -50.0), // C
Lab::new(48.0, 74.0, -3.0), // M
Lab::new(24.0, 22.0, -46.0), // CM (blue)
Lab::new(89.0, -5.0, 93.0), // Y
Lab::new(50.0, -65.0, 27.0), // CY (green)
Lab::new(47.0, 68.0, 48.0), // MY (red)
Lab::new(24.0, 1.0, 0.0), // CMY
],
black: Lab::new(19.0, 0.5, 0.5),
dot_gain: [0.20, 0.20, 0.20, 0.22],
tac: 3.0,
bg: BlackGeneration { start: 0.1, max_k: 0.95, gamma: 1.25 },
}
}
/// Gamut boundary descriptor: max chroma per (L, hue) cell in media-relative Lab.
struct Gbd {
cmax: Vec<f32>,
/// Lightness of maximum chroma per hue bin.
cusp_l: Vec<f32>,
}
const GBD_L: usize = 51; // L 0..100, step 2
const GBD_H: usize = 72; // 5° bins
pub struct GenericCmyk {
pub params: GenericCmykParams,
/// XYZ of the 8 CMY primaries (index = c | m<<1 | y<<2), without and with K.
prim: [[f32; 3]; 8],
prim_k: [[f32; 3]; 8],
paper_xyz: [f32; 3],
/// Media-relative XYZ of the darkest printable black (for black-point compensation).
black_rel: [f32; 3],
gbd: OnceLock<Gbd>,
}
impl GenericCmyk {
pub fn new(params: GenericCmykParams) -> Self {
let paper_xyz = lab_to_xyz(params.paper);
let mut prim = [[0.0f32; 3]; 8];
prim[0] = paper_xyz;
for i in 1..8 {
prim[i] = lab_to_xyz(params.solids[i - 1]);
}
let k_xyz = lab_to_xyz(params.black);
// Black over other inks: optical densities add, but saturate towards a maximum density
// (ink trapping / surface reflection), normalised so that K on paper is exact.
const D_MAX: f32 = 2.0;
let sat = |d: f32| D_MAX * (1.0 - (-d / D_MAX).exp());
let mut prim_k = [[0.0f32; 3]; 8];
for i in 0..8 {
for ch in 0..3 {
let di = -(prim[i][ch] / paper_xyz[ch]).max(1e-6).log10();
let dk = -(k_xyz[ch] / paper_xyz[ch]).max(1e-6).log10();
let d = sat(di + dk) * dk / sat(dk).max(1e-6);
prim_k[i][ch] = paper_xyz[ch] * 10f32.powf(-d.max(di));
}
}
let mut g = Self { params, prim, prim_k, paper_xyz, black_rel: [0.0; 3], gbd: OnceLock::new() };
// Darkest black within TAC: search a few rich blacks.
let mut best = [1.0f32; 3];
for k in [0.8f32, 0.9, 1.0] {
let rest = (g.params.tac - k).max(0.0) / 3.0;
let cmy = rest.min(1.0);
let x = g.to_xyz_rel([cmy, cmy, cmy, k]);
if x[1] < best[1] {
best = x;
}
}
g.black_rel = best;
g
}
pub fn name(&self) -> &str {
self.params.name
}
fn gain(a: f32, g: f32) -> f32 {
let a = a.clamp(0.0, 1.0);
(a + 4.0 * g * a * (1.0 - a)).clamp(0.0, 1.0)
}
/// CMYK (0..1) → absolute XYZ (D50).
pub fn to_xyz(&self, cmyk: [f32; 4]) -> [f32; 3] {
let dg = self.params.dot_gain;
let c = Self::gain(cmyk[0], dg[0]);
let m = Self::gain(cmyk[1], dg[1]);
let y = Self::gain(cmyk[2], dg[2]);
let k = Self::gain(cmyk[3], dg[3]);
let mut out = [0.0f32; 3];
for i in 0..8 {
let w = (if i & 1 != 0 { c } else { 1.0 - c }) * (if i & 2 != 0 { m } else { 1.0 - m }) * (if i & 4 != 0 { y } else { 1.0 - y });
if w == 0.0 {
continue;
}
for ch in 0..3 {
out[ch] += w * ((1.0 - k) * self.prim[i][ch] + k * self.prim_k[i][ch]);
}
}
out
}
/// Media-relative XYZ (paper = D50 white).
pub fn to_xyz_rel(&self, cmyk: [f32; 4]) -> [f32; 3] {
let x = self.to_xyz(cmyk);
[x[0] * D50[0] / self.paper_xyz[0], x[1] * D50[1] / self.paper_xyz[1], x[2] * D50[2] / self.paper_xyz[2]]
}
pub fn to_lab_rel(&self, cmyk: [f32; 4]) -> Lab {
xyz_to_lab(self.to_xyz_rel(cmyk))
}
pub fn to_lab_abs(&self, cmyk: [f32; 4]) -> Lab {
xyz_to_lab(self.to_xyz(cmyk))
}
/// Display sRGB: media-relative (paper shows as white) or absolute (simulate paper colour).
pub fn to_srgb(&self, cmyk: [f32; 4], absolute: bool) -> [f32; 3] {
xyz_to_srgb(if absolute { self.to_xyz(cmyk) } else { self.to_xyz_rel(cmyk) })
}
/// Lab of the darkest printable black (media-relative).
pub fn black_point(&self) -> Lab {
xyz_to_lab(self.black_rel)
}
/// Solve CMY for a fixed K so that the media-relative Lab matches `target` (bounded).
fn solve_cmy(&self, target: Lab, k: f32, init: [f32; 3]) -> [f32; 3] {
let mut x = init.map(|v| v.clamp(0.0, 1.0));
let f = |x: [f32; 3]| {
let l = self.to_lab_rel([x[0], x[1], x[2], k]);
[l.l - target.l, l.a - target.a, l.b - target.b]
};
let mut r = f(x);
let mut err = r.iter().map(|v| v * v).sum::<f32>();
let mut lambda = 1e-3f32;
for _ in 0..40 {
if err < 1e-4 {
break;
}
// Numeric Jacobian (one-sided, pointing into the box).
let mut j = [[0.0f32; 3]; 3];
for col in 0..3 {
let h = if x[col] > 0.5 { -1e-3 } else { 1e-3 };
let mut xp = x;
xp[col] += h;
let rp = f(xp);
for row in 0..3 {
j[row][col] = (rp[row] - r[row]) / h;
}
}
// Levenberg–Marquardt step: (JᵀJ + λ·diag) δ = −Jᵀr.
let mut a = [[0.0f32; 3]; 3];
let mut g = [0.0f32; 3];
for p in 0..3 {
for q in 0..3 {
a[p][q] = (0..3).map(|i| j[i][p] * j[i][q]).sum();
}
g[p] = -(0..3).map(|i| j[i][p] * r[i]).sum::<f32>();
}
let mut improved = false;
for _ in 0..8 {
let mut al = a;
for (p, row) in al.iter_mut().enumerate() {
row[p] += lambda * (a[p][p] + 1e-3);
}
let Some(d) = solve3(al, g) else {
lambda *= 10.0;
continue;
};
let xn = [(x[0] + d[0]).clamp(0.0, 1.0), (x[1] + d[1]).clamp(0.0, 1.0), (x[2] + d[2]).clamp(0.0, 1.0)];
let rn = f(xn);
let en = rn.iter().map(|v| v * v).sum::<f32>();
if en < err {
let step = (0..3).map(|i| (xn[i] - x[i]).abs()).fold(0.0, f32::max);
x = xn;
r = rn;
err = en;
lambda = (lambda * 0.3).max(1e-7);
improved = step > 1e-6;
break;
}
lambda *= 10.0;
}
if !improved {
break;
}
}
x
}
fn initial_cmy(target: Lab, k: f32) -> [f32; 3] {
let rgb = super::lab::lab_to_srgb(target);
let d = (1.0 - k).max(0.05);
rgb.map(|v| ((1.0 - v - k) / d).clamp(0.0, 1.0))
}
fn black_for_grey(&self, grey: f32) -> f32 {
let bg = self.params.bg;
if grey <= bg.start {
return 0.0;
}
(((grey - bg.start) / (1.0 - bg.start)).clamp(0.0, 1.0).powf(bg.gamma) * bg.max_k).clamp(0.0, 1.0)
}
/// Separate a media-relative Lab (already gamut-mapped) into CMYK with GCR/UCR and the TAC limit.
pub fn separate(&self, target: Lab) -> [f32; 4] {
if target.l >= 99.99 && target.a.abs() < 0.01 && target.b.abs() < 0.01 {
return [0.0; 4];
}
let cmy0 = self.solve_cmy(target, 0.0, Self::initial_cmy(target, 0.0));
let grey = cmy0[0].min(cmy0[1]).min(cmy0[2]);
let mut k = self.black_for_grey(grey);
// If CMY alone can't get dark enough, black must carry the rest.
let l0 = self.to_lab_rel([cmy0[0], cmy0[1], cmy0[2], 0.0]).l;
if l0 > target.l + 1.0 {
k = k.max(((l0 - target.l) / l0.max(1.0)).clamp(0.0, 1.0));
}
let mut cmy = if k > 0.0 { self.solve_cmy(target, k, Self::initial_cmy(target, k)) } else { cmy0 };
// UCR: trade CMY for K until within the TAC limit.
for _ in 0..6 {
let sum = cmy.iter().sum::<f32>() + k;
if sum <= self.params.tac + 1e-3 || k >= 1.0 {
break;
}
k = (k + (sum - self.params.tac) * 0.5).min(1.0);
cmy = self.solve_cmy(target, k, cmy);
}
let sum = cmy.iter().sum::<f32>() + k;
if sum > self.params.tac {
let s = ((self.params.tac - k) / cmy.iter().sum::<f32>().max(1e-6)).clamp(0.0, 1.0);
cmy = cmy.map(|v| v * s);
}
[cmy[0], cmy[1], cmy[2], k].map(|v| if v < 1e-4 { 0.0 } else { v.min(1.0) })
}
fn gbd(&self) -> &Gbd {
self.gbd.get_or_init(|| {
let mut cmax = vec![0.0f32; GBD_L * GBD_H];
let mut cusp = vec![(0.0f32, 50.0f32); GBD_H];
let n = 16;
for ki in 0..=5 {
let k = ki as f32 / 5.0;
for ci in 0..=n {
for mi in 0..=n {
for yi in 0..=n {
let (c, m, y) = (ci as f32 / n as f32, mi as f32 / n as f32, yi as f32 / n as f32);
if c + m + y + k > self.params.tac + 1e-3 {
continue;
}
let lab = self.to_lab_rel([c, m, y, k]);
let li = ((lab.l / 2.0).round() as usize).min(GBD_L - 1);
let hi = ((lab.hue() / 5.0) as usize).min(GBD_H - 1);
let ch = lab.chroma();
let cell = &mut cmax[li * GBD_H + hi];
*cell = cell.max(ch);
if ch > cusp[hi].0 {
cusp[hi] = (ch, lab.l);
}
}
}
}
}
// Fill holes: take the max over neighbouring hue bins, then interpolate along L.
let mut smooth = cmax.clone();
for li in 0..GBD_L {
for hi in 0..GBD_H {
let get = |h: usize| cmax[li * GBD_H + h % GBD_H];
smooth[li * GBD_H + hi] = get(hi).max(get(hi + 1)).max(get(hi + GBD_H - 1));
}
}
for hi in 0..GBD_H {
let col: Vec<f32> = (0..GBD_L).map(|li| smooth[li * GBD_H + hi]).collect();
let known: Vec<usize> = (0..GBD_L).filter(|&li| col[li] > 0.0).collect();
for li in 0..GBD_L {
if col[li] > 0.0 {
continue;
}
let below = known.iter().rev().find(|&&k| k < li);
let above = known.iter().find(|&&k| k > li);
smooth[li * GBD_H + hi] = match (below, above) {
(Some(&b), Some(&a)) => col[b] + (col[a] - col[b]) * (li - b) as f32 / (a - b) as f32,
_ => 0.0,
};
}
}
Gbd { cmax: smooth, cusp_l: cusp.into_iter().map(|c| c.1).collect() }
})
}
/// Maximum printable chroma at (L, hue), media-relative.
pub fn max_chroma(&self, l: f32, hue: f32) -> f32 {
let g = self.gbd();
let lf = (l.clamp(0.0, 100.0) / 2.0).min((GBD_L - 1) as f32);
let hf = hue.rem_euclid(360.0) / 5.0;
let (l0, h0) = (lf.floor() as usize, hf.floor() as usize);
let (l1, h1) = ((l0 + 1).min(GBD_L - 1), (h0 + 1) % GBD_H);
let (tl, th) = (lf - l0 as f32, hf - h0 as f32);
let at = |li: usize, hi: usize| g.cmax[li * GBD_H + hi % GBD_H];
let a = at(l0, h0) * (1.0 - th) + at(l0, h1) * th;
let b = at(l1, h0) * (1.0 - th) + at(l1, h1) * th;
a * (1.0 - tl) + b * tl
}
fn cusp_l(&self, hue: f32) -> f32 {
self.gbd().cusp_l[((hue.rem_euclid(360.0) / 5.0) as usize).min(GBD_H - 1)]
}
/// Map an absolute source Lab (D50; sRGB white = L 100) to the media-relative target for `intent`.
pub fn gamut_map(&self, src: Lab, intent: Intent, bpc: bool) -> Lab {
if intent == Intent::AbsoluteColorimetric {
// Keep absolute colorimetry: express in media-relative terms without white mapping.
let x = lab_to_xyz(src);
return xyz_to_lab([x[0] * D50[0] / self.paper_xyz[0], x[1] * D50[1] / self.paper_xyz[1], x[2] * D50[2] / self.paper_xyz[2]]);
}
let mut lab = src;
if bpc || matches!(intent, Intent::Perceptual | Intent::Saturation) {
// Black-point compensation in XYZ: source black (0) → printable black.
let x = lab_to_xyz(lab);
let bp = self.black_rel;
let y = [0, 1, 2].map(|i| bp[i] + x[i] * (1.0 - bp[i] / D50[i]));
lab = xyz_to_lab(y);
}
match intent {
Intent::Perceptual => {
let c = lab.chroma();
if c > 1e-3 {
// Cusp-directed: very out-of-gamut colours also move towards the lightness
// where the press reaches its maximum chroma for this hue.
let h = lab.hue();
let cmax0 = self.max_chroma(lab.l, h).max(1.0);
if c > cmax0 {
let w = ((c - cmax0) / c).clamp(0.0, 1.0) * 0.9;
lab.l += (self.cusp_l(h) - lab.l) * w;
}
let cmax = self.max_chroma(lab.l, h).max(1.0);
let r = c / cmax;
const KNEE: f32 = 0.75;
let r2 = if r > KNEE { KNEE + (1.0 - KNEE) * (1.0 - (-(r - KNEE) / (1.0 - KNEE)).exp()) } else { r };
lab = Lab::from_lch(lab.l, r2 * cmax * 0.995, lab.hue());
}
lab
}
Intent::Saturation => {
let (c, h) = (lab.chroma(), lab.hue());
if c > 1e-3 {
let mut l = lab.l;
let cmax = self.max_chroma(l, h);
if c > cmax {
let cl = self.cusp_l(h);
l += (cl - l) * ((c - cmax) / c.max(1.0)).clamp(0.0, 1.0);
}
let cmax = self.max_chroma(l, h);
lab = Lab::from_lch(l, (c * 1.1).min(cmax * 0.995), h);
}
lab
}
_ => lab,
}
}
/// Convert an absolute Lab to CMYK with `intent`.
pub fn from_lab(&self, src: Lab, intent: Intent, bpc: bool) -> [f32; 4] {
self.separate(self.gamut_map(src, intent, bpc))
}
pub fn from_srgb(&self, rgb: [f32; 3], intent: Intent, bpc: bool) -> [f32; 4] {
self.from_lab(srgb_to_lab(rgb), intent, bpc)
}
/// ΔE2000 between an sRGB colour and its best (relative colorimetric) reproduction.
pub fn gamut_error(&self, src: Lab) -> f32 {
let target = self.gamut_map(src, Intent::RelativeColorimetric, false);
let cmyk = self.separate(target);
delta_e2000(self.to_lab_rel(cmyk), target)
}
}
fn solve3(a: [[f32; 3]; 3], b: [f32; 3]) -> Option<[f32; 3]> {
let det = a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1]) - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
+ a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
if det.abs() < 1e-12 {
return None;
}
let mut out = [0.0f32; 3];
for (i, o) in out.iter_mut().enumerate() {
let mut m = a;
for r in 0..3 {
m[r][i] = b[r];
}
*o = (m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]))
/ det;
}
Some(out)
}

135
crates/color/src/cms/icc.rs Normal file
View File

@ -0,0 +1,135 @@
//! ICC profiles through `moxcms` (pure Rust): built-in RGB spaces and user-supplied `.icc` files.
use std::sync::{Arc, OnceLock};
use moxcms::{ColorProfile, DataColorSpace, Layout, ProfileText, RenderingIntent, TransformF32Executor, TransformOptions};
use super::{CmsError, Intent, ProfileKind};
fn moxcms_intent(i: Intent) -> RenderingIntent {
match i {
Intent::Perceptual => RenderingIntent::Perceptual,
Intent::RelativeColorimetric => RenderingIntent::RelativeColorimetric,
Intent::Saturation => RenderingIntent::Saturation,
Intent::AbsoluteColorimetric => RenderingIntent::AbsoluteColorimetric,
}
}
const INTENTS: [Intent; 4] = [Intent::Perceptual, Intent::RelativeColorimetric, Intent::Saturation, Intent::AbsoluteColorimetric];
fn intent_index(i: Intent) -> usize {
INTENTS.iter().position(|x| *x == i).unwrap_or(1)
}
type Xf = Option<Arc<TransformF32Executor>>;
/// A parsed ICC profile plus lazily-built transforms to/from sRGB.
pub struct IccProfile {
pub name: String,
pub kind: ProfileKind,
profile: ColorProfile,
srgb: ColorProfile,
to_srgb: [OnceLock<Xf>; 4],
from_srgb: [OnceLock<Xf>; 4],
}
impl std::fmt::Debug for IccProfile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("IccProfile").field("name", &self.name).field("kind", &self.kind).finish()
}
}
fn text(t: &Option<ProfileText>) -> Option<String> {
match t.as_ref()? {
ProfileText::PlainString(s) => Some(s.clone()),
ProfileText::Localizable(v) => v.iter().find(|l| l.language == "en").or(v.first()).map(|l| l.value.clone()),
ProfileText::Description(d) => Some(if d.ascii_string.is_empty() { d.unicode_string.clone() } else { d.ascii_string.clone() }),
}
.map(|s| s.trim_matches(char::from(0)).trim().to_string())
.filter(|s| !s.is_empty())
}
impl IccProfile {
pub fn from_profile(name: Option<String>, profile: ColorProfile) -> Result<Self, CmsError> {
let kind = match profile.color_space {
DataColorSpace::Rgb => ProfileKind::Rgb,
DataColorSpace::Cmyk => ProfileKind::Cmyk,
DataColorSpace::Gray => ProfileKind::Gray,
other => return Err(CmsError::Unsupported(format!("profile colour space {other:?} is not RGB, CMYK or Gray"))),
};
let name = name.or_else(|| text(&profile.description)).unwrap_or_else(|| format!("Untitled {kind:?} profile"));
Ok(Self { name, kind, profile, srgb: ColorProfile::new_srgb(), to_srgb: Default::default(), from_srgb: Default::default() })
}
pub fn from_bytes(name: Option<String>, bytes: &[u8]) -> Result<Self, CmsError> {
let p = ColorProfile::new_from_slice(bytes).map_err(|e| CmsError::BadProfile(format!("{e:?}")))?;
Self::from_profile(name, p)
}
fn layout(&self) -> Layout {
match self.kind {
ProfileKind::Cmyk => Layout::Rgba, // 4 channels
ProfileKind::Gray => Layout::Gray,
ProfileKind::Rgb => Layout::Rgb,
}
}
fn channels(&self) -> usize {
match self.kind {
ProfileKind::Cmyk => 4,
ProfileKind::Gray => 1,
ProfileKind::Rgb => 3,
}
}
fn opts(intent: Intent) -> TransformOptions {
TransformOptions { rendering_intent: moxcms_intent(intent), ..Default::default() }
}
fn to_xf(&self, intent: Intent) -> Xf {
self.to_srgb[intent_index(intent)]
.get_or_init(|| self.profile.create_transform_f32(self.layout(), &self.srgb, Layout::Rgb, Self::opts(intent)).ok())
.clone()
}
fn from_xf(&self, intent: Intent) -> Xf {
self.from_srgb[intent_index(intent)]
.get_or_init(|| self.srgb.create_transform_f32(Layout::Rgb, &self.profile, self.layout(), Self::opts(intent)).ok())
.clone()
}
/// Device values (RGB / CMYK / Gray, 0..1) → sRGB.
pub fn to_srgb(&self, v: &[f32], intent: Intent) -> Option<[f32; 3]> {
let xf = self.to_xf(intent)?;
let mut src = v.to_vec();
src.resize(self.channels(), 0.0);
let mut dst = [0.0f32; 3];
xf.transform(&src, &mut dst).ok()?;
Some(dst.map(|x| x.clamp(0.0, 1.0)))
}
/// sRGB → device values.
pub fn from_srgb(&self, rgb: [f32; 3], intent: Intent) -> Option<Vec<f32>> {
let xf = self.from_xf(intent)?;
let mut dst = vec![0.0f32; self.channels()];
xf.transform(&rgb, &mut dst).ok()?;
Some(dst.into_iter().map(|x| x.clamp(0.0, 1.0)).collect())
}
/// Whether transforms can be built for this profile (checked at registration).
pub fn usable(&self) -> bool {
self.to_xf(Intent::RelativeColorimetric).is_some() && self.from_xf(Intent::RelativeColorimetric).is_some()
}
}
/// Built-in RGB working spaces from `moxcms`'s standard primaries.
pub fn builtin_rgb(name: &str) -> Option<IccProfile> {
let p = match name {
super::ADOBE_RGB => ColorProfile::new_adobe_rgb(),
super::DISPLAY_P3 => ColorProfile::new_display_p3(),
super::PROPHOTO_RGB => ColorProfile::new_pro_photo_rgb(),
super::SRGB => ColorProfile::new_srgb(),
_ => return None,
};
IccProfile::from_profile(Some(name.to_string()), p).ok()
}

195
crates/color/src/cms/lab.rs Normal file
View File

@ -0,0 +1,195 @@
//! CIE XYZ / L*a*b* maths (D50, the ICC profile connection space) and colour differences.
use serde::{Deserialize, Serialize};
/// D50 reference white (ICC PCS), Y = 1.
pub const D50: [f32; 3] = [0.964_22, 1.0, 0.825_21];
/// A CIE L*a*b* colour (D50). L is 0..100, a/b roughly -128..127.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Lab {
pub l: f32,
pub a: f32,
pub b: f32,
}
impl Lab {
pub const fn new(l: f32, a: f32, b: f32) -> Self {
Self { l, a, b }
}
/// Chroma (C*ab).
pub fn chroma(&self) -> f32 {
self.a.hypot(self.b)
}
/// Hue angle in degrees 0..360.
pub fn hue(&self) -> f32 {
self.b.atan2(self.a).to_degrees().rem_euclid(360.0)
}
pub fn from_lch(l: f32, c: f32, h_deg: f32) -> Self {
let h = h_deg.to_radians();
Self { l, a: c * h.cos(), b: c * h.sin() }
}
}
const EPS: f32 = 216.0 / 24389.0;
const KAPPA: f32 = 24389.0 / 27.0;
pub fn xyz_to_lab(xyz: [f32; 3]) -> Lab {
let f = |t: f32| if t > EPS { t.cbrt() } else { (KAPPA * t + 16.0) / 116.0 };
let fx = f(xyz[0] / D50[0]);
let fy = f(xyz[1] / D50[1]);
let fz = f(xyz[2] / D50[2]);
Lab { l: 116.0 * fy - 16.0, a: 500.0 * (fx - fy), b: 200.0 * (fy - fz) }
}
pub fn lab_to_xyz(lab: Lab) -> [f32; 3] {
let fy = (lab.l + 16.0) / 116.0;
let fx = fy + lab.a / 500.0;
let fz = fy - lab.b / 200.0;
let inv = |f: f32| {
let f3 = f * f * f;
if f3 > EPS { f3 } else { (116.0 * f - 16.0) / KAPPA }
};
let y = if lab.l > KAPPA * EPS { fy * fy * fy } else { lab.l / KAPPA };
[inv(fx) * D50[0], y * D50[1], inv(fz) * D50[2]]
}
/// sRGB transfer: encoded → linear.
pub fn srgb_to_linear(v: f32) -> f32 {
if v <= 0.040_45 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
}
/// sRGB transfer: linear → encoded.
pub fn linear_to_srgb(v: f32) -> f32 {
if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.max(0.0).powf(1.0 / 2.4) - 0.055 }
}
// ICC sRGB colorants, Bradford-adapted to D50.
const SRGB_TO_XYZ: [[f32; 3]; 3] =
[[0.436_074_7, 0.385_064_9, 0.143_080_4], [0.222_504_5, 0.716_878_6, 0.060_616_9], [0.013_932_2, 0.097_104_5, 0.714_173_3]];
const XYZ_TO_SRGB: [[f32; 3]; 3] =
[[3.133_856_1, -1.616_866_7, -0.490_614_6], [-0.978_768_4, 1.916_141_5, 0.033_454], [0.071_945_3, -0.228_991_4, 1.405_242_7]];
fn mul(m: &[[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] {
[
m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
]
}
/// Encoded sRGB (0..1) → XYZ D50.
pub fn srgb_to_xyz(rgb: [f32; 3]) -> [f32; 3] {
mul(&SRGB_TO_XYZ, rgb.map(srgb_to_linear))
}
/// XYZ D50 → encoded sRGB, unclipped.
pub fn xyz_to_srgb_unclipped(xyz: [f32; 3]) -> [f32; 3] {
mul(&XYZ_TO_SRGB, xyz).map(|v| v.signum() * linear_to_srgb(v.abs()))
}
/// XYZ D50 → encoded sRGB clipped to 0..1.
pub fn xyz_to_srgb(xyz: [f32; 3]) -> [f32; 3] {
// Snap float noise so paper white is exactly white.
mul(&XYZ_TO_SRGB, xyz).map(|v| {
let e = linear_to_srgb(v.clamp(0.0, 1.0));
if e > 0.99999 { 1.0 } else if e < 1e-6 { 0.0 } else { e }
})
}
pub fn srgb_to_lab(rgb: [f32; 3]) -> Lab {
xyz_to_lab(srgb_to_xyz(rgb))
}
pub fn lab_to_srgb(lab: Lab) -> [f32; 3] {
xyz_to_srgb(lab_to_xyz(lab))
}
/// Linear-light sRGB (for simulations that work on linear RGB).
pub fn srgb_linear(rgb: [f32; 3]) -> [f32; 3] {
rgb.map(srgb_to_linear)
}
pub(crate) fn mat_mul(m: &[[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] {
mul(m, v)
}
/// CIE76 colour difference.
pub fn delta_e76(x: Lab, y: Lab) -> f32 {
((x.l - y.l).powi(2) + (x.a - y.a).powi(2) + (x.b - y.b).powi(2)).sqrt()
}
/// CIEDE2000 colour difference (kL = kC = kH = 1).
pub fn delta_e2000(x: Lab, y: Lab) -> f32 {
let (l1, a1, b1) = (x.l as f64, x.a as f64, x.b as f64);
let (l2, a2, b2) = (y.l as f64, y.a as f64, y.b as f64);
let c1 = a1.hypot(b1);
let c2 = a2.hypot(b2);
let cm = (c1 + c2) / 2.0;
let g = 0.5 * (1.0 - (cm.powi(7) / (cm.powi(7) + 25f64.powi(7))).sqrt());
let a1p = a1 * (1.0 + g);
let a2p = a2 * (1.0 + g);
let c1p = a1p.hypot(b1);
let c2p = a2p.hypot(b2);
let hp = |b: f64, a: f64| if b == 0.0 && a == 0.0 { 0.0 } else { b.atan2(a).to_degrees().rem_euclid(360.0) };
let h1p = hp(b1, a1p);
let h2p = hp(b2, a2p);
let dlp = l2 - l1;
let dcp = c2p - c1p;
let dhp = if c1p * c2p == 0.0 {
0.0
} else if (h2p - h1p).abs() <= 180.0 {
h2p - h1p
} else if h2p - h1p > 180.0 {
h2p - h1p - 360.0
} else {
h2p - h1p + 360.0
};
let dhp_big = 2.0 * (c1p * c2p).sqrt() * (dhp.to_radians() / 2.0).sin();
let lpm = (l1 + l2) / 2.0;
let cpm = (c1p + c2p) / 2.0;
let hpm = if c1p * c2p == 0.0 {
h1p + h2p
} else if (h1p - h2p).abs() <= 180.0 {
(h1p + h2p) / 2.0
} else if h1p + h2p < 360.0 {
(h1p + h2p + 360.0) / 2.0
} else {
(h1p + h2p - 360.0) / 2.0
};
let t = 1.0 - 0.17 * (hpm - 30.0).to_radians().cos() + 0.24 * (2.0 * hpm).to_radians().cos() + 0.32 * (3.0 * hpm + 6.0).to_radians().cos()
- 0.20 * (4.0 * hpm - 63.0).to_radians().cos();
let dtheta = 30.0 * (-((hpm - 275.0) / 25.0).powi(2)).exp();
let rc = 2.0 * (cpm.powi(7) / (cpm.powi(7) + 25f64.powi(7))).sqrt();
let sl = 1.0 + 0.015 * (lpm - 50.0).powi(2) / (20.0 + (lpm - 50.0).powi(2)).sqrt();
let sc = 1.0 + 0.045 * cpm;
let sh = 1.0 + 0.015 * cpm * t;
let rt = -(2.0 * dtheta).to_radians().sin() * rc;
let v = (dlp / sl).powi(2) + (dcp / sc).powi(2) + (dhp_big / sh).powi(2) + rt * (dcp / sc) * (dhp_big / sh);
v.max(0.0).sqrt() as f32
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn white_and_black() {
let w = srgb_to_lab([1.0, 1.0, 1.0]);
assert!((w.l - 100.0).abs() < 0.05 && w.a.abs() < 0.05 && w.b.abs() < 0.05, "{w:?}");
let k = srgb_to_lab([0.0, 0.0, 0.0]);
assert!(k.l.abs() < 0.01);
}
#[test]
fn lab_roundtrip() {
for rgb in [[0.2, 0.4, 0.6], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.9, 0.9, 0.1]] {
let back = lab_to_srgb(srgb_to_lab(rgb));
for i in 0..3 {
assert!((back[i] - rgb[i]).abs() < 1e-3, "{rgb:?} → {back:?}");
}
}
}
#[test]
fn ciede2000_reference_pairs() {
// Sharma, Wu & Dalal (2005) test data, pairs 1 and 7.
let d = delta_e2000(Lab::new(50.0, 2.6772, -79.7751), Lab::new(50.0, 0.0, -82.7485));
assert!((d - 2.0425).abs() < 1e-3, "{d}");
let d = delta_e2000(Lab::new(50.0, 0.0, 0.0), Lab::new(50.0, -1.0, 2.0));
assert!((d - 2.3669).abs() < 1e-3, "{d}");
}
}

638
crates/color/src/cms/mod.rs Normal file
View File

@ -0,0 +1,638 @@
//! Colour management: working spaces, RGB ↔ CMYK ↔ Lab conversions with rendering intents,
//! gamut checks and soft-proof transforms.
//!
//! * **RGB working spaces** are ICC matrix/shaper profiles via [`moxcms`] (sRGB, Adobe RGB (1998)
//! compatible, Display P3, ProPhoto RGB) or user `.icc` files. Display output is sRGB.
//! * **CMYK spaces:** [`GENERIC_CMYK`] is our own documented parametric press model
//! ([`generic`]); [`DEVICE_CMYK`] is the old profile-free formula (kept for exact legacy numbers);
//! any CMYK `.icc` the user loads goes through `moxcms` (LUT-based profiles).
//! * The **active settings** (Edit → Color Settings) are process-wide; [`crate::Color::to_rgb`]
//! uses them. Code that needs deterministic numbers builds its own [`Cms`] with [`Cms::new`].
pub mod generic;
pub mod icc;
pub mod lab;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock, RwLock};
use serde::{Deserialize, Serialize};
pub use generic::GenericCmyk;
pub use icc::IccProfile;
pub use lab::{Lab, delta_e76, delta_e2000};
use crate::Color;
pub const SRGB: &str = "sRGB IEC61966-2.1";
pub const ADOBE_RGB: &str = "Adobe RGB (1998) compatible";
pub const DISPLAY_P3: &str = "Display P3";
pub const PROPHOTO_RGB: &str = "ProPhoto RGB";
/// Our parametric press model (see [`generic`]). Not an Adobe/ECI profile.
pub const GENERIC_CMYK: &str = "DrawCraft Generic CMYK (SWOP-like)";
/// Profile-free CMYK (`rgb = (1−c)(1−k)` …): uncalibrated, kept for legacy numbers.
pub const DEVICE_CMYK: &str = "Device CMYK (uncalibrated)";
/// ΔE2000 above which a colour counts as out of the CMYK gamut (the gamut warning).
pub const GAMUT_THRESHOLD: f32 = 2.0;
/// ICC rendering intent.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Intent {
Perceptual,
#[default]
RelativeColorimetric,
Saturation,
AbsoluteColorimetric,
}
impl Intent {
pub const ALL: [Intent; 4] = [Intent::Perceptual, Intent::RelativeColorimetric, Intent::Saturation, Intent::AbsoluteColorimetric];
/// Parse `perceptual`, `relative`/`relativeColorimetric`, `saturation`, `absolute`/`absoluteColorimetric`.
pub fn parse(s: &str) -> Option<Self> {
Some(match s.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() {
"perceptual" => Intent::Perceptual,
"relative" | "relativecolorimetric" => Intent::RelativeColorimetric,
"saturation" => Intent::Saturation,
"absolute" | "absolutecolorimetric" => Intent::AbsoluteColorimetric,
_ => return None,
})
}
pub fn id(&self) -> &'static str {
match self {
Intent::Perceptual => "perceptual",
Intent::RelativeColorimetric => "relative",
Intent::Saturation => "saturation",
Intent::AbsoluteColorimetric => "absolute",
}
}
pub fn label(&self) -> &'static str {
match self {
Intent::Perceptual => "Perceptual",
Intent::RelativeColorimetric => "Relative Colorimetric",
Intent::Saturation => "Saturation",
Intent::AbsoluteColorimetric => "Absolute Colorimetric",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ProfileKind {
Rgb,
Cmyk,
Gray,
}
/// A profile the user can pick.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ProfileInfo {
pub name: String,
pub kind: ProfileKind,
pub builtin: bool,
}
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum CmsError {
#[error("unknown colour profile: {0}")]
UnknownProfile(String),
#[error("invalid ICC profile: {0}")]
BadProfile(String),
#[error("unsupported: {0}")]
Unsupported(String),
#[error("cannot read profile: {0}")]
Io(String),
}
/// Edit → Color Settings.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ColorSettings {
/// RGB working space.
pub rgb: String,
/// CMYK working space.
pub cmyk: String,
/// Conversion intent (Convert to CMYK, document colour mode).
pub intent: Intent,
/// Black-point compensation.
pub bpc: bool,
}
impl Default for ColorSettings {
fn default() -> Self {
Self { rgb: SRGB.into(), cmyk: GENERIC_CMYK.into(), intent: Intent::RelativeColorimetric, bpc: true }
}
}
/// Target colour model for [`Cms::convert`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Model {
Rgb,
Cmyk,
Gray,
}
#[derive(Clone)]
enum CmykSpace {
Generic(Arc<GenericCmyk>),
Device,
Icc(Arc<IccProfile>),
}
#[derive(Clone)]
enum RgbSpace {
Srgb,
Icc(Arc<IccProfile>),
}
fn generic() -> Arc<GenericCmyk> {
static G: OnceLock<Arc<GenericCmyk>> = OnceLock::new();
G.get_or_init(|| Arc::new(GenericCmyk::new(generic::swop_like()))).clone()
}
static USER: RwLock<Vec<Arc<IccProfile>>> = RwLock::new(Vec::new());
fn builtin_rgb_cache(name: &str) -> Option<Arc<IccProfile>> {
static CACHE: Mutex<Vec<Arc<IccProfile>>> = Mutex::new(Vec::new());
let mut c = CACHE.lock().unwrap_or_else(|e| e.into_inner());
if let Some(p) = c.iter().find(|p| p.name == name) {
return Some(p.clone());
}
let p = Arc::new(icc::builtin_rgb(name)?);
c.push(p.clone());
Some(p)
}
fn user_profile(name: &str) -> Option<Arc<IccProfile>> {
USER.read().unwrap_or_else(|e| e.into_inner()).iter().find(|p| p.name == name).cloned()
}
fn cmyk_space(name: &str) -> Result<CmykSpace, CmsError> {
match name {
GENERIC_CMYK => Ok(CmykSpace::Generic(generic())),
DEVICE_CMYK => Ok(CmykSpace::Device),
_ => match user_profile(name) {
Some(p) if p.kind == ProfileKind::Cmyk => Ok(CmykSpace::Icc(p)),
Some(_) => Err(CmsError::Unsupported(format!("{name} is not a CMYK profile"))),
None => Err(CmsError::UnknownProfile(name.into())),
},
}
}
fn rgb_space(name: &str) -> Result<RgbSpace, CmsError> {
if name == SRGB {
return Ok(RgbSpace::Srgb);
}
if let Some(p) = builtin_rgb_cache(name) {
return Ok(RgbSpace::Icc(p));
}
match user_profile(name) {
Some(p) if p.kind == ProfileKind::Rgb => Ok(RgbSpace::Icc(p)),
Some(_) => Err(CmsError::Unsupported(format!("{name} is not an RGB profile"))),
None => Err(CmsError::UnknownProfile(name.into())),
}
}
/// Every profile available (built-in first, then user-loaded).
pub fn profiles() -> Vec<ProfileInfo> {
let mut v: Vec<ProfileInfo> = [SRGB, ADOBE_RGB, DISPLAY_P3, PROPHOTO_RGB]
.iter()
.map(|n| ProfileInfo { name: (*n).into(), kind: ProfileKind::Rgb, builtin: true })
.chain([GENERIC_CMYK, DEVICE_CMYK].iter().map(|n| ProfileInfo { name: (*n).into(), kind: ProfileKind::Cmyk, builtin: true }))
.collect();
for p in USER.read().unwrap_or_else(|e| e.into_inner()).iter() {
v.push(ProfileInfo { name: p.name.clone(), kind: p.kind, builtin: false });
}
v
}
/// Register an ICC profile from bytes (named by its description unless `name` is given).
/// Re-registering a name replaces the old profile.
pub fn register_icc(bytes: &[u8], name: Option<String>) -> Result<ProfileInfo, CmsError> {
let p = IccProfile::from_bytes(name, bytes)?;
if p.kind == ProfileKind::Gray {
return Err(CmsError::Unsupported("grayscale profiles can't be working spaces yet".into()));
}
if [SRGB, ADOBE_RGB, DISPLAY_P3, PROPHOTO_RGB, GENERIC_CMYK, DEVICE_CMYK].contains(&p.name.as_str()) {
return Err(CmsError::Unsupported(format!("{} clashes with a built-in profile name", p.name)));
}
if !p.usable() {
return Err(CmsError::Unsupported(format!("{}: no usable transform to/from sRGB", p.name)));
}
let info = ProfileInfo { name: p.name.clone(), kind: p.kind, builtin: false };
let mut u = USER.write().unwrap_or_else(|e| e.into_inner());
u.retain(|x| x.name != info.name);
u.push(Arc::new(p));
drop(u);
clear_proof_cache();
Ok(info)
}
/// Load a `.icc`/`.icm` file from disk (native only).
#[cfg(not(target_arch = "wasm32"))]
pub fn load_icc_file(path: &std::path::Path) -> Result<ProfileInfo, CmsError> {
let bytes = std::fs::read(path).map_err(|e| CmsError::Io(format!("{}: {e}", path.display())))?;
register_icc(&bytes, None)
}
/// A colour-management context: resolved working spaces plus conversion settings.
#[derive(Clone)]
pub struct Cms {
settings: ColorSettings,
rgb: RgbSpace,
cmyk: CmykSpace,
}
impl std::fmt::Debug for Cms {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Cms").field("settings", &self.settings).finish()
}
}
impl Default for Cms {
fn default() -> Self {
Self { settings: ColorSettings::default(), rgb: RgbSpace::Srgb, cmyk: CmykSpace::Generic(generic()) }
}
}
impl Cms {
pub fn new(settings: &ColorSettings) -> Result<Self, CmsError> {
Ok(Self { settings: settings.clone(), rgb: rgb_space(&settings.rgb)?, cmyk: cmyk_space(&settings.cmyk)? })
}
pub fn settings(&self) -> &ColorSettings {
&self.settings
}
pub fn rgb_is_srgb(&self) -> bool {
matches!(self.rgb, RgbSpace::Srgb)
}
/// Working RGB → display sRGB.
pub fn rgb_to_srgb(&self, rgb: [f32; 3]) -> [f32; 3] {
match &self.rgb {
RgbSpace::Srgb => rgb,
RgbSpace::Icc(p) => p.to_srgb(&rgb, Intent::RelativeColorimetric).unwrap_or(rgb),
}
}
/// Display sRGB → working RGB.
pub fn srgb_to_rgb(&self, srgb: [f32; 3]) -> [f32; 3] {
match &self.rgb {
RgbSpace::Srgb => srgb,
RgbSpace::Icc(p) => p.from_srgb(srgb, Intent::RelativeColorimetric).map(|v| [v[0], v[1], v[2]]).unwrap_or(srgb),
}
}
fn cmyk_to_srgb_in(space: &CmykSpace, cmyk: [f32; 4], absolute: bool) -> [f32; 3] {
let cmyk = cmyk.map(|v| v.clamp(0.0, 1.0));
match space {
CmykSpace::Generic(g) => g.to_srgb(cmyk, absolute),
CmykSpace::Device => naive_cmyk_to_rgb(cmyk),
CmykSpace::Icc(p) => {
let i = if absolute { Intent::AbsoluteColorimetric } else { Intent::RelativeColorimetric };
p.to_srgb(&cmyk, i).unwrap_or_else(|| naive_cmyk_to_rgb(cmyk))
}
}
}
fn srgb_to_cmyk_in(space: &CmykSpace, srgb: [f32; 3], intent: Intent, bpc: bool) -> [f32; 4] {
match space {
CmykSpace::Generic(g) => g.from_srgb(srgb, intent, bpc),
CmykSpace::Device => naive_rgb_to_cmyk(srgb),
CmykSpace::Icc(p) => p.from_srgb(srgb, intent).map(|v| [v[0], v[1], v[2], v[3]]).unwrap_or_else(|| naive_rgb_to_cmyk(srgb)),
}
}
/// CMYK in the working space → display sRGB (`absolute` simulates the paper colour).
pub fn cmyk_to_srgb(&self, cmyk: [f32; 4], absolute: bool) -> [f32; 3] {
Self::cmyk_to_srgb_in(&self.cmyk, cmyk, absolute)
}
/// Display sRGB → working CMYK with `intent`.
pub fn srgb_to_cmyk(&self, srgb: [f32; 3], intent: Intent) -> [f32; 4] {
Self::srgb_to_cmyk_in(&self.cmyk, srgb, intent, self.settings.bpc)
}
/// Media-relative Lab of a working-space CMYK colour.
pub fn cmyk_to_lab(&self, cmyk: [f32; 4]) -> Lab {
match &self.cmyk {
CmykSpace::Generic(g) => g.to_lab_rel(cmyk.map(|v| v.clamp(0.0, 1.0))),
_ => lab::srgb_to_lab(self.cmyk_to_srgb(cmyk, false)),
}
}
/// Lab → working CMYK with `intent`.
pub fn lab_to_cmyk(&self, l: Lab, intent: Intent) -> [f32; 4] {
match &self.cmyk {
CmykSpace::Generic(g) => g.from_lab(l, intent, self.settings.bpc),
_ => self.srgb_to_cmyk(lab::lab_to_srgb(l), intent),
}
}
/// Display sRGB of any colour.
pub fn display_rgb(&self, c: &Color) -> [f32; 3] {
match *c {
Color::Rgb { r, g, b } => self.rgb_to_srgb([r, g, b]),
Color::Cmyk { c, m, y, k } => self.cmyk_to_srgb([c, m, y, k], false),
Color::Gray { k } => [1.0 - k.clamp(0.0, 1.0); 3],
}
}
/// CIE Lab (D50) of any colour.
pub fn lab(&self, c: &Color) -> Lab {
match *c {
Color::Cmyk { c, m, y, k } => self.cmyk_to_lab([c, m, y, k]),
_ => lab::srgb_to_lab(self.display_rgb(c)),
}
}
/// Ink values of any colour in the working CMYK space (CMYK passes through; grey is K only).
pub fn to_cmyk(&self, c: &Color, intent: Intent) -> [f32; 4] {
match *c {
Color::Cmyk { c, m, y, k } => [c, m, y, k],
Color::Gray { k } => [0.0, 0.0, 0.0, k],
Color::Rgb { .. } => self.srgb_to_cmyk(self.display_rgb(c), intent),
}
}
/// Convert `c` into `model` (a colour already in `model` is returned unchanged).
pub fn convert(&self, c: &Color, model: Model, intent: Intent) -> Color {
match (c, model) {
(Color::Rgb { .. }, Model::Rgb) | (Color::Cmyk { .. }, Model::Cmyk) | (Color::Gray { .. }, Model::Gray) => *c,
(_, Model::Cmyk) => {
let [c, m, y, k] = self.to_cmyk(c, intent);
Color::Cmyk { c, m, y, k }
}
(_, Model::Rgb) => {
let [r, g, b] = self.srgb_to_rgb(self.display_rgb(c));
Color::Rgb { r, g, b }
}
(_, Model::Gray) => {
// Neutral with the same L* (grey ink percentage is 1 − lightness on screen).
let l = self.lab(c).l;
let v = lab::xyz_to_srgb(lab::lab_to_xyz(Lab::new(l, 0.0, 0.0)))[1];
Color::Gray { k: (1.0 - v).clamp(0.0, 1.0) }
}
}
}
/// Colour from Lab (in the working RGB space).
pub fn from_lab(&self, l: Lab) -> Color {
let [r, g, b] = self.srgb_to_rgb(lab::lab_to_srgb(l));
Color::Rgb { r, g, b }
}
/// How far (ΔE2000) the best CMYK reproduction of `c` is from `c` (0 for CMYK/grey colours).
pub fn gamut_error(&self, c: &Color) -> f32 {
match c {
Color::Rgb { .. } => {
let srgb = self.display_rgb(c);
let src = lab::srgb_to_lab(srgb);
match &self.cmyk {
CmykSpace::Generic(g) => g.gamut_error(src),
CmykSpace::Device => 0.0,
CmykSpace::Icc(_) => {
let back = self.cmyk_to_srgb(self.srgb_to_cmyk(srgb, Intent::RelativeColorimetric), false);
delta_e2000(src, lab::srgb_to_lab(back))
}
}
}
_ => 0.0,
}
}
/// Gamut warning: can this colour be printed in the working CMYK space?
pub fn out_of_gamut(&self, c: &Color) -> bool {
self.gamut_error(c) > GAMUT_THRESHOLD
}
/// Soft-proof one display sRGB colour.
pub fn proof_srgb(&self, srgb: [f32; 3], proof: &ProofSetup) -> [f32; 3] {
let srgb = srgb.map(|v| v.clamp(0.0, 1.0));
match &proof.target {
ProofTarget::Srgb | ProofTarget::MonitorRgb => srgb,
ProofTarget::LegacyMacRgb => srgb.map(|v| lab::linear_to_srgb(v.powf(1.8))),
ProofTarget::Protanopia => simulate_cvd(srgb, &PROTANOPIA),
ProofTarget::Deuteranopia => simulate_cvd(srgb, &DEUTERANOPIA),
ProofTarget::WorkingCmyk => {
let cmyk = self.srgb_to_cmyk(srgb, proof.intent);
self.cmyk_to_srgb(cmyk, proof.simulate_paper)
}
ProofTarget::Cmyk(name) => match cmyk_space(name) {
Ok(sp) => {
let cmyk = Self::srgb_to_cmyk_in(&sp, srgb, proof.intent, self.settings.bpc);
Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper)
}
Err(_) => srgb,
},
}
}
/// Display sRGB of CMYK inks in the proof's CMYK space (the working space for RGB targets).
pub fn proof_cmyk_to_srgb(&self, cmyk: [f32; 4], proof: &ProofSetup) -> [f32; 3] {
match &proof.target {
ProofTarget::Cmyk(name) => match cmyk_space(name) {
Ok(sp) => Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper),
Err(_) => self.cmyk_to_srgb(cmyk, proof.simulate_paper),
},
_ => self.cmyk_to_srgb(cmyk, proof.simulate_paper),
}
}
/// A cached 3-D lookup table implementing [`Cms::proof_srgb`] for 8-bit pixels.
pub fn proof_lut(&self, proof: &ProofSetup) -> Arc<ProofLut> {
let key = format!("{:?}|{:?}|{:?}|{}", self.settings, proof.target, proof.intent, proof.simulate_paper);
let mut cache = PROOF_CACHE.lock().unwrap_or_else(|e| e.into_inner());
if let Some((_, l)) = cache.iter().find(|(k, _)| *k == key) {
return l.clone();
}
let lut = Arc::new(ProofLut::build(|rgb| self.proof_srgb(rgb, proof)));
if cache.len() >= 8 {
cache.remove(0);
}
cache.push((key, lut.clone()));
lut
}
}
static PROOF_CACHE: Mutex<Vec<(String, Arc<ProofLut>)>> = Mutex::new(Vec::new());
fn clear_proof_cache() {
PROOF_CACHE.lock().unwrap_or_else(|e| e.into_inner()).clear();
}
pub(crate) fn naive_cmyk_to_rgb([c, m, y, k]: [f32; 4]) -> [f32; 3] {
[(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)]
}
pub(crate) fn naive_rgb_to_cmyk([r, g, b]: [f32; 3]) -> [f32; 4] {
let k = 1.0 - r.max(g).max(b);
if k >= 1.0 {
return [0.0, 0.0, 0.0, 1.0];
}
[(1.0 - r - k) / (1.0 - k), (1.0 - g - k) / (1.0 - k), (1.0 - b - k) / (1.0 - k), k]
}
// Machado, Oliveira & Fernandes (2009) dichromacy simulation matrices (severity 1.0, linear RGB).
const PROTANOPIA: [[f32; 3]; 3] = [[0.152_286, 1.052_583, -0.204_868], [0.114_503, 0.786_281, 0.099_216], [-0.003_882, -0.048_116, 1.051_998]];
const DEUTERANOPIA: [[f32; 3]; 3] = [[0.367_322, 0.860_646, -0.227_968], [0.280_085, 0.672_501, 0.047_413], [-0.011_820, 0.042_940, 0.968_881]];
fn simulate_cvd(srgb: [f32; 3], m: &[[f32; 3]; 3]) -> [f32; 3] {
lab::mat_mul(m, lab::srgb_linear(srgb)).map(|v| lab::linear_to_srgb(v.clamp(0.0, 1.0)))
}
/// View → Proof Setup target.
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "kind", content = "profile", rename_all = "camelCase")]
pub enum ProofTarget {
/// Working CMYK.
#[default]
WorkingCmyk,
/// A specific CMYK profile (Customize…).
Cmyk(String),
/// Legacy Macintosh RGB (Gamma 1.8).
LegacyMacRgb,
/// Internet Standard RGB (sRGB).
Srgb,
/// Monitor RGB (no simulation).
MonitorRgb,
/// Colour blindness — protanopia type.
Protanopia,
/// Colour blindness — deuteranopia type.
Deuteranopia,
}
impl ProofTarget {
pub fn parse(s: &str) -> Option<Self> {
if let Some(p) = s.strip_prefix("cmyk:") {
return Some(ProofTarget::Cmyk(p.to_string()));
}
Some(match s {
"workingCmyk" | "cmyk" => ProofTarget::WorkingCmyk,
"legacyMacRgb" | "macRgb" => ProofTarget::LegacyMacRgb,
"srgb" | "sRGB" => ProofTarget::Srgb,
"monitorRgb" | "monitor" => ProofTarget::MonitorRgb,
"protanopia" => ProofTarget::Protanopia,
"deuteranopia" => ProofTarget::Deuteranopia,
_ => return None,
})
}
pub fn id(&self) -> String {
match self {
ProofTarget::WorkingCmyk => "workingCmyk".into(),
ProofTarget::Cmyk(p) => format!("cmyk:{p}"),
ProofTarget::LegacyMacRgb => "legacyMacRgb".into(),
ProofTarget::Srgb => "srgb".into(),
ProofTarget::MonitorRgb => "monitorRgb".into(),
ProofTarget::Protanopia => "protanopia".into(),
ProofTarget::Deuteranopia => "deuteranopia".into(),
}
}
/// Whether the target simulates a CMYK press (separations make sense).
pub fn is_cmyk(&self) -> bool {
matches!(self, ProofTarget::WorkingCmyk | ProofTarget::Cmyk(_))
}
}
/// View → Proof Setup / Proof Colors / Separations Preview state for rendering.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProofSetup {
pub target: ProofTarget,
pub intent: Intent,
/// Simulate Paper Color (absolute colorimetric display of the press).
#[serde(default)]
pub simulate_paper: bool,
/// Separations Preview: names of the visible plates ("Cyan", "Magenta", "Yellow", "Black",
/// spot swatch names). `None` = normal composite proof.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub separations: Option<Vec<String>>,
}
/// Process plate names in order.
pub const PROCESS_PLATES: [&str; 4] = ["Cyan", "Magenta", "Yellow", "Black"];
const LUT_N: usize = 17;
/// A 17³ RGB → RGB lookup table with trilinear interpolation.
pub struct ProofLut {
data: Vec<[f32; 3]>,
}
impl ProofLut {
pub fn build(f: impl Fn([f32; 3]) -> [f32; 3]) -> Self {
let n = LUT_N;
let mut data = Vec::with_capacity(n * n * n);
for r in 0..n {
for g in 0..n {
for b in 0..n {
let s = (n - 1) as f32;
data.push(f([r as f32 / s, g as f32 / s, b as f32 / s]));
}
}
}
Self { data }
}
pub fn apply(&self, rgb: [f32; 3]) -> [f32; 3] {
let n = LUT_N;
let s = (n - 1) as f32;
let p = rgb.map(|v| v.clamp(0.0, 1.0) * s);
let i = p.map(|v| (v.floor() as usize).min(n - 2));
let t = [p[0] - i[0] as f32, p[1] - i[1] as f32, p[2] - i[2] as f32];
let at = |r: usize, g: usize, b: usize| self.data[(r * n + g) * n + b];
let mut out = [0.0f32; 3];
for (dr, wr) in [(0, 1.0 - t[0]), (1, t[0])] {
for (dg, wg) in [(0, 1.0 - t[1]), (1, t[1])] {
for (db, wb) in [(0, 1.0 - t[2]), (1, t[2])] {
let w = wr * wg * wb;
if w == 0.0 {
continue;
}
let v = at(i[0] + dr, i[1] + dg, i[2] + db);
for c in 0..3 {
out[c] += w * v[c];
}
}
}
}
out
}
pub fn apply8(&self, rgb: [u8; 3]) -> [u8; 3] {
self.apply(rgb.map(|v| v as f32 / 255.0)).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)
}
}
// ---------- the active (process-wide) settings ----------
static ACTIVE: RwLock<Option<Arc<Cms>>> = RwLock::new(None);
static RGB_IS_SRGB: AtomicBool = AtomicBool::new(true);
static CMYK_IS_DEVICE: AtomicBool = AtomicBool::new(false);
/// The active colour-management context (Edit → Color Settings).
pub fn active() -> Arc<Cms> {
if let Some(c) = ACTIVE.read().unwrap_or_else(|e| e.into_inner()).as_ref() {
return c.clone();
}
let mut w = ACTIVE.write().unwrap_or_else(|e| e.into_inner());
w.get_or_insert_with(|| Arc::new(Cms::default())).clone()
}
pub fn active_settings() -> ColorSettings {
active().settings.clone()
}
/// Replace the active settings (fails, leaving them unchanged, if a profile is unknown).
pub fn set_active(settings: &ColorSettings) -> Result<(), CmsError> {
let cms = Cms::new(settings)?;
RGB_IS_SRGB.store(cms.rgb_is_srgb(), Ordering::Relaxed);
CMYK_IS_DEVICE.store(matches!(cms.cmyk, CmykSpace::Device), Ordering::Relaxed);
*ACTIVE.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(cms));
Ok(())
}
/// Fast path for [`Color::to_rgb`]: RGB colours need no conversion while the working space is sRGB.
pub(crate) fn rgb_is_srgb() -> bool {
RGB_IS_SRGB.load(Ordering::Relaxed)
}
pub(crate) fn cmyk_is_device() -> bool {
CMYK_IS_DEVICE.load(Ordering::Relaxed)
}
#[cfg(test)]
mod tests;

View File

@ -0,0 +1,202 @@
use super::*;
fn cms() -> Cms {
Cms::new(&ColorSettings::default()).unwrap()
}
/// Without black-point compensation, relative colorimetric reproduces in-gamut colours exactly.
fn cms_no_bpc() -> Cms {
Cms::new(&ColorSettings { bpc: false, ..Default::default() }).unwrap()
}
fn de(a: [f32; 3], b: [f32; 3]) -> f32 {
delta_e2000(lab::srgb_to_lab(a), lab::srgb_to_lab(b))
}
#[test]
fn intents_parse_and_name() {
for i in Intent::ALL {
assert_eq!(Intent::parse(i.id()), Some(i));
assert_eq!(Intent::parse(i.label()), Some(i));
}
assert_eq!(Intent::parse("bogus"), None);
}
#[test]
fn srgb_via_moxcms_matches_analytic() {
// The built-in sRGB ICC profile through moxcms agrees with our analytic sRGB maths.
let p = icc::builtin_rgb(SRGB).unwrap();
for rgb in [[0.2, 0.5, 0.8], [1.0, 0.0, 0.0], [0.5, 0.5, 0.5]] {
let out = p.to_srgb(&rgb, Intent::RelativeColorimetric).unwrap();
assert!(de(out, rgb) < 0.5, "{rgb:?} → {out:?}");
}
}
#[test]
fn paper_white_and_rich_black() {
let c = cms();
assert_eq!(c.srgb_to_cmyk([1.0, 1.0, 1.0], Intent::RelativeColorimetric), [0.0; 4]);
let k = c.srgb_to_cmyk([0.0, 0.0, 0.0], Intent::RelativeColorimetric);
assert!(k[3] > 0.8, "black generation puts most of the black in K: {k:?}");
assert!(k.iter().sum::<f32>() <= 3.0 + 1e-3, "TAC limit: {k:?}");
assert!(k[0] > 0.3 && k[1] > 0.3 && k[2] > 0.3, "rich black: {k:?}");
assert_eq!(c.cmyk_to_srgb([0.0; 4], false), [1.0, 1.0, 1.0]);
}
#[test]
fn rgb_cmyk_rgb_roundtrip_in_gamut() {
let c = cms_no_bpc();
for rgb in [[0.8, 0.6, 0.5], [0.5, 0.5, 0.5], [0.3, 0.45, 0.35], [0.9, 0.85, 0.3], [0.55, 0.4, 0.6], [0.35, 0.55, 0.7], [0.75, 0.75, 0.75]] {
let cmyk = c.srgb_to_cmyk(rgb, Intent::RelativeColorimetric);
let back = c.cmyk_to_srgb(cmyk, false);
assert!(de(rgb, back) < 1.5, "{rgb:?} → {cmyk:?} → {back:?} ΔE {}", de(rgb, back));
}
}
#[test]
fn cmyk_lab_cmyk_roundtrip() {
let c = cms_no_bpc();
for cmyk in [[0.2, 0.4, 0.6, 0.1], [0.0, 0.0, 0.0, 0.5], [0.5, 0.1, 0.0, 0.0], [0.1, 0.7, 0.3, 0.2]] {
let l = c.cmyk_to_lab(cmyk);
// Lab here is media-relative, i.e. what relative colorimetric reproduces exactly.
let back = c.lab_to_cmyk(l, Intent::RelativeColorimetric);
let l2 = c.cmyk_to_lab(back);
assert!(delta_e2000(l, l2) < 1.0, "{cmyk:?} → {l:?} → {back:?} → {l2:?}");
}
}
#[test]
fn gcr_puts_grey_into_black() {
let k = cms().srgb_to_cmyk([0.4, 0.4, 0.4], Intent::RelativeColorimetric);
assert!(k[3] > 0.2, "{k:?}");
let spread = k[0].max(k[1]).max(k[2]) - k[0].min(k[1]).min(k[2]);
assert!(spread < 0.2, "neutral stays balanced: {k:?}");
}
#[test]
fn gamut_warning() {
let c = cms();
assert!(c.out_of_gamut(&Color::rgb(0.0, 0.0, 1.0)), "sRGB blue is outside SWOP-like CMYK");
assert!(c.out_of_gamut(&Color::rgb(0.0, 1.0, 0.0)), "sRGB green is outside");
assert!(!c.out_of_gamut(&Color::rgb(0.8, 0.6, 0.5)), "skin tone is printable");
assert!(!c.out_of_gamut(&Color::rgb(0.5, 0.5, 0.5)));
assert!(!c.out_of_gamut(&Color::cmyk(1.0, 0.0, 0.0, 0.0)), "CMYK colours are in gamut by definition");
// The uncalibrated device space has no gamut limits.
let dev = Cms::new(&ColorSettings { cmyk: DEVICE_CMYK.into(), ..Default::default() }).unwrap();
assert!(!dev.out_of_gamut(&Color::rgb(0.0, 0.0, 1.0)));
}
#[test]
fn intents_differ() {
let c = cms();
let blue = [0.0, 0.0, 1.0];
let res: Vec<[f32; 4]> = Intent::ALL.iter().map(|i| c.srgb_to_cmyk(blue, *i)).collect();
let dist = |a: [f32; 4], b: [f32; 4]| (0..4).map(|i| (a[i] - b[i]).abs()).sum::<f32>();
assert!(dist(res[0], res[1]) > 0.02, "perceptual vs relative: {res:?}");
assert!(dist(res[2], res[1]) > 0.02, "saturation vs relative: {res:?}");
// Absolute colorimetry keeps the source white brighter than paper → less ink on light greys.
let g = [0.88, 0.88, 0.88];
let rel = c.srgb_to_cmyk(g, Intent::RelativeColorimetric);
let abs = c.srgb_to_cmyk(g, Intent::AbsoluteColorimetric);
assert!(rel.iter().sum::<f32>() > abs.iter().sum::<f32>() + 0.02, "{rel:?} vs {abs:?}");
// Perceptual compresses in-gamut saturated colours too (relative leaves them alone).
let red = lab::srgb_to_lab([0.8, 0.25, 0.25]);
let pr = c.cmyk_to_lab(c.srgb_to_cmyk([0.8, 0.25, 0.25], Intent::Perceptual));
let rr = c.cmyk_to_lab(c.srgb_to_cmyk([0.8, 0.25, 0.25], Intent::RelativeColorimetric));
assert!(delta_e2000(rr, red) < delta_e2000(pr, red), "relative is closer for in-gamut colours");
}
#[test]
fn simulate_paper_tints_white() {
let c = cms();
let rel = c.cmyk_to_srgb([0.0; 4], false);
let abs = c.cmyk_to_srgb([0.0; 4], true);
assert_eq!(rel, [1.0; 3]);
assert!(abs[0] < 0.98 && abs[2] < abs[0], "paper is darker and slightly warm: {abs:?}");
}
#[test]
fn device_cmyk_keeps_legacy_numbers() {
let dev = Cms::new(&ColorSettings { cmyk: DEVICE_CMYK.into(), ..Default::default() }).unwrap();
assert_eq!(dev.cmyk_to_srgb([1.0, 0.0, 0.0, 0.0], false), [0.0, 1.0, 1.0]);
assert_eq!(dev.srgb_to_cmyk([1.0, 0.0, 0.0], Intent::Perceptual), [0.0, 1.0, 1.0, 0.0]);
}
#[test]
fn convert_between_models() {
let c = cms();
let red = Color::rgb(0.9, 0.2, 0.2);
let cmyk = c.convert(&red, Model::Cmyk, Intent::RelativeColorimetric);
assert!(matches!(cmyk, Color::Cmyk { .. }));
assert_eq!(c.convert(&cmyk, Model::Cmyk, Intent::Perceptual), cmyk, "idempotent");
let back = c.convert(&cmyk, Model::Rgb, Intent::RelativeColorimetric);
assert!(de(back.to_rgb_uncalibrated(), [0.9, 0.2, 0.2]) < 6.0, "{back:?}");
let g = c.convert(&Color::rgb(0.5, 0.5, 0.5), Model::Gray, Intent::RelativeColorimetric);
let Color::Gray { k } = g else { panic!() };
assert!((k - 0.5).abs() < 0.01, "{k}");
}
#[test]
fn wide_gamut_working_space() {
let c = Cms::new(&ColorSettings { rgb: ADOBE_RGB.into(), ..Default::default() }).unwrap();
assert!(!c.rgb_is_srgb());
let grey = c.rgb_to_srgb([0.5, 0.5, 0.5]);
assert!(de(grey, [0.5, 0.5, 0.5]) < 1.5, "{grey:?}");
// Adobe RGB green is outside sRGB: it clips to (nearly) full sRGB green.
let g = c.rgb_to_srgb([0.0, 1.0, 0.0]);
assert!(g[1] > 0.95 && g[0] < 0.1, "{g:?}");
let back = c.srgb_to_rgb(c.rgb_to_srgb([0.4, 0.5, 0.3]));
assert!(de(back, [0.4, 0.5, 0.3]) < 1.0);
assert!(matches!(Cms::new(&ColorSettings { rgb: "Nope".into(), ..Default::default() }), Err(CmsError::UnknownProfile(_))));
}
#[test]
fn proof_lut_matches_direct_proof() {
let c = cms();
let p = ProofSetup::default();
let lut = c.proof_lut(&p);
for rgb in [[0.8, 0.6, 0.5], [0.0, 0.0, 1.0], [0.3, 0.9, 0.2]] {
let direct = c.proof_srgb(rgb, &p);
let via_lut = lut.apply(rgb);
assert!(de(direct, via_lut) < 2.5, "{rgb:?}: {direct:?} vs {via_lut:?}");
}
// Out-of-gamut blue gets visibly duller in the proof.
let b = c.proof_srgb([0.0, 0.0, 1.0], &p);
assert!(de(b, [0.0, 0.0, 1.0]) > 5.0, "{b:?}");
}
#[test]
fn colour_blindness_proofs() {
let c = cms();
let p = ProofSetup { target: ProofTarget::Protanopia, ..Default::default() };
let red = c.proof_srgb([0.9, 0.1, 0.1], &p);
let green = c.proof_srgb([0.1, 0.6, 0.1], &p);
assert!(de(red, green) < de([0.9, 0.1, 0.1], [0.1, 0.6, 0.1]) * 0.5, "red/green confusion");
let d = ProofSetup { target: ProofTarget::Deuteranopia, ..Default::default() };
assert_ne!(c.proof_srgb([0.9, 0.1, 0.1], &d), [0.9, 0.1, 0.1]);
let m = ProofSetup { target: ProofTarget::MonitorRgb, ..Default::default() };
assert_eq!(c.proof_srgb([0.2, 0.3, 0.4], &m), [0.2, 0.3, 0.4]);
assert_eq!(ProofTarget::parse(&ProofTarget::Cmyk("X".into()).id()), Some(ProofTarget::Cmyk("X".into())));
}
#[test]
fn bad_icc_rejected() {
assert!(matches!(register_icc(b"not a profile", None), Err(CmsError::BadProfile(_))));
}
#[cfg(target_os = "macos")]
#[test]
fn loads_system_cmyk_icc() {
let path = std::path::Path::new("/System/Library/ColorSync/Profiles/Generic CMYK Profile.icc");
if !path.exists() {
return;
}
let info = load_icc_file(path).unwrap();
assert_eq!(info.kind, ProfileKind::Cmyk);
assert!(profiles().iter().any(|p| p.name == info.name && !p.builtin));
let c = Cms::new(&ColorSettings { cmyk: info.name.clone(), ..Default::default() }).unwrap();
let rgb = [0.7, 0.5, 0.4];
let back = c.cmyk_to_srgb(c.srgb_to_cmyk(rgb, Intent::RelativeColorimetric), false);
assert!(de(rgb, back) < 5.0, "{back:?}");
assert_eq!(c.cmyk_to_srgb([0.0; 4], false).map(|v| (v * 255.0).round() as u8), [255, 255, 255]);
}

View File

@ -5,6 +5,7 @@
#![forbid(unsafe_code)]
pub mod blend;
pub mod cms;
pub mod gradient;
pub mod harmony;
pub mod swatch;
@ -47,20 +48,46 @@ impl Color {
Color::Gray { k }
}
/// Display RGB (naive, profile-free conversion; colour management comes later).
/// Display (sRGB) colour through the active colour settings ([`cms::active`]): RGB is
/// converted from the working RGB space, CMYK through the working CMYK profile (relative
/// colorimetric). Grey is ink percentage (0 = white).
pub fn to_rgb(&self) -> [f32; 3] {
match *self {
Color::Rgb { r, g, b } => [r, g, b],
Color::Cmyk { c, m, y, k } => [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)],
Color::Rgb { r, g, b } if cms::rgb_is_srgb() => [r, g, b],
Color::Cmyk { c, m, y, k } if cms::cmyk_is_device() => cms::naive_cmyk_to_rgb([c, m, y, k]),
// Illustrator's Gray is ink percentage: 0 = white, 1 = black.
Color::Gray { k } => [1.0 - k; 3],
_ => cms::active().display_rgb(self),
}
}
/// Profile-free display RGB (`(1−c)(1−k)` …), the pre-colour-management formula.
pub fn to_rgb_uncalibrated(&self) -> [f32; 3] {
match *self {
Color::Rgb { r, g, b } => [r, g, b],
Color::Cmyk { c, m, y, k } => cms::naive_cmyk_to_rgb([c, m, y, k]),
Color::Gray { k } => [1.0 - k; 3],
}
}
/// CIE Lab (D50) through the active colour settings.
pub fn to_lab(&self) -> cms::Lab {
cms::active().lab(self)
}
/// Colour-managed CMYK in the working CMYK space with `intent` (see [`Color::to_cmyk`] for the
/// profile-free formula).
pub fn to_cmyk_managed(&self, intent: cms::Intent) -> [f32; 4] {
cms::active().to_cmyk(self, intent)
}
/// Gamut warning: true when this colour can't be reproduced in the working CMYK space.
pub fn out_of_gamut(&self) -> bool {
cms::active().out_of_gamut(self)
}
pub fn to_rgba8(&self, alpha: f32) -> [u8; 4] {
let [r, g, b] = self.to_rgb();
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
[q(r), q(g), q(b), q(alpha)]
}
/// Profile-free CMYK (`k = 1 − max(r, g, b)` …), kept for exact legacy numbers; colour-managed
/// separations use [`Color::to_cmyk_managed`].
pub fn to_cmyk(&self) -> [f32; 4] {
match *self {
Color::Cmyk { c, m, y, k } => [c, m, y, k],
@ -170,9 +197,17 @@ pub enum Paint {
Gradient(Box<GradientPaint>),
Pattern {
pattern: String,
/// Pattern space → document placement (Transform Patterns edits it; identity = tiles
/// anchored at the document origin).
#[serde(default, skip_serializing_if = "is_identity")]
xf: kurbo::Affine,
},
}
fn is_identity(a: &kurbo::Affine) -> bool {
*a == kurbo::Affine::IDENTITY
}
impl Paint {
pub fn solid(c: Color) -> Self {
Paint::Solid { color: c, swatch: None }
@ -192,7 +227,7 @@ impl Paint {
Paint::Solid { color, swatch: Some(n) } => format!("{n} ({})", color.to_hex()),
Paint::Solid { color, .. } => color.to_hex(),
Paint::Gradient(g) => format!("{} gradient", g.gradient.kind.label()),
Paint::Pattern { pattern } => format!("pattern {pattern}"),
Paint::Pattern { pattern, .. } => format!("pattern {pattern}"),
}
}
}
@ -220,8 +255,12 @@ mod tests {
#[test]
fn cmyk_conversions() {
assert_eq!(Color::cmyk(0.0, 0.0, 0.0, 1.0).to_hex(), "#000000");
assert_eq!(Color::cmyk(1.0, 0.0, 0.0, 0.0).to_hex(), "#00ffff");
assert_eq!(Color::cmyk(0.0, 0.0, 0.0, 1.0).to_rgb_uncalibrated(), [0.0, 0.0, 0.0]);
assert_eq!(Color::cmyk(1.0, 0.0, 0.0, 0.0).to_rgb_uncalibrated(), [0.0, 1.0, 1.0]);
// Managed display: paper is white, 100% cyan is a press cyan (not #00ffff).
assert_eq!(Color::cmyk(0.0, 0.0, 0.0, 0.0).to_hex(), "#ffffff");
let [r, g, b] = Color::cmyk(1.0, 0.0, 0.0, 0.0).to_rgb();
assert!(r < 0.2 && g > 0.5 && b > 0.8, "{r} {g} {b}");
let k = Color::rgb(1.0, 0.0, 0.0).to_cmyk();
assert_eq!(k, [0.0, 1.0, 1.0, 0.0]);
}

View File

@ -63,6 +63,10 @@ pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
if let NodeKind::Layer { template: true, .. } = layer.kind {
continue;
}
// Pattern editing mode: only the tile art is editable.
if doc.pattern_edit.as_ref().is_some_and(|e| e.layer != layer.id) {
continue;
}
chain.push(layer.id);
if let Some(h) = hit_children(layer, p, opt, &mut chain) {
return Some(h);
@ -133,6 +137,7 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::SymbolInstance { .. } | NodeKind::Blend { .. } | NodeKind::Envelope { .. } => {
n.geometric_bounds().filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds)
}
NodeKind::Repeat(r) => r.expand().iter().find_map(|g| hit_any(g, p, opt)),
NodeKind::Mesh(m) => {
let bp = m.outline().to_bezpath();
if stroke_contains(&bp, 0.0, opt.tol, p) {
@ -144,12 +149,24 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
}
}
/// Hit anywhere in an evaluated subtree (groups recurse; leaves use [`hit_leaf`]).
fn hit_any(n: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
match &n.kind {
NodeKind::Group { children, .. } => children.iter().rev().find_map(|c| hit_any(c, p, opt)),
_ => hit_leaf(n, p, opt),
}
}
/// Objects (children of layers, or of `scope` in isolation mode) touched by a marquee rect.
pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) -> Vec<NodeId> {
let mut out = Vec::new();
let tops: Vec<&std::sync::Arc<Node>> = match scope.and_then(|s| doc.node(s)) {
Some(s) => s.children().map(|c| c.iter().collect()).unwrap_or_default(),
None => doc.layers.iter().filter(|l| l.visible && !l.locked).flat_map(|l| l.children().into_iter().flatten()).collect(),
None => doc
.layers
.iter()
.filter(|l| l.visible && !l.locked && doc.pattern_edit.as_ref().is_none_or(|e| e.layer == l.id))
.flat_map(|l| l.children().into_iter().flatten()).collect(),
};
fn touches(n: &Node, r: Rect) -> bool {
match &n.kind {

View File

@ -9,6 +9,7 @@ pub mod appearance;
pub mod hit;
pub mod live;
pub mod node;
pub mod pattern;
pub mod selection;
pub mod text;
@ -20,6 +21,7 @@ pub use drawcraft_color as color;
pub use drawcraft_geom as geom;
pub use hit::{Hit, HitKind};
pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType};
pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind};
pub use selection::{AnchorRef, Selection};
pub use text::{CharStyle, Justify, ParaStyle, TextKind, TextObject, TextRun};
@ -263,6 +265,12 @@ pub struct Document {
pub raster_effects_ppi: f64,
#[serde(default)]
pub images: BTreeMap<String, ImageBlob>,
/// Pattern swatch definitions (Object → Pattern).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub patterns: Vec<PatternDef>,
/// Pattern editing mode, while active.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pattern_edit: Option<PatternEdit>,
next_id: u64,
/// Foreign data preserved on round-trip.
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
@ -300,6 +308,8 @@ impl Document {
grid: GridPrefs::default(),
raster_effects_ppi: 72.0,
images: BTreeMap::new(),
patterns: vec![],
pattern_edit: None,
next_id: 1,
unknown: Default::default(),
};
@ -519,6 +529,12 @@ impl Document {
pub fn next_artboard_id(&self) -> u32 {
self.artboards.iter().map(|a| a.id).max().unwrap_or(0) + 1
}
pub fn pattern(&self, name: &str) -> Option<&PatternDef> {
self.patterns.iter().find(|p| p.name == name)
}
pub fn pattern_mut(&mut self, name: &str) -> Option<&mut PatternDef> {
self.patterns.iter_mut().find(|p| p.name == name)
}
pub fn swatch(&self, name: &str) -> Option<&Swatch> {
self.swatches.iter().chain(self.swatch_groups.iter().flat_map(|g| g.swatches.iter())).find(|s| s.name == name)
}

View File

@ -1320,7 +1320,7 @@ fn map_node(n: &Node, f: &dyn Fn(Point) -> Point, piece: f64, outline: Outliner)
}
}
NodeKind::Mesh(m) => m.map(f),
NodeKind::Blend { .. } | NodeKind::Envelope { .. } => {
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) => {
let g = expanded_group(n, outline);
return map_node(&g, f, piece, outline);
}
@ -1373,7 +1373,7 @@ pub fn envelope_bounds(content: &[Arc<Node>], kind: &EnvelopeKind) -> Option<Rec
/// Is this one of the live kinds?
pub fn is_live(n: &Node) -> bool {
matches!(n.kind, NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_))
matches!(n.kind, NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_))
}
/// Mesh tessellation as filled quad paths (no stroke), `n`×`n` per patch.
@ -1401,6 +1401,7 @@ pub fn expand_live_with(n: &Node, outline: Outliner) -> Vec<Node> {
NodeKind::Blend { children, spec } => blend_expand(children, spec),
NodeKind::Envelope { content, kind, fidelity, .. } => envelope_expand(content, kind, *fidelity, outline),
NodeKind::Mesh(m) => mesh_quad_nodes(m, 8),
NodeKind::Repeat(r) => r.expand(),
_ => vec![n.clone()],
}
}

View File

@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize};
use crate::appearance::Appearance;
use crate::live::{BlendSpec, EnvelopeKind, GradientMesh};
use crate::pattern::RepeatSpec;
use crate::text::TextObject;
/// Stable per-document object id. Never reused.
@ -225,6 +226,8 @@ pub enum NodeKind {
},
/// Gradient mesh.
Mesh(GradientMesh),
/// Live Repeat (radial / grid / mirror) of source art.
Repeat(RepeatSpec),
}
fn yes() -> bool {
@ -291,7 +294,8 @@ impl Node {
| NodeKind::Group { children, .. }
| NodeKind::Compound { children, .. }
| NodeKind::Blend { children, .. }
| NodeKind::Envelope { content: children, .. } => Some(children),
| NodeKind::Envelope { content: children, .. }
| NodeKind::Repeat(RepeatSpec { source: children, .. }) => Some(children),
_ => None,
}
}
@ -301,7 +305,8 @@ impl Node {
| NodeKind::Group { children, .. }
| NodeKind::Compound { children, .. }
| NodeKind::Blend { children, .. }
| NodeKind::Envelope { content: children, .. } => Some(children),
| NodeKind::Envelope { content: children, .. }
| NodeKind::Repeat(RepeatSpec { source: children, .. }) => Some(children),
_ => None,
}
}
@ -327,6 +332,7 @@ impl Node {
NodeKind::Blend { .. } => "Blend",
NodeKind::Envelope { .. } => "Envelope",
NodeKind::Mesh(_) => "Mesh",
NodeKind::Repeat(r) => r.kind.label(),
}
}
/// Name shown in the Layers panel: explicit name or `<Kind>`.
@ -373,6 +379,7 @@ impl Node {
}
NodeKind::Envelope { content, kind, .. } => crate::live::envelope_bounds(content, kind),
NodeKind::Mesh(m) => m.bounds(),
NodeKind::Repeat(r) => r.bounds(),
}
}
/// Visual bounds (includes stroke outset).
@ -387,7 +394,7 @@ impl Node {
let o = crate::live::max_outset(children);
drawcraft_geom::union_opt(b, spec.spine.as_ref().and_then(|s| s.bounds()).map(|r| r.inflate(o, o)))
}
NodeKind::Envelope { content, .. } => {
NodeKind::Envelope { content, .. } | NodeKind::Repeat(RepeatSpec { source: content, .. }) => {
let o = crate::live::max_outset(content);
self.geometric_bounds().map(|b| b.inflate(o, o))
}
@ -445,6 +452,7 @@ impl Node {
}
}
NodeKind::Mesh(m) => m.transform(a),
NodeKind::Repeat(r) => r.transform(a, scale_strokes),
}
}
/// Visit this node and all descendants depth first (paint order).

740
crates/doc/src/pattern.rs Normal file
View File

@ -0,0 +1,740 @@
//! Pattern swatches (Object → Pattern) and live Repeats (Object → Repeat): data and pure math.
//!
//! **Patterns.** A [`PatternDef`] is a named tile: art plus a tile rectangle and a tile type
//! (grid, brick by row/column, hex by column/row). Pattern space has the tile's top-left at the
//! origin; instance `(i, j)` is the art translated by `-tile.origin + tile_offset(i, j)`. A
//! [`Paint::Pattern`] maps pattern space into the document with its own `xf` (identity = tiles
//! anchored at the document origin, like Illustrator's ruler origin). Every tiling is periodic
//! on a rectangular *super-tile* ([`PatternDef::period`]), which is what renderers rasterize and
//! what SVG `<pattern>` elements repeat.
//!
//! **Repeats.** A [`RepeatSpec`] holds source art and a radial / grid / mirror arrangement; the
//! instances are the source under [`repeat_transforms`] (evaluated on demand like blends).
use std::sync::Arc;
use drawcraft_color::Paint;
use drawcraft_geom::{Affine, Point, Rect, Vec2};
use serde::{Deserialize, Serialize};
use crate::appearance::AppearanceItem;
use crate::node::{Node, NodeId, NodeKind};
fn half() -> f64 {
0.5
}
fn five() -> u32 {
5
}
fn seventy() -> f32 {
70.0
}
fn yes() -> bool {
true
}
fn three() -> u32 {
3
}
fn full_turn() -> f64 {
360.0
}
// =====================================================================================
// Patterns
// =====================================================================================
/// Pattern Options → Tile Type.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum TileType {
#[default]
Grid,
/// Rows shifted horizontally by `offset` × tile width (0.5 = half brick).
BrickByRow {
#[serde(default = "half")]
offset: f64,
},
/// Columns shifted vertically by `offset` × tile height.
BrickByColumn {
#[serde(default = "half")]
offset: f64,
},
/// Hexagonal tiles in columns: columns ¾ tile width apart, odd columns shifted half a tile down.
HexByColumn,
/// Hexagonal tiles in rows: rows ¾ tile height apart, odd rows shifted half a tile right.
HexByRow,
}
impl TileType {
pub fn id(&self) -> &'static str {
match self {
TileType::Grid => "grid",
TileType::BrickByRow { .. } => "brickByRow",
TileType::BrickByColumn { .. } => "brickByColumn",
TileType::HexByColumn => "hexByColumn",
TileType::HexByRow => "hexByRow",
}
}
pub fn label(&self) -> &'static str {
match self {
TileType::Grid => "Grid",
TileType::BrickByRow { .. } => "Brick by Row",
TileType::BrickByColumn { .. } => "Brick by Column",
TileType::HexByColumn => "Hex by Column",
TileType::HexByRow => "Hex by Row",
}
}
/// Parse an id (`grid`, `brickByRow`, `brick-row`, …) with an optional brick offset.
pub fn parse(s: &str, offset: Option<f64>) -> Option<Self> {
let o = offset.unwrap_or(0.5);
Some(match s.to_ascii_lowercase().replace(['-', '_', ' '], "").as_str() {
"grid" => TileType::Grid,
"brickbyrow" | "brickrow" | "brick" => TileType::BrickByRow { offset: o },
"brickbycolumn" | "brickcolumn" | "brickcol" => TileType::BrickByColumn { offset: o },
"hexbycolumn" | "hexcolumn" | "hexcol" | "hex" => TileType::HexByColumn,
"hexbyrow" | "hexrow" => TileType::HexByRow,
_ => return None,
})
}
pub const IDS: [&'static str; 5] = ["grid", "brickByRow", "brickByColumn", "hexByColumn", "hexByRow"];
/// Column / row steps for a `w`×`h` tile (the hex types pack columns/rows at ¾).
pub fn steps(&self, w: f64, h: f64) -> (f64, f64) {
match self {
TileType::HexByColumn => (w * 0.75, h),
TileType::HexByRow => (w, h * 0.75),
_ => (w, h),
}
}
}
/// Position of instance (column `i`, row `j`) for a `w`×`h` tile. `(0, 0)` is at the origin.
pub fn tile_offset(tt: TileType, w: f64, h: f64, i: i64, j: i64) -> Vec2 {
let (i_f, j_f) = (i as f64, j as f64);
let frac = |v: f64| v - v.floor();
match tt {
TileType::Grid => Vec2::new(i_f * w, j_f * h),
TileType::BrickByRow { offset } => Vec2::new(i_f * w + frac(j_f * offset) * w, j_f * h),
TileType::BrickByColumn { offset } => Vec2::new(i_f * w, j_f * h + frac(i_f * offset) * h),
TileType::HexByColumn => Vec2::new(i_f * w * 0.75, j_f * h + if i.rem_euclid(2) == 1 { h / 2.0 } else { 0.0 }),
TileType::HexByRow => Vec2::new(i_f * w + if j.rem_euclid(2) == 1 { w / 2.0 } else { 0.0 }, j_f * h * 0.75),
}
}
/// Smallest `q` in 1..=24 with `q·offset` (almost) an integer (24 when none is).
fn brick_repeat(offset: f64) -> f64 {
for q in 1..=24 {
let v = q as f64 * offset;
if (v - v.round()).abs() < 1e-6 {
return q as f64;
}
}
24.0
}
/// Size of the rectangular super-tile a tiling repeats on.
pub fn tile_period(tt: TileType, w: f64, h: f64) -> (f64, f64) {
match tt {
TileType::Grid => (w, h),
TileType::BrickByRow { offset } => (w, h * brick_repeat(offset)),
TileType::BrickByColumn { offset } => (w * brick_repeat(offset), h),
TileType::HexByColumn => (w * 1.5, h),
TileType::HexByRow => (w, h * 1.5),
}
}
/// Pattern Options → Overlap.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Overlap {
/// Right in front (default: left in front).
#[serde(default)]
pub right_in_front: bool,
/// Bottom in front (default: top in front).
#[serde(default)]
pub bottom_in_front: bool,
}
/// A pattern swatch definition.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PatternDef {
pub name: String,
/// Tile rectangle in the art's coordinates.
pub tile: Rect,
#[serde(default)]
pub tile_type: TileType,
pub art: Vec<Arc<Node>>,
#[serde(default)]
pub overlap: Overlap,
/// Size Tile to Art: the tile is the art bounds grown by the spacing.
#[serde(default)]
pub size_tile_to_art: bool,
#[serde(default)]
pub h_spacing: f64,
#[serde(default)]
pub v_spacing: f64,
/// Pattern editing preview: copies per side (3, 5, 7, 9).
#[serde(default = "five")]
pub copies: u32,
/// Dim Copies to (percent opacity of the preview copies).
#[serde(default = "seventy")]
pub dim_copies: f32,
#[serde(default = "yes")]
pub show_tile_edge: bool,
}
impl PatternDef {
/// A pattern of `art` whose tile is the art's bounds.
pub fn new(name: &str, art: Vec<Arc<Node>>) -> Self {
let tile = crate::live::nodes_bounds(&art).unwrap_or(Rect::new(0.0, 0.0, 10.0, 10.0));
Self {
name: name.to_string(),
tile,
tile_type: TileType::Grid,
art,
overlap: Overlap::default(),
size_tile_to_art: false,
h_spacing: 0.0,
v_spacing: 0.0,
copies: 5,
dim_copies: 70.0,
show_tile_edge: true,
}
}
pub fn width(&self) -> f64 {
self.tile.width().max(1e-3)
}
pub fn height(&self) -> f64 {
self.tile.height().max(1e-3)
}
/// Visual bounds of the art (stroke included).
pub fn art_bounds(&self) -> Option<Rect> {
self.art.iter().fold(None, |acc, n| drawcraft_geom::union_opt(acc, n.visual_bounds()))
}
/// Re-derive the tile from the art (Size Tile to Art).
pub fn fit_tile_to_art(&mut self) {
if let Some(b) = crate::live::nodes_bounds(&self.art) {
self.tile = Rect::new(b.x0 - self.h_spacing / 2.0, b.y0 - self.v_spacing / 2.0, b.x1 + self.h_spacing / 2.0, b.y1 + self.v_spacing / 2.0);
}
}
/// Offset of instance (column `i`, row `j`) in pattern space.
pub fn offset(&self, i: i64, j: i64) -> Vec2 {
tile_offset(self.tile_type, self.width(), self.height(), i, j)
}
/// The rectangular period of the tiling (the super-tile).
pub fn period(&self) -> (f64, f64) {
tile_period(self.tile_type, self.width(), self.height())
}
/// Maps the art's coordinates into pattern space for instance offset `o`.
pub fn instance_xf(&self, o: Vec2) -> Affine {
Affine::translate(o - self.tile.origin().to_vec2())
}
/// Offsets (pattern space) of every instance whose art touches `region` (pattern space), in
/// paint order: the instances "in front" per [`Overlap`] come last.
pub fn offsets_covering(&self, region: Rect) -> Vec<Vec2> {
let (w, h) = (self.width(), self.height());
// Art extent relative to the tile's origin (at least the tile itself).
let ab = self.art_bounds().unwrap_or(self.tile).union(self.tile) - self.tile.origin().to_vec2();
let (sx, sy) = self.tile_type.steps(w, h);
// Shifts of brick/hex rows or columns are < one tile.
let (i0, i1) = (((region.x0 - ab.x1 - w) / sx).floor() as i64 - 1, ((region.x1 - ab.x0 + w) / sx).ceil() as i64 + 1);
let (j0, j1) = (((region.y0 - ab.y1 - h) / sy).floor() as i64 - 1, ((region.y1 - ab.y0 + h) / sy).ceil() as i64 + 1);
if (i1 - i0).saturating_mul(j1 - j0) > 250_000 {
return vec![];
}
let mut out = vec![];
let rows: Vec<i64> = if self.overlap.bottom_in_front { (j0..=j1).collect() } else { (j0..=j1).rev().collect() };
for &j in &rows {
let cols: Vec<i64> = if self.overlap.right_in_front { (i0..=i1).collect() } else { (i0..=i1).rev().collect() };
for i in cols {
let o = self.offset(i, j);
let r = ab + o;
if r.x0 < region.x1 && r.x1 > region.x0 && r.y0 < region.y1 && r.y1 > region.y0 {
out.push(o);
}
}
}
out
}
/// Offsets of the pattern-editing preview copies (`copies`×`copies` around the tile, the
/// centre `(0, 0)` excluded).
pub fn preview_offsets(&self) -> Vec<Vec2> {
let k = (self.copies.clamp(1, 15) / 2) as i64;
let mut out = vec![];
for j in -k..=k {
for i in -k..=k {
if (i, j) != (0, 0) {
out.push(self.offset(i, j));
}
}
}
out
}
/// Instances covering `region` (document space) as plain nodes for a pattern painted with
/// `paint_xf` (pattern space → document), fresh-id-less (id 0). Used by exporters and Expand.
pub fn instances_in(&self, paint_xf: Affine, region: Rect) -> Vec<Node> {
let inv = paint_xf.inverse();
let pr = inv.transform_rect_bbox(region);
let mut out = vec![];
for o in self.offsets_covering(pr) {
let m = paint_xf * self.instance_xf(o);
for a in &self.art {
let mut n = (**a).clone();
n.transform(m, false);
zero_ids(&mut n);
out.push(n);
}
}
out
}
}
fn zero_ids(n: &mut Node) {
n.id = NodeId(0);
if let Some(ch) = n.children_mut() {
for c in ch.iter_mut() {
zero_ids(Arc::make_mut(c));
}
}
}
/// Pattern editing mode state (lives in the document so undo and the renderer see it).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PatternEdit {
/// Pattern being edited.
pub pattern: String,
/// The temporary layer holding the tile art while editing.
pub layer: NodeId,
/// The definition before editing (restored by Cancel); `None` when Make created it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub original: Option<PatternDef>,
}
/// Name of the temporary pattern-editing layer.
pub const PATTERN_EDIT_LAYER: &str = "Pattern Editing Mode";
/// A pattern paint (identity placement).
pub fn pattern_paint(name: &str) -> Paint {
Paint::Pattern { pattern: name.to_string(), xf: Affine::IDENTITY }
}
/// Does `n` (not its children) paint any fill/stroke with pattern `name` (any pattern for `None`)?
pub fn uses_pattern(n: &Node, name: Option<&str>) -> bool {
n.appearance.items.iter().any(|it| {
let p = match it {
AppearanceItem::Fill(f) => &f.paint,
AppearanceItem::Stroke(s) => &s.paint,
};
matches!(p, Paint::Pattern { pattern, .. } if name.is_none_or(|nm| nm == pattern))
})
}
/// Apply `m` to the pattern placement of every pattern paint of `n` (Transform Patterns).
pub fn transform_pattern_paints(n: &mut Node, m: Affine) {
for it in n.appearance.items.iter_mut() {
let p = match it {
AppearanceItem::Fill(f) => &mut f.paint,
AppearanceItem::Stroke(s) => &mut s.paint,
};
if let Paint::Pattern { xf, .. } = p {
*xf = m * *xf;
}
}
}
// =====================================================================================
// Repeat
// =====================================================================================
/// The arrangement of a Repeat object.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum RepeatKind {
/// Copies rotated around `center`; the source's centre is kept `radius` from it.
#[serde(rename_all = "camelCase")]
Radial {
instances: u32,
radius: f64,
center: Point,
#[serde(default)]
reverse_overlap: bool,
#[serde(default)]
start_angle: f64,
#[serde(default = "full_turn")]
end_angle: f64,
},
/// Copies on a grid (tile = source bounds + spacing), optionally brick/hex offset and with
/// alternate rows/columns flipped.
#[serde(rename_all = "camelCase")]
Grid {
h_spacing: f64,
v_spacing: f64,
#[serde(default = "three")]
rows: u32,
#[serde(default = "three")]
cols: u32,
#[serde(default)]
grid_type: TileType,
/// Flip alternate rows vertically.
#[serde(default)]
flip_rows: bool,
/// Flip alternate columns horizontally.
#[serde(default)]
flip_cols: bool,
},
/// The source plus its reflection across the axis through `center` at `angle` degrees
/// (90 = vertical axis).
Mirror { angle: f64, center: Point },
}
impl RepeatKind {
pub fn label(&self) -> &'static str {
match self {
RepeatKind::Radial { .. } => "Radial Repeat",
RepeatKind::Grid { .. } => "Grid Repeat",
RepeatKind::Mirror { .. } => "Mirror Repeat",
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RepeatSpec {
pub kind: RepeatKind,
pub source: Vec<Arc<Node>>,
}
/// Reflection across the line through `c` with direction angle `deg`.
pub fn reflect_about(c: Point, deg: f64) -> Affine {
let t = (2.0 * deg).to_radians();
let (s2, c2) = t.sin_cos();
Affine::translate(c.to_vec2()) * Affine::new([c2, s2, s2, -c2, 0.0, 0.0]) * Affine::translate(-c.to_vec2())
}
/// Instance transforms of a repeat (source space → document), in paint order.
pub fn repeat_transforms(kind: &RepeatKind, source_bounds: Option<Rect>) -> Vec<Affine> {
let b = source_bounds.unwrap_or(Rect::new(0.0, 0.0, 1.0, 1.0));
match kind {
RepeatKind::Radial { instances, radius, center, reverse_overlap, start_angle, end_angle } => {
let n = (*instances).clamp(1, 1000);
let c0 = b.center();
let mut d = c0 - *center;
if d.hypot() < 1e-9 {
d = Vec2::new(0.0, -1.0);
}
let pre = Affine::translate(*center + d.normalize() * radius.max(0.0) - c0);
let sweep = end_angle - start_angle;
let full = (sweep.abs() - 360.0).abs() < 1e-9;
let step = if full || n == 1 { sweep / n as f64 } else { sweep / (n - 1) as f64 };
let mut v: Vec<Affine> = (0..n)
.map(|k| {
let a = (start_angle + step * k as f64).to_radians();
Affine::translate(center.to_vec2()) * Affine::rotate(a) * Affine::translate(-center.to_vec2()) * pre
})
.collect();
if *reverse_overlap {
v.reverse();
}
v
}
RepeatKind::Grid { h_spacing, v_spacing, rows, cols, grid_type, flip_rows, flip_cols } => {
let (w, h) = ((b.width() + h_spacing).max(1e-3), (b.height() + v_spacing).max(1e-3));
let mut v = vec![];
for j in 0..(*rows).clamp(1, 500) as i64 {
for i in 0..(*cols).clamp(1, 500) as i64 {
let o = tile_offset(*grid_type, w, h, i, j);
let c = b.center();
let mut flip = Affine::IDENTITY;
if *flip_cols && i % 2 == 1 {
flip = reflect_about(c, 90.0) * flip;
}
if *flip_rows && j % 2 == 1 {
flip = reflect_about(c, 0.0) * flip;
}
v.push(Affine::translate(o) * flip);
}
}
v
}
RepeatKind::Mirror { angle, center } => vec![Affine::IDENTITY, reflect_about(*center, *angle)],
}
}
impl RepeatSpec {
pub fn source_bounds(&self) -> Option<Rect> {
crate::live::nodes_bounds(&self.source)
}
pub fn transforms(&self) -> Vec<Affine> {
repeat_transforms(&self.kind, self.source_bounds())
}
/// Evaluated instances: one group of the (transformed) source per instance (ids 0).
pub fn expand(&self) -> Vec<Node> {
self.transforms()
.into_iter()
.map(|m| {
let children = self
.source
.iter()
.map(|s| {
let mut n = (**s).clone();
n.transform(m, false);
zero_ids(&mut n);
Arc::new(n)
})
.collect();
Node::group(NodeId(0), children)
})
.collect()
}
/// Geometric bounds of all instances.
pub fn bounds(&self) -> Option<Rect> {
let b = self.source_bounds()?;
self.transforms().into_iter().fold(None, |acc, m| drawcraft_geom::union_opt(acc, Some(m.transform_rect_bbox(b))))
}
/// Apply a document transform (source + arrangement parameters).
pub fn transform(&mut self, a: Affine, scale_strokes: bool) {
for c in self.source.iter_mut() {
Arc::make_mut(c).transform(a, scale_strokes);
}
let s = a.determinant().abs().sqrt();
match &mut self.kind {
RepeatKind::Radial { radius, center, .. } => {
*center = a * *center;
*radius *= s;
}
RepeatKind::Grid { h_spacing, v_spacing, .. } => {
*h_spacing *= s;
*v_spacing *= s;
}
RepeatKind::Mirror { angle, center } => {
*center = a * *center;
let d = a * Point::new(angle.to_radians().cos(), angle.to_radians().sin()) - a * Point::ZERO;
*angle = d.y.atan2(d.x).to_degrees();
}
}
}
/// Default radial repeat around the source (centre below it, radius = its height × 1.2).
pub fn radial(source: Vec<Arc<Node>>, instances: u32, radius: Option<f64>) -> Self {
let b = crate::live::nodes_bounds(&source).unwrap_or(Rect::new(0.0, 0.0, 10.0, 10.0));
let r = radius.unwrap_or((b.height().max(b.width()) * 1.2).max(10.0));
let center = b.center() + Vec2::new(0.0, r);
Self { kind: RepeatKind::Radial { instances, radius: r, center, reverse_overlap: false, start_angle: 0.0, end_angle: 360.0 }, source }
}
pub fn grid(source: Vec<Arc<Node>>, h_spacing: f64, v_spacing: f64) -> Self {
Self {
kind: RepeatKind::Grid { h_spacing, v_spacing, rows: 3, cols: 3, grid_type: TileType::Grid, flip_rows: false, flip_cols: false },
source,
}
}
/// Mirror across an axis at `angle` through a point `offset` beyond the source's right edge
/// (vertical axis) or below it (other angles pass through the right-edge midpoint).
pub fn mirror(source: Vec<Arc<Node>>, angle: f64, offset: f64) -> Self {
let b = crate::live::nodes_bounds(&source).unwrap_or(Rect::new(0.0, 0.0, 10.0, 10.0));
let center = Point::new(b.x1 + offset, b.center().y);
Self { kind: RepeatKind::Mirror { angle, center }, source }
}
}
/// Is this a Repeat object?
pub fn is_repeat(n: &Node) -> bool {
matches!(n.kind, NodeKind::Repeat(_))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Appearance;
use drawcraft_geom::shapes;
fn sq(x: f64, y: f64, s: f64) -> Arc<Node> {
Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(x, y, x + s, y + s)), Appearance::default()))
}
fn close(a: Vec2, b: Vec2) -> bool {
(a - b).hypot() < 1e-9
}
#[test]
fn grid_offsets() {
assert!(close(tile_offset(TileType::Grid, 10.0, 20.0, 2, 3), Vec2::new(20.0, 60.0)));
assert!(close(tile_offset(TileType::Grid, 10.0, 20.0, -1, -1), Vec2::new(-10.0, -20.0)));
assert_eq!(tile_period(TileType::Grid, 10.0, 20.0), (10.0, 20.0));
}
#[test]
fn brick_by_row_offsets() {
let t = TileType::BrickByRow { offset: 0.5 };
assert!(close(tile_offset(t, 10.0, 20.0, 0, 1), Vec2::new(5.0, 20.0)));
assert!(close(tile_offset(t, 10.0, 20.0, 0, 2), Vec2::new(0.0, 40.0)));
assert!(close(tile_offset(t, 10.0, 20.0, 1, -1), Vec2::new(15.0, -20.0)));
assert_eq!(tile_period(t, 10.0, 20.0), (10.0, 40.0));
let t3 = TileType::BrickByRow { offset: 1.0 / 3.0 };
assert_eq!(tile_period(t3, 10.0, 20.0), (10.0, 60.0));
}
#[test]
fn brick_by_column_offsets() {
let t = TileType::BrickByColumn { offset: 0.25 };
assert!(close(tile_offset(t, 10.0, 20.0, 1, 0), Vec2::new(10.0, 5.0)));
assert!(close(tile_offset(t, 10.0, 20.0, 3, 1), Vec2::new(30.0, 35.0)));
assert_eq!(tile_period(t, 10.0, 20.0), (40.0, 20.0));
}
#[test]
fn hex_offsets() {
let c = TileType::HexByColumn;
assert!(close(tile_offset(c, 10.0, 20.0, 1, 0), Vec2::new(7.5, 10.0)));
assert!(close(tile_offset(c, 10.0, 20.0, 2, 1), Vec2::new(15.0, 20.0)));
assert!(close(tile_offset(c, 10.0, 20.0, -1, 0), Vec2::new(-7.5, 10.0)));
assert_eq!(tile_period(c, 10.0, 20.0), (15.0, 20.0));
let r = TileType::HexByRow;
assert!(close(tile_offset(r, 10.0, 20.0, 0, 1), Vec2::new(5.0, 15.0)));
assert!(close(tile_offset(r, 10.0, 20.0, 1, 2), Vec2::new(10.0, 30.0)));
assert_eq!(tile_period(r, 10.0, 20.0), (10.0, 30.0));
}
#[test]
fn tilings_are_periodic_on_their_period() {
// Every instance shifted by the period is again an instance.
for tt in [
TileType::Grid,
TileType::BrickByRow { offset: 0.5 },
TileType::BrickByColumn { offset: 0.25 },
TileType::HexByColumn,
TileType::HexByRow,
] {
let (w, h) = (10.0, 20.0);
let (pw, ph) = tile_period(tt, w, h);
let all: Vec<Vec2> = (-8..8).flat_map(|j| (-8..8).map(move |i| tile_offset(tt, w, h, i, j))).collect();
for o in all.iter().filter(|o| o.x.abs() < 30.0 && o.y.abs() < 30.0) {
for d in [Vec2::new(pw, 0.0), Vec2::new(0.0, ph)] {
assert!(all.iter().any(|p| close(*p, *o + d)), "{tt:?} {o:?}+{d:?}");
}
}
}
}
#[test]
fn tile_type_parse() {
assert_eq!(TileType::parse("brickByRow", Some(0.25)), Some(TileType::BrickByRow { offset: 0.25 }));
assert_eq!(TileType::parse("hex-by-row", None), Some(TileType::HexByRow));
assert_eq!(TileType::parse("nope", None), None);
for id in TileType::IDS {
assert_eq!(TileType::parse(id, None).unwrap().id(), id);
}
}
#[test]
fn covering_offsets_and_overlap_order() {
let def = PatternDef::new("p", vec![sq(0.0, 0.0, 10.0)]);
let offs = def.offsets_covering(Rect::new(0.0, 0.0, 30.0, 20.0));
// 3×2 tiles fully inside, plus neighbours touching the edges are excluded (strict overlap).
assert_eq!(offs.len(), 6, "{offs:?}");
// Left/top in front: the top-left tile is drawn last.
assert!(close(*offs.last().unwrap(), Vec2::ZERO));
let mut d2 = def.clone();
d2.overlap = Overlap { right_in_front: true, bottom_in_front: true };
assert!(close(*d2.offsets_covering(Rect::new(0.0, 0.0, 30.0, 20.0)).last().unwrap(), Vec2::new(20.0, 10.0)));
}
#[test]
fn instances_follow_paint_xf() {
let def = PatternDef::new("p", vec![sq(100.0, 100.0, 10.0)]);
let n = def.instances_in(Affine::translate((3.0, 0.0)), Rect::new(0.0, 0.0, 20.0, 10.0));
let xs: Vec<f64> = n.iter().filter_map(|n| n.geometric_bounds()).map(|b| b.x0).collect();
assert!(xs.contains(&3.0) && xs.contains(&-7.0) && xs.contains(&13.0), "{xs:?}");
assert!(n.iter().all(|n| n.id == NodeId(0)));
}
#[test]
fn preview_offsets_count() {
let mut def = PatternDef::new("p", vec![sq(0.0, 0.0, 10.0)]);
assert_eq!(def.preview_offsets().len(), 24);
def.copies = 3;
assert_eq!(def.preview_offsets().len(), 8);
}
#[test]
fn radial_transforms() {
let r = RepeatSpec::radial(vec![sq(-5.0, -105.0, 10.0)], 4, Some(100.0));
let RepeatKind::Radial { center, .. } = r.kind else { panic!() };
assert!((center - Point::new(0.0, -0.0)).hypot() < 1e-9, "{center:?}");
let cs: Vec<Point> = r.transforms().iter().map(|m| *m * Point::new(0.0, -100.0)).collect();
assert!((cs[0] - Point::new(0.0, -100.0)).hypot() < 1e-9);
assert!((cs[1] - Point::new(100.0, 0.0)).hypot() < 1e-9, "{cs:?}");
assert!((cs[2] - Point::new(0.0, 100.0)).hypot() < 1e-9);
assert_eq!(r.expand().len(), 4);
}
#[test]
fn radial_radius_is_authoritative() {
let mut r = RepeatSpec::radial(vec![sq(-5.0, -105.0, 10.0)], 6, Some(100.0));
if let RepeatKind::Radial { radius, .. } = &mut r.kind {
*radius = 50.0;
}
let m = r.transforms()[0];
assert!((m * Point::new(0.0, -100.0) - Point::new(0.0, -50.0)).hypot() < 1e-9);
}
#[test]
fn grid_repeat_transforms() {
let g = RepeatSpec::grid(vec![sq(0.0, 0.0, 10.0)], 5.0, 2.0);
let t = g.transforms();
assert_eq!(t.len(), 9);
assert!((t[1] * Point::ZERO - Point::new(15.0, 0.0)).hypot() < 1e-9);
assert!((t[3] * Point::ZERO - Point::new(0.0, 12.0)).hypot() < 1e-9);
assert_eq!(g.bounds(), Some(Rect::new(0.0, 0.0, 40.0, 34.0)));
}
#[test]
fn grid_repeat_flip() {
let mut g = RepeatSpec::grid(vec![sq(0.0, 0.0, 10.0)], 0.0, 0.0);
if let RepeatKind::Grid { flip_cols, .. } = &mut g.kind {
*flip_cols = true;
}
let t = g.transforms();
// Column 1 is mirrored about the source centre, then moved one tile right.
assert!((t[1] * Point::new(0.0, 0.0) - Point::new(20.0, 0.0)).hypot() < 1e-9);
}
#[test]
fn mirror_transforms() {
let m = RepeatSpec::mirror(vec![sq(0.0, 0.0, 10.0)], 90.0, 5.0);
let t = m.transforms();
assert_eq!(t.len(), 2);
assert!((t[1] * Point::new(0.0, 3.0) - Point::new(30.0, 3.0)).hypot() < 1e-9);
let b = m.bounds().unwrap();
assert!((b.x1 - 30.0).abs() < 1e-9 && b.x0.abs() < 1e-9 && (b.y1 - 10.0).abs() < 1e-9, "{b:?}");
// Horizontal axis.
let h = reflect_about(Point::new(0.0, 10.0), 0.0);
assert!((h * Point::new(4.0, 0.0) - Point::new(4.0, 20.0)).hypot() < 1e-9);
}
#[test]
fn repeat_transform_moves_arrangement() {
let mut r = RepeatSpec::radial(vec![sq(-5.0, -105.0, 10.0)], 4, Some(100.0));
let before = r.bounds().unwrap();
r.transform(Affine::translate((50.0, 0.0)), false);
let after = r.bounds().unwrap();
assert!((after.x0 - before.x0 - 50.0).abs() < 1e-9);
let mut m = RepeatSpec::mirror(vec![sq(0.0, 0.0, 10.0)], 90.0, 5.0);
m.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), false);
let RepeatKind::Mirror { angle, .. } = m.kind else { panic!() };
assert!((angle.rem_euclid(180.0) - 0.0).abs() < 1e-9 || (angle.rem_euclid(180.0) - 180.0).abs() < 1e-9, "{angle}");
}
#[test]
fn serde_roundtrip() {
let mut def = PatternDef::new("Dots", vec![sq(0.0, 0.0, 10.0)]);
def.tile_type = TileType::HexByRow;
let s = serde_json::to_string(&def).unwrap();
assert_eq!(serde_json::from_str::<PatternDef>(&s).unwrap(), def);
let n = Node::new(NodeId(5), NodeKind::Repeat(RepeatSpec::grid(vec![sq(0.0, 0.0, 10.0)], 1.0, 2.0)));
let s = serde_json::to_string(&n).unwrap();
assert_eq!(serde_json::from_str::<Node>(&s).unwrap(), n);
// Old pattern paints (no xf) still load.
let p: Paint = serde_json::from_str(r#"{"type":"pattern","pattern":"Dots"}"#).unwrap();
assert_eq!(p, pattern_paint("Dots"));
}
}

View File

@ -17,6 +17,7 @@ mod paint;
mod panelcmds;
mod path;
mod pathops;
pub mod prefscmds;
mod recolor;
mod select;
mod textedit;
@ -143,6 +144,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(panelcmds::specs());
v.extend(buildcmds::specs());
v.extend(brushsym::specs());
v.extend(prefscmds::specs());
v
})
}

View File

@ -0,0 +1,449 @@
//! Preferences (Edit → Preferences): `prefs.get`, `prefs.set`, `prefs.reset`, `prefs.list`.
//!
//! [`PREF_SPECS`] is the single source of truth for every preference: its category, section,
//! label and value kind. The Preferences dialog renders from it and `prefs.set` validates with it.
use serde_json::{Map, Value, json};
use super::*;
use crate::Prefs;
/// What kind of value a preference holds.
#[derive(Clone, Copy, Debug)]
pub enum PrefKind {
Bool,
/// A number in `min..=max` shown with `unit` ("pt", "px", "°", "%", "min", "").
Num { min: f64, max: f64, unit: &'static str },
/// A whole number in `min..=max`.
Int { min: i64, max: i64 },
/// One of (value, label).
Choice(&'static [(&'static str, &'static str)]),
/// `#rrggbb`.
Color,
Text,
}
#[derive(Clone, Copy, Debug)]
pub struct PrefSpec {
pub key: &'static str,
pub category: &'static str,
/// Group heading inside the category ("" = none).
pub section: &'static str,
pub label: &'static str,
pub kind: PrefKind,
}
/// Categories in Illustrator's order (Plug-ins & Scratch Disks is "Performance & Storage" here).
pub const PREF_CATEGORIES: &[&str] = &[
"General",
"Selection & Anchor Display",
"Type",
"Units",
"Guides & Grid",
"Smart Guides",
"Slices",
"Hyphenation",
"Performance & Storage",
"User Interface",
"Performance",
"File Handling",
"Clipboard Handling",
"Appearance of Black",
"Devices",
];
pub const UNITS: &[(&str, &str)] = &[
("points", "Points"),
("picas", "Picas"),
("inches", "Inches"),
("millimeters", "Millimeters"),
("centimeters", "Centimeters"),
("pixels", "Pixels"),
];
const LINE_STYLE: &[(&str, &str)] = &[("lines", "Lines"), ("dots", "Dots")];
const BLACK: &[(&str, &str)] = &[("accurate", "Display All Blacks Accurately"), ("rich", "Display All Blacks as Rich Black")];
const BLACK_OUT: &[(&str, &str)] = &[("accurate", "Output All Blacks Accurately"), ("rich", "Output All Blacks as Rich Black")];
macro_rules! p {
($key:literal, $cat:literal, $sec:literal, $label:literal, bool) => {
PrefSpec { key: $key, category: $cat, section: $sec, label: $label, kind: PrefKind::Bool }
};
($key:literal, $cat:literal, $sec:literal, $label:literal, num($min:expr, $max:expr, $unit:literal)) => {
PrefSpec { key: $key, category: $cat, section: $sec, label: $label, kind: PrefKind::Num { min: $min, max: $max, unit: $unit } }
};
($key:literal, $cat:literal, $sec:literal, $label:literal, int($min:expr, $max:expr)) => {
PrefSpec { key: $key, category: $cat, section: $sec, label: $label, kind: PrefKind::Int { min: $min, max: $max } }
};
($key:literal, $cat:literal, $sec:literal, $label:literal, choice($c:expr)) => {
PrefSpec { key: $key, category: $cat, section: $sec, label: $label, kind: PrefKind::Choice($c) }
};
($key:literal, $cat:literal, $sec:literal, $label:literal, color) => {
PrefSpec { key: $key, category: $cat, section: $sec, label: $label, kind: PrefKind::Color }
};
($key:literal, $cat:literal, $sec:literal, $label:literal, text) => {
PrefSpec { key: $key, category: $cat, section: $sec, label: $label, kind: PrefKind::Text }
};
}
pub const PREF_SPECS: &[PrefSpec] = &[
// General
p!("keyboardIncrement", "General", "", "Keyboard Increment", num(0.001, 1296.0, "pt")),
p!("constrainAngle", "General", "", "Constrain Angle", num(-360.0, 360.0, "°")),
p!("cornerRadius", "General", "", "Corner Radius", num(0.0, 1296.0, "pt")),
p!("pasteOffset", "General", "", "Paste Offset", num(0.0, 1296.0, "pt")),
p!("disableAutoAddDelete", "General", "Options", "Disable Auto Add/Delete", bool),
p!("usePreciseCursors", "General", "Options", "Use Precise Cursors", bool),
p!("showToolTips", "General", "Options", "Show Tool Tips", bool),
p!("antiAliasedArtwork", "General", "Options", "Anti-aliased Artwork", bool),
p!("selectSameTintPercent", "General", "Options", "Select Same Tint %", bool),
p!("showHomeScreen", "General", "Options", "Show The Home Screen When No Documents Are Open", bool),
p!("usePreviewBounds", "General", "Options", "Use Preview Bounds", bool),
p!("displayPrintSize", "General", "Options", "Display Print Size at 100% Zoom", bool),
p!("doubleClickToIsolate", "General", "Options", "Double Click To Isolate", bool),
p!("transformPatternTiles", "General", "Options", "Transform Pattern Tiles", bool),
p!("scaleCorners", "General", "Options", "Scale Corners", bool),
p!("scaleStrokes", "General", "Options", "Scale Strokes & Effects", bool),
p!("zoomWithMouseWheel", "General", "Options", "Zoom with Mouse Wheel", bool),
// Selection & Anchor Display
p!("selectionTolerance", "Selection & Anchor Display", "Selection", "Tolerance", num(1.0, 8.0, "px")),
p!("objectSelectionByPathOnly", "Selection & Anchor Display", "Selection", "Object Selection by Path Only", bool),
p!("snapToPointTolerance", "Selection & Anchor Display", "Selection", "Snap to Point", num(1.0, 8.0, "px")),
p!("ctrlClickSelectsBehind", "Selection & Anchor Display", "Selection", "Command Click to Select Objects Behind", bool),
p!("zoomToSelection", "Selection & Anchor Display", "Selection", "Zoom to Selection", bool),
p!("moveLockedWithArtboard", "Selection & Anchor Display", "Selection", "Move Locked and Hidden Artwork with Artboard", bool),
p!("anchorSize", "Selection & Anchor Display", "Anchor Points, Handle, and Bounding Box Display", "Size", int(1, 7)),
p!(
"handleStyle",
"Selection & Anchor Display",
"Anchor Points, Handle, and Bounding Box Display",
"Handles",
choice(&[("solid", "Solid"), ("hollow", "Hollow"), ("large", "Large")])
),
p!("highlightAnchorsOnHover", "Selection & Anchor Display", "Anchor Points, Handle, and Bounding Box Display", "Highlight anchors on mouse over", bool),
p!(
"showHandlesMultipleAnchors",
"Selection & Anchor Display",
"Anchor Points, Handle, and Bounding Box Display",
"Show handles when multiple anchors are selected",
bool
),
p!("hideCornerWidgetAbove", "Selection & Anchor Display", "Anchor Points, Handle, and Bounding Box Display", "Hide Corner Widget for angles greater than", num(0.0, 180.0, "°")),
p!("penRubberBand", "Selection & Anchor Display", "Enable Rubber Band for", "Pen Tool", bool),
p!("curvatureRubberBand", "Selection & Anchor Display", "Enable Rubber Band for", "Curvature Tool", bool),
// Type
p!("typeSizeIncrement", "Type", "", "Size/Leading", num(0.001, 1296.0, "pt")),
p!("trackingIncrement", "Type", "", "Tracking", num(1.0, 1000.0, "/1000 em")),
p!("baselineShiftIncrement", "Type", "", "Baseline Shift", num(0.001, 1296.0, "pt")),
p!("showEastAsianOptions", "Type", "Language Options", "Show East Asian Options", bool),
p!("showIndicOptions", "Type", "Language Options", "Show Indic Options", bool),
p!("typeSelectionByPathOnly", "Type", "Options", "Type Object Selection by Path Only", bool),
p!("fontNamesInEnglish", "Type", "Options", "Show Font Names in English", bool),
p!("autoSizeAreaType", "Type", "Options", "Auto Size New Area Type", bool),
p!("fontPreview", "Type", "Options", "Enable in-menu font previews", bool),
p!("fontPreviewSize", "Type", "Options", "Font Preview Size", choice(&[("small", "Small"), ("medium", "Medium"), ("large", "Large")])),
p!("recentFontsCount", "Type", "Options", "Number of Recent Fonts", int(1, 15)),
p!("missingGlyphProtection", "Type", "Options", "Enable Missing Glyph Protection", bool),
p!("highlightAlternateGlyphs", "Type", "Options", "Highlight Alternate Glyphs", bool),
p!("placeholderText", "Type", "Options", "Fill New Type Objects With Placeholder Text", bool),
// Units
p!("unitsGeneral", "Units", "", "General", choice(UNITS)),
p!("unitsStroke", "Units", "", "Stroke", choice(UNITS)),
p!("unitsType", "Units", "", "Type", choice(UNITS)),
p!("unitsAsianType", "Units", "", "East Asian Type", choice(UNITS)),
p!("numbersWithoutUnitsArePoints", "Units", "", "Numbers Without Units Are Points", bool),
p!("identifyObjectsBy", "Units", "", "Identify Objects By", choice(&[("objectName", "Object Name"), ("xmlId", "XML ID")])),
// Guides & Grid
p!("guideColor", "Guides & Grid", "Guides", "Color", color),
p!("guideStyle", "Guides & Grid", "Guides", "Style", choice(LINE_STYLE)),
p!("gridColor", "Guides & Grid", "Grid", "Color", color),
p!("gridStyle", "Guides & Grid", "Grid", "Style", choice(LINE_STYLE)),
p!("gridlineEvery", "Guides & Grid", "Grid", "Gridline every", num(1.0, 16383.0, "pt")),
p!("gridSubdivisions", "Guides & Grid", "Grid", "Subdivisions", int(1, 1000)),
p!("gridsInBack", "Guides & Grid", "Grid", "Grids In Back", bool),
p!("showPixelGrid", "Guides & Grid", "Grid", "Show Pixel Grid (Above 600% Zoom)", bool),
// Smart Guides
p!("smartGuideColor", "Smart Guides", "Display Options", "Color", color),
p!("alignmentGuides", "Smart Guides", "Display Options", "Alignment Guides", bool),
p!("objectHighlighting", "Smart Guides", "Display Options", "Object Highlighting", bool),
p!("transformToolsGuides", "Smart Guides", "Display Options", "Transform Tools", bool),
p!("constructionGuides", "Smart Guides", "Display Options", "Construction Guides", bool),
p!(
"constructionAngles",
"Smart Guides",
"Display Options",
"Angles",
choice(&[
("90° & 45° Angles", "90° & 45° Angles"),
("30° Angles", "30° Angles"),
("45° Angles", "45° Angles"),
("60° Angles", "60° Angles"),
("90° Angles", "90° Angles"),
("90° & 45° & 30° Angles", "90° & 45° & 30° Angles"),
])
),
p!("anchorPathLabels", "Smart Guides", "Display Options", "Anchor/Path Labels", bool),
p!("measurementLabels", "Smart Guides", "Display Options", "Measurement Labels", bool),
p!("spacingGuides", "Smart Guides", "Display Options", "Spacing Guides", bool),
p!("snappingTolerance", "Smart Guides", "", "Snapping Tolerance", num(0.0, 40.0, "pt")),
// Slices
p!("showSliceNumbers", "Slices", "", "Show Slice Numbers", bool),
p!("sliceLineColor", "Slices", "", "Line Color", color),
// Hyphenation
p!(
"hyphenationLanguage",
"Hyphenation",
"",
"Default Language",
choice(&[
("English: USA", "English: USA"),
("English: UK", "English: UK"),
("French", "French"),
("German", "German"),
("Spanish", "Spanish"),
("Italian", "Italian"),
("Dutch", "Dutch"),
("Portuguese", "Portuguese")
])
),
p!("hyphenationExceptions", "Hyphenation", "", "Exceptions (comma separated)", text),
// Performance & Storage
p!("pluginsFolder", "Performance & Storage", "Plug-ins", "Additional Plug-ins Folder", text),
p!("scratchPrimary", "Performance & Storage", "Scratch Disks", "Primary", text),
p!("scratchSecondary", "Performance & Storage", "Scratch Disks", "Secondary", text),
// User Interface
p!(
"uiBrightness",
"User Interface",
"",
"Brightness",
choice(&[("dark", "Dark"), ("mediumDark", "Medium Dark"), ("mediumLight", "Medium Light"), ("light", "Light")])
),
p!("canvasColor", "User Interface", "", "Canvas Color", choice(&[("matchUi", "Match User Interface Brightness"), ("white", "White")])),
p!("autoCollapseIconPanels", "User Interface", "", "Auto-Collapse Iconic Panels", bool),
p!("openDocumentsAsTabs", "User Interface", "", "Open Documents As Tabs", bool),
p!("largeTabs", "User Interface", "", "Large Tabs", bool),
p!("uiScaling", "User Interface", "UI Scaling", "Scale", num(0.75, 2.0, "×")),
p!("scaleCursorWithUi", "User Interface", "UI Scaling", "Scale Cursor Proportional to UI", bool),
// Performance
p!("gpuPerformance", "Performance", "GPU Performance", "GPU Performance", bool),
p!("animatedZoom", "Performance", "GPU Performance", "Animated Zoom", bool),
p!("historyStates", "Performance", "", "History States", int(5, 1000)),
p!("realTimeDrawing", "Performance", "", "Real-time Drawing and Editing", bool),
p!("renderThreads", "Performance", "", "Render Threads (-1 = Automatic)", int(-1, 64)),
// File Handling
p!("backgroundSave", "File Handling", "", "Enable Background Save", bool),
p!("backgroundExport", "File Handling", "", "Enable Background Export", bool),
p!("autosaveRecovery", "File Handling", "Data Recovery", "Automatically Save Recovery Data", bool),
p!("autosaveInterval", "File Handling", "Data Recovery", "Every (minutes)", int(1, 60)),
p!("recoveryFolder", "File Handling", "Data Recovery", "Folder", text),
p!("recoveryOffForComplex", "File Handling", "Data Recovery", "Turn off Data Recovery for complex documents", bool),
p!("recentFilesCount", "File Handling", "Files", "Number of Recent Files to Display", int(0, 30)),
p!("lowResProxyEps", "File Handling", "Files", "Use Low Resolution Proxy for Linked EPS", bool),
p!("antiAliasedBitmaps", "File Handling", "Files", "Display Bitmaps as Anti-aliased Images in Pixel Preview", bool),
p!(
"updateLinks",
"File Handling",
"Files",
"Update Links",
choice(&[("automatically", "Automatically"), ("manually", "Manually"), ("askWhenModified", "Ask When Modified")])
),
// Clipboard Handling
p!("copyAsSvg", "Clipboard Handling", "On Copy", "Include SVG Code", bool),
p!("copyAsPdf", "Clipboard Handling", "On Copy", "PDF", bool),
p!("copyAicb", "Clipboard Handling", "On Copy", "AICB (no transparency support)", bool),
p!("aicbMode", "Clipboard Handling", "On Copy", "AICB", choice(&[("preservePaths", "Preserve Paths"), ("preserveAppearance", "Preserve Appearance and Overprints")])),
p!("pasteTextFormatting", "Clipboard Handling", "On Paste", "When pasting text", choice(&[("keep", "Keep Formatting"), ("plain", "Keep Plain Text")])),
// Appearance of Black
p!("blackOnScreen", "Appearance of Black", "", "On Screen", choice(BLACK)),
p!("blackOutput", "Appearance of Black", "", "Printing / Exporting", choice(BLACK_OUT)),
// Devices
p!("touchWorkspace", "Devices", "", "Enable Touch Workspace", bool),
p!("touchGestures", "Devices", "", "Enable Touch Gestures", bool),
];
pub fn spec(key: &str) -> Option<&'static PrefSpec> {
PREF_SPECS.iter().find(|s| s.key == key)
}
/// Validate (and normalize) a value for `key`. Numbers may be given as strings; booleans as
/// "true"/"false"; choices by value or label (case-insensitive).
pub fn validate(key: &str, v: &Value) -> std::result::Result<Value, String> {
let sp = spec(key).ok_or_else(|| format!("unknown preference `{key}`"))?;
let num = || v.as_f64().or_else(|| v.as_str().and_then(|s| s.trim().trim_end_matches(|c: char| c.is_alphabetic() || c == '°').trim().parse().ok()));
match sp.kind {
PrefKind::Bool => match v {
Value::Bool(b) => Ok(json!(b)),
Value::String(s) if s == "true" || s == "false" => Ok(json!(s == "true")),
_ => Err(format!("`{key}` must be true or false")),
},
PrefKind::Num { min, max, .. } => match num() {
Some(x) if x.is_finite() && (min..=max).contains(&x) => Ok(json!(x)),
Some(x) => Err(format!("`{key}` must be between {min} and {max} (got {x})")),
None => Err(format!("`{key}` must be a number")),
},
PrefKind::Int { min, max } => match num() {
Some(x) if x.fract() == 0.0 && (min as f64..=max as f64).contains(&x) => Ok(json!(x as i64)),
Some(x) => Err(format!("`{key}` must be a whole number between {min} and {max} (got {x})")),
None => Err(format!("`{key}` must be a whole number")),
},
PrefKind::Choice(opts) => {
let s = v.as_str().ok_or_else(|| format!("`{key}` must be a string"))?;
opts.iter()
.find(|(val, label)| val.eq_ignore_ascii_case(s) || label.eq_ignore_ascii_case(s))
.map(|(val, _)| json!(val))
.ok_or_else(|| format!("`{key}` must be one of {}", opts.iter().map(|o| o.0).collect::<Vec<_>>().join(", ")))
}
PrefKind::Color => {
let s = v.as_str().unwrap_or("");
let hex = s.trim_start_matches('#');
if hex.len() == 6 && hex.chars().all(|c| c.is_ascii_hexdigit()) {
Ok(json!(format!("#{}", hex.to_ascii_lowercase())))
} else {
Err(format!("`{key}` must be a #rrggbb colour"))
}
}
PrefKind::Text => match v {
Value::String(s) => Ok(json!(s)),
_ => Err(format!("`{key}` must be a string")),
},
}
}
impl Prefs {
pub fn to_json(&self) -> Value {
serde_json::to_value(self).unwrap_or(Value::Null)
}
/// Set several validated values at once; on any error nothing changes.
pub fn set_values(&mut self, values: &Map<String, Value>) -> std::result::Result<(), String> {
let mut obj = self.to_json();
for (k, v) in values {
obj[k.as_str()] = validate(k, v)?;
}
*self = serde_json::from_value(obj).map_err(|e| e.to_string())?;
Ok(())
}
/// Values of one category (`None` = all) reset to defaults.
pub fn reset(&mut self, category: Option<&str>) {
let d = Prefs::default().to_json();
let mut obj = self.to_json();
for sp in PREF_SPECS.iter().filter(|s| category.is_none_or(|c| c.eq_ignore_ascii_case(s.category))) {
obj[sp.key] = d[sp.key].clone();
}
if let Ok(p) = serde_json::from_value(obj) {
*self = p;
}
}
}
impl Session {
/// Replace the preferences and push the ones with engine-side consumers into open documents
/// and the renderer (grid, history depth, render threads).
pub fn apply_prefs(&mut self, p: Prefs) {
let grid_changed = p.gridline_every != self.prefs.gridline_every || p.grid_subdivisions != self.prefs.grid_subdivisions;
let history_changed = p.history_states != self.prefs.history_states;
self.prefs = p;
drawcraft_render::set_default_threads(u16::try_from(self.prefs.render_threads).ok());
for st in &mut self.docs {
if history_changed {
st.history.limit = self.prefs.history_states as usize;
let over = st.history.undo.len().saturating_sub(st.history.limit);
st.history.undo.drain(..over);
}
if grid_changed {
let d = std::sync::Arc::make_mut(&mut st.doc);
d.grid.spacing = self.prefs.gridline_every;
d.grid.subdivisions = self.prefs.grid_subdivisions;
st.revision += 1;
}
}
}
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "prefs.get", "Get Preferences", [], None, "{key?} → the value of one preference, or {prefs: {…all…}}", always, get),
cmd!(
query "prefs.set",
"Set Preference",
[],
None,
"{key, value} or {values: {key: value, …}} set preferences (validated; see prefs.list)",
always,
set
),
cmd!(query "prefs.reset", "Reset Preferences", [], None, "{category?} reset one category (or everything) to defaults", always, reset),
cmd!(query "prefs.list", "List Preferences", [], None, "{} → [{key, category, section, label, kind, value, …}] every preference", always, list),
]
}
fn get(s: &mut Session, p: &Value) -> Result<Value> {
let all = s.prefs.to_json();
match str_param(p, "key") {
Some(k) if spec(k).is_some() => Ok(all[k].clone()),
Some(k) => Err(bad("prefs.get", format!("unknown preference `{k}`"))),
None => Ok(json!({ "prefs": all })),
}
}
fn set(s: &mut Session, p: &Value) -> Result<Value> {
let mut values = p.get("values").and_then(Value::as_object).cloned().unwrap_or_default();
if let Some(k) = str_param(p, "key") {
values.insert(k.to_string(), p.get("value").cloned().ok_or_else(|| bad("prefs.set", "missing `value`"))?);
}
if values.is_empty() {
return Err(bad("prefs.set", "give {key, value} or {values}"));
}
let mut next = s.prefs.clone();
next.set_values(&values).map_err(|e| bad("prefs.set", e))?;
s.apply_prefs(next);
let all = s.prefs.to_json();
Ok(Value::Object(values.keys().map(|k| (k.clone(), all[k.as_str()].clone())).collect()))
}
fn reset(s: &mut Session, p: &Value) -> Result<Value> {
let cat = str_param(p, "category");
if let Some(c) = cat
&& !PREF_CATEGORIES.iter().any(|x| x.eq_ignore_ascii_case(c))
{
return Err(bad("prefs.reset", format!("unknown category `{c}`")));
}
let mut next = s.prefs.clone();
next.reset(cat);
s.apply_prefs(next);
Ok(json!({ "prefs": s.prefs.to_json() }))
}
fn list(s: &mut Session, _: &Value) -> Result<Value> {
let all = s.prefs.to_json();
Ok(Value::Array(
PREF_SPECS
.iter()
.map(|sp| {
let mut o = json!({"key": sp.key, "category": sp.category, "section": sp.section, "label": sp.label, "value": all[sp.key]});
match sp.kind {
PrefKind::Bool => o["kind"] = json!("bool"),
PrefKind::Num { min, max, unit } => {
o["kind"] = json!("number");
o["min"] = json!(min);
o["max"] = json!(max);
o["unit"] = json!(unit);
}
PrefKind::Int { min, max } => {
o["kind"] = json!("integer");
o["min"] = json!(min);
o["max"] = json!(max);
}
PrefKind::Choice(c) => {
o["kind"] = json!("choice");
o["options"] = json!(c.iter().map(|x| x.0).collect::<Vec<_>>());
}
PrefKind::Color => o["kind"] = json!("color"),
PrefKind::Text => o["kind"] = json!("text"),
}
o
})
.collect(),
))
}

View File

@ -154,21 +154,249 @@ pub enum DrawMode {
Inside,
}
/// Application preferences that affect commands.
#[derive(Clone, Debug)]
/// Application preferences (Edit → Preferences). Every field is reachable through `prefs.get` /
/// `prefs.set {key, value}` (camelCase keys, validated against [`cmd::prefscmds::PREF_SPECS`]).
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Prefs {
// General
/// Arrow-key nudge distance (General → Keyboard Increment), points.
pub keyboard_increment: f64,
pub constrain_angle: f64,
pub corner_radius: f64,
pub disable_auto_add_delete: bool,
pub use_precise_cursors: bool,
pub show_tool_tips: bool,
pub anti_aliased_artwork: bool,
pub select_same_tint_percent: bool,
pub show_home_screen: bool,
pub use_preview_bounds: bool,
pub display_print_size: bool,
pub double_click_to_isolate: bool,
pub transform_pattern_tiles: bool,
pub scale_corners: bool,
/// Scale Strokes & Effects.
pub scale_strokes: bool,
pub zoom_with_mouse_wheel: bool,
/// Offset for Paste / duplicate (Illustrator pastes to the view centre; we offset by this).
pub paste_offset: f64,
pub corner_radius: f64,
// Selection & Anchor Display
pub selection_tolerance: f64,
pub object_selection_by_path_only: bool,
pub snap_to_point_tolerance: f64,
pub ctrl_click_selects_behind: bool,
pub zoom_to_selection: bool,
pub move_locked_with_artboard: bool,
pub anchor_size: u32,
pub handle_style: String,
pub highlight_anchors_on_hover: bool,
pub show_handles_multiple_anchors: bool,
pub hide_corner_widget_above: f64,
pub pen_rubber_band: bool,
pub curvature_rubber_band: bool,
// Type
pub type_size_increment: f64,
pub tracking_increment: f64,
pub baseline_shift_increment: f64,
pub show_east_asian_options: bool,
pub show_indic_options: bool,
pub type_selection_by_path_only: bool,
pub font_names_in_english: bool,
pub auto_size_area_type: bool,
pub font_preview: bool,
pub font_preview_size: String,
pub recent_fonts_count: u32,
pub missing_glyph_protection: bool,
pub highlight_alternate_glyphs: bool,
pub placeholder_text: bool,
// Units
pub units_general: String,
pub units_stroke: String,
pub units_type: String,
pub units_asian_type: String,
pub numbers_without_units_are_points: bool,
pub identify_objects_by: String,
// Guides & Grid
pub guide_color: String,
pub guide_style: String,
pub grid_color: String,
pub grid_style: String,
pub gridline_every: f64,
pub grid_subdivisions: u32,
pub grids_in_back: bool,
pub show_pixel_grid: bool,
// Smart Guides
pub smart_guide_color: String,
pub alignment_guides: bool,
pub object_highlighting: bool,
pub transform_tools_guides: bool,
pub construction_guides: bool,
pub construction_angles: String,
pub anchor_path_labels: bool,
pub measurement_labels: bool,
pub spacing_guides: bool,
pub snapping_tolerance: f64,
// Slices
pub show_slice_numbers: bool,
pub slice_line_color: String,
// Hyphenation
pub hyphenation_language: String,
pub hyphenation_exceptions: String,
// Performance & Storage (Plug-ins & Scratch Disks)
pub plugins_folder: String,
pub scratch_primary: String,
pub scratch_secondary: String,
// User Interface
pub ui_brightness: String,
pub canvas_color: String,
pub auto_collapse_icon_panels: bool,
pub open_documents_as_tabs: bool,
pub large_tabs: bool,
pub ui_scaling: f64,
pub scale_cursor_with_ui: bool,
// Performance
pub gpu_performance: bool,
pub animated_zoom: bool,
pub history_states: u32,
pub real_time_drawing: bool,
/// Rasterizer worker threads; -1 = automatic.
pub render_threads: i32,
// File Handling
pub background_save: bool,
pub background_export: bool,
pub autosave_recovery: bool,
pub autosave_interval: u32,
pub recovery_folder: String,
pub recovery_off_for_complex: bool,
pub recent_files_count: u32,
pub low_res_proxy_eps: bool,
pub anti_aliased_bitmaps: bool,
pub update_links: String,
// Clipboard Handling
pub copy_as_svg: bool,
pub copy_as_pdf: bool,
pub copy_aicb: bool,
pub aicb_mode: String,
pub paste_text_formatting: String,
// Appearance of Black
pub black_on_screen: String,
pub black_output: String,
// Devices
pub touch_workspace: bool,
pub touch_gestures: bool,
}
impl Default for Prefs {
fn default() -> Self {
Self { keyboard_increment: 1.0, scale_strokes: true, paste_offset: 10.0, corner_radius: 12.0 }
let s = |v: &str| v.to_string();
Self {
keyboard_increment: 1.0,
constrain_angle: 0.0,
corner_radius: 12.0,
disable_auto_add_delete: false,
use_precise_cursors: false,
show_tool_tips: true,
anti_aliased_artwork: true,
select_same_tint_percent: false,
show_home_screen: true,
use_preview_bounds: false,
display_print_size: false,
double_click_to_isolate: true,
transform_pattern_tiles: false,
scale_corners: false,
scale_strokes: true,
zoom_with_mouse_wheel: false,
paste_offset: 10.0,
selection_tolerance: 3.0,
object_selection_by_path_only: false,
snap_to_point_tolerance: 2.0,
ctrl_click_selects_behind: true,
zoom_to_selection: true,
move_locked_with_artboard: false,
anchor_size: 3,
handle_style: s("solid"),
highlight_anchors_on_hover: true,
show_handles_multiple_anchors: true,
hide_corner_widget_above: 177.0,
pen_rubber_band: true,
curvature_rubber_band: true,
type_size_increment: 2.0,
tracking_increment: 20.0,
baseline_shift_increment: 2.0,
show_east_asian_options: false,
show_indic_options: false,
type_selection_by_path_only: false,
font_names_in_english: true,
auto_size_area_type: false,
font_preview: true,
font_preview_size: s("medium"),
recent_fonts_count: 10,
missing_glyph_protection: true,
highlight_alternate_glyphs: true,
placeholder_text: true,
units_general: s("points"),
units_stroke: s("points"),
units_type: s("points"),
units_asian_type: s("points"),
numbers_without_units_are_points: true,
identify_objects_by: s("objectName"),
guide_color: s("#4affff"),
guide_style: s("lines"),
grid_color: s("#c8c8c8"),
grid_style: s("lines"),
gridline_every: 72.0,
grid_subdivisions: 8,
grids_in_back: true,
show_pixel_grid: true,
smart_guide_color: s("#ff4af0"),
alignment_guides: true,
object_highlighting: true,
transform_tools_guides: true,
construction_guides: true,
construction_angles: s("90° & 45° Angles"),
anchor_path_labels: true,
measurement_labels: true,
spacing_guides: true,
snapping_tolerance: 4.0,
show_slice_numbers: true,
slice_line_color: s("#ff3f3f"),
hyphenation_language: s("English: USA"),
hyphenation_exceptions: String::new(),
plugins_folder: String::new(),
scratch_primary: s("Startup"),
scratch_secondary: s("None"),
ui_brightness: s("mediumDark"),
canvas_color: s("matchUi"),
auto_collapse_icon_panels: false,
open_documents_as_tabs: true,
large_tabs: false,
ui_scaling: 1.0,
scale_cursor_with_ui: false,
gpu_performance: true,
animated_zoom: true,
history_states: 500,
real_time_drawing: true,
render_threads: -1,
background_save: true,
background_export: true,
autosave_recovery: true,
autosave_interval: 2,
recovery_folder: String::new(),
recovery_off_for_complex: false,
recent_files_count: 20,
low_res_proxy_eps: false,
anti_aliased_bitmaps: false,
update_links: s("askWhenModified"),
copy_as_svg: true,
copy_as_pdf: false,
copy_aicb: false,
aicb_mode: s("preserveAppearance"),
paste_text_formatting: s("keep"),
black_on_screen: s("accurate"),
black_output: s("accurate"),
touch_workspace: true,
touch_gestures: true,
}
}
}
@ -240,8 +468,23 @@ impl Session {
pub fn doc_mut(&mut self) -> Result<&mut DocState> {
self.active_mut().ok_or(EngineError::NoDocument)
}
/// Finish the tool's pending work in the current document and give it fresh state, so nothing
/// (e.g. uncommitted typing) leaks into the document we are switching to.
fn reset_tool_for_doc_switch(&mut self) {
if self.active.is_none() {
return;
}
let view = self.last_view;
let acts = self.with_tool_cx(view, |t, cx| t.deactivate(cx));
let _ = self.apply_actions(acts);
let _ = self.cancel_interaction();
self.tool = drawcraft_tools::create(self.tool.id());
}
pub fn set_active(&mut self, index: usize) -> bool {
if index < self.docs.len() {
if self.active != Some(index) {
self.reset_tool_for_doc_switch();
}
self.active = Some(index);
true
} else {
@ -254,7 +497,10 @@ impl Session {
}
/// Add a document and make it active.
pub fn add_document(&mut self, doc: Document, path: Option<String>) -> usize {
self.docs.push(DocState::new(doc, path));
self.reset_tool_for_doc_switch();
let mut st = DocState::new(doc, path);
st.history.limit = self.prefs.history_states as usize;
self.docs.push(st);
let i = self.docs.len() - 1;
self.active = Some(i);
i
@ -263,6 +509,7 @@ impl Session {
if index >= self.docs.len() {
return false;
}
self.reset_tool_for_doc_switch();
self.docs.remove(index);
self.active = if self.docs.is_empty() { None } else { Some(index.min(self.docs.len() - 1)) };
true
@ -423,6 +670,8 @@ mod tests_menucmds;
#[cfg(test)]
mod tests_panelcmds;
#[cfg(test)]
mod tests_prefs;
#[cfg(test)]
mod tests_pathops;
#[cfg(test)]
mod tests_textedit;

View File

@ -0,0 +1,121 @@
//! Preferences: `prefs.get` / `prefs.set` / `prefs.reset` / `prefs.list`.
use serde_json::json;
use super::*;
use crate::cmd::prefscmds::{PREF_CATEGORIES, PREF_SPECS, validate};
#[test]
fn every_spec_matches_a_prefs_field_and_back() {
let all = Prefs::default().to_json();
let obj = all.as_object().unwrap();
for sp in PREF_SPECS {
assert!(obj.contains_key(sp.key), "spec `{}` has no Prefs field", sp.key);
assert!(PREF_CATEGORIES.contains(&sp.category), "spec `{}` has unknown category", sp.key);
// Defaults validate against their own spec.
assert!(validate(sp.key, &obj[sp.key]).is_ok(), "default of `{}` fails validation", sp.key);
}
for k in obj.keys() {
assert!(PREF_SPECS.iter().any(|s| s.key == k), "Prefs field `{k}` has no spec");
}
for c in PREF_CATEGORIES {
assert!(PREF_SPECS.iter().any(|s| s.category == *c), "category {c} is empty");
}
}
#[test]
fn set_and_get_keyboard_increment_drives_nudge() {
let mut s = Session::new();
s.execute("prefs.set", &json!({"key": "keyboardIncrement", "value": 5})).unwrap();
assert_eq!(s.execute("prefs.get", &json!({"key": "keyboardIncrement"})).unwrap(), json!(5.0));
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let r = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("object.nudge", &json!({"dx": 1, "dy": 0})).unwrap();
let b = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap();
assert!((b.x0 - 15.0).abs() < 1e-9);
}
#[test]
fn set_rejects_out_of_range_and_wrong_types() {
let mut s = Session::new();
assert!(s.execute("prefs.set", &json!({"key": "keyboardIncrement", "value": -1})).is_err());
assert!(s.execute("prefs.set", &json!({"key": "anchorSize", "value": 9})).is_err());
assert!(s.execute("prefs.set", &json!({"key": "anchorSize", "value": 2.5})).is_err());
assert!(s.execute("prefs.set", &json!({"key": "scaleStrokes", "value": 3})).is_err());
assert!(s.execute("prefs.set", &json!({"key": "uiBrightness", "value": "purple"})).is_err());
assert!(s.execute("prefs.set", &json!({"key": "gridColor", "value": "#12345"})).is_err());
assert!(s.execute("prefs.set", &json!({"key": "nope", "value": 1})).is_err());
assert!(s.execute("prefs.set", &json!({"key": "scaleStrokes"})).is_err());
assert_eq!(s.prefs, Prefs::default());
}
#[test]
fn set_normalizes_strings_labels_and_colours() {
let mut s = Session::new();
let r = s.execute("prefs.set", &json!({"values": {"uiBrightness": "Medium Light", "gridColor": "ABCDEF", "keyboardIncrement": "2.5 pt", "scaleStrokes": "false"}})).unwrap();
assert_eq!(r["uiBrightness"], json!("mediumLight"));
assert_eq!(s.prefs.grid_color, "#abcdef");
assert_eq!(s.prefs.keyboard_increment, 2.5);
assert!(!s.prefs.scale_strokes);
}
#[test]
fn batch_set_is_atomic() {
let mut s = Session::new();
assert!(s.execute("prefs.set", &json!({"values": {"keyboardIncrement": 3, "anchorSize": 99}})).is_err());
assert_eq!(s.prefs.keyboard_increment, 1.0);
}
#[test]
fn grid_prefs_apply_to_open_documents() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
s.execute("prefs.set", &json!({"values": {"gridlineEvery": 36, "gridSubdivisions": 4}})).unwrap();
let g = &s.doc().unwrap().doc.grid;
assert_eq!((g.spacing, g.subdivisions), (36.0, 4));
}
#[test]
fn history_states_limit_undo_depth() {
let mut s = Session::new();
s.execute("prefs.set", &json!({"key": "historyStates", "value": 5})).unwrap();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
for i in 0..9 {
s.execute("shape.rectangle", &json!({"x": i * 10, "y": 0, "width": 5, "height": 5})).unwrap();
}
assert!(s.doc().unwrap().history.undo.len() <= 5);
}
#[test]
fn reset_category_and_all() {
let mut s = Session::new();
s.execute("prefs.set", &json!({"values": {"keyboardIncrement": 7, "gridColor": "#000000"}})).unwrap();
s.execute("prefs.reset", &json!({"category": "General"})).unwrap();
assert_eq!(s.prefs.keyboard_increment, 1.0);
assert_eq!(s.prefs.grid_color, "#000000");
s.execute("prefs.reset", &json!({})).unwrap();
assert_eq!(s.prefs, Prefs::default());
assert!(s.execute("prefs.reset", &json!({"category": "Bogus"})).is_err());
}
#[test]
fn list_describes_every_pref() {
let mut s = Session::new();
let l = s.execute("prefs.list", &json!({})).unwrap();
let a = l.as_array().unwrap();
assert_eq!(a.len(), PREF_SPECS.len());
assert!(a.iter().any(|e| e["key"] == "renderThreads" && e["kind"] == "integer" && e["min"] == -1));
}
#[test]
fn prefs_serde_round_trip_and_tolerates_missing_fields() {
let mut p = Prefs::default();
p.ui_scaling = 1.25;
p.units_general = "millimeters".into();
let back: Prefs = serde_json::from_value(p.to_json()).unwrap();
assert_eq!(back, p);
let partial: Prefs = serde_json::from_value(json!({"keyboardIncrement": 4})).unwrap();
assert_eq!(partial.keyboard_increment, 4.0);
assert_eq!(partial.corner_radius, 12.0);
}

View File

@ -321,3 +321,22 @@ fn commands_are_listed_with_params() {
assert!(cmds.iter().any(|c| c["id"] == id), "{id}");
}
}
#[test]
fn type_tool_state_does_not_leak_across_documents() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
click(&mut s, 200.0, 200.0);
let id = s.doc().unwrap().selection.objects[0];
s.tool_text("First", v).unwrap();
// A second document whose first object reuses the same id.
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
let id2 = text(&mut s, "Other");
assert!(!s.tool_wants_text(), "switching documents ends the edit");
s.tool_text("XYZ", v).unwrap();
assert_eq!(obj(&s, id2).plain_text(), "Other");
s.set_active(0);
assert_eq!(obj(&s, id).plain_text(), "First");
assert!(!s.in_interaction());
}

View File

@ -39,13 +39,13 @@ impl Session {
if self.tool.id() == id {
return Ok(());
}
let acts = self.with_cx(view, |t, cx| t.deactivate(cx));
let acts = self.with_tool_cx(view, |t, cx| t.deactivate(cx));
self.apply_actions(acts)?;
self.tool = drawcraft_tools::create(id);
Ok(())
}
fn with_cx<R>(&mut self, view: ViewInfo, f: impl FnOnce(&mut dyn drawcraft_tools::Tool, &ToolContext) -> R) -> R
pub(crate) fn with_tool_cx<R>(&mut self, view: ViewInfo, f: impl FnOnce(&mut dyn drawcraft_tools::Tool, &ToolContext) -> R) -> R
where
R: Default,
{
@ -67,18 +67,18 @@ impl Session {
/// Feed a pointer event to the active tool. Returns requests for the UI.
pub fn pointer(&mut self, ev: &PointerEvent, view: ViewInfo) -> Result<Vec<UiRequest>> {
self.last_view = view;
let acts = self.with_cx(view, |t, cx| t.pointer(cx, ev));
let acts = self.with_tool_cx(view, |t, cx| t.pointer(cx, ev));
self.apply_actions(acts)
}
pub fn tool_key(&mut self, key: ToolKey, mods: Mods, view: ViewInfo) -> Result<Vec<UiRequest>> {
let acts = self.with_cx(view, |t, cx| t.key(cx, key, mods));
let acts = self.with_tool_cx(view, |t, cx| t.key(cx, key, mods));
self.apply_actions(acts)
}
/// Typed text for the active tool (Type tool).
pub fn tool_text(&mut self, text: &str, view: ViewInfo) -> Result<Vec<UiRequest>> {
let acts = self.with_cx(view, |t, cx| t.text_input(cx, text));
let acts = self.with_tool_cx(view, |t, cx| t.text_input(cx, text));
self.apply_actions(acts)
}
@ -91,11 +91,11 @@ impl Session {
}
pub fn overlays(&mut self, view: ViewInfo) -> Vec<Overlay> {
self.with_cx(view, |t, cx| t.overlays(cx))
self.with_tool_cx(view, |t, cx| t.overlays(cx))
}
pub fn cursor(&mut self, p: Point, mods: Mods, view: ViewInfo) -> Cursor {
self.with_cx(view, |t, cx| t.cursor(cx, p, mods))
self.with_tool_cx(view, |t, cx| t.cursor(cx, p, mods))
}
pub fn tool_options(&self) -> Value {
@ -125,7 +125,7 @@ impl Session {
Action::SwitchTool(t) => ui.push(UiRequest::SwitchTool(t)),
Action::Notify(what) => {
let view = self.last_view;
self.with_cx(view, |t, cx| t.notify(cx, &what));
self.with_tool_cx(view, |t, cx| t.notify(cx, &what));
}
}
}

View File

@ -281,7 +281,7 @@ impl Exporter<'_> {
}
}
Paint::Pattern { .. } => {
self.warn("pattern fills are exported as mid-grey");
self.warn("pattern strokes (and missing patterns) are exported as mid-grey");
Some(rgb::Color::new(128, 128, 128).into())
}
}
@ -377,7 +377,7 @@ impl Exporter<'_> {
}
}
// Live blends/envelopes/meshes export their evaluated (expanded) form.
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) => {
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
let g = drawcraft_doc::live::expand_deep(n, None);
for c in g.children().into_iter().flatten() {
self.node(s, c, page, false);
@ -401,6 +401,24 @@ impl Exporter<'_> {
if !fl.effects.is_empty() {
self.warn("live effects are not exported to PDF yet");
}
// Pattern fills: the tile instances covering the shape, clipped to it.
let doc = self.doc;
if let Paint::Pattern { pattern, xf } = &fl.paint
&& let Some(def) = doc.pattern(pattern)
{
s.push_clip_path(&path, &rule(r));
if fl.opacity < 1.0 {
s.push_opacity(norm(fl.opacity));
}
for inst in def.instances_in(*xf, bounds) {
self.node(s, &inst, bounds, true);
}
if fl.opacity < 1.0 {
s.pop();
}
s.pop();
continue;
}
let Some(paint) = self.paint(&fl.paint, bounds) else { continue };
let bl = fl.blend != BlendMode::Normal;
if bl {

View File

@ -10,6 +10,8 @@ mod brush_fx;
mod fx;
mod live;
mod paint;
pub mod proof;
mod pattern;
use std::collections::HashMap;
use std::sync::Arc;
@ -22,6 +24,7 @@ use vello_cpu::{Pixmap, RenderContext, Resources};
pub use drawcraft_effects as effects;
pub use live::expand_live;
pub use pattern::render_pattern_swatch;
pub use vello_cpu;
/// Rendering options.
@ -37,11 +40,15 @@ pub struct RenderOptions {
pub hidden: Vec<NodeId>,
/// Draw template layers dimmed (50%).
pub dim_templates: bool,
/// Soft proof / separations preview (see [`proof`]).
pub proof: Option<proof::ProofSetup>,
/// Overprint Preview (see [`proof`] for what overprints).
pub overprint_preview: bool,
}
impl Default for RenderOptions {
fn default() -> Self {
Self { outline: false, background: None, artboards: false, hidden: vec![], dim_templates: true }
Self { outline: false, background: None, artboards: false, hidden: vec![], dim_templates: true, proof: None, overprint_preview: false }
}
}
@ -185,6 +192,8 @@ impl Renderer {
/// Render `doc` into a `width`×`height` image using `view` (document → pixel transform).
pub fn render(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions) -> Rendered {
let start = now();
let prepared = proof::prepare(doc, opts);
let doc: &Document = &prepared;
let w = width.clamp(1, u16::MAX as u32) as u16;
let h = height.clamp(1, u16::MAX as u32) as u16;
// Raster filters (drop shadow, glows, blur) need the single-threaded pipeline.
@ -222,8 +231,10 @@ impl Renderer {
}
}
self.stamp += 1;
for layer in &doc.layers {
self.draw_arc(&mut ctx, &frame, layer);
if !self.draw_pattern_edit(&mut ctx, &frame) {
for layer in &doc.layers {
self.draw_arc(&mut ctx, &frame, layer);
}
}
// Drop cache entries not seen for a few frames.
let g = self.stamp;
@ -238,8 +249,10 @@ impl Renderer {
} else {
self.ctx = Some(ctx);
}
let mut pixels = pm.data_as_u8_slice().to_vec();
proof::post(&mut pixels, opts);
self.stats.micros = now().saturating_sub(start);
Rendered { width: w as u32, height: h as u32, pixels: pm.data_as_u8_slice().to_vec() }
Rendered { width: w as u32, height: h as u32, pixels }
}
/// Render one artboard (or any document rect) at `scale` pixels per point, transparent or on white.
@ -433,7 +446,7 @@ impl Renderer {
self.draw_node(ctx, f, &art, true);
}
}
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) => self.draw_live_node(ctx, f, n),
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => self.draw_live_node(ctx, f, n),
}
self.stats.drawn += 1;
for _ in 0..layers {
@ -686,14 +699,26 @@ fn painted_items(n: &Node) -> usize {
.max(if n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if f.visible && !matches!(f.paint, drawcraft_color::Paint::Solid { .. } | drawcraft_color::Paint::None)) || matches!(i, AppearanceItem::Stroke(s) if s.visible && !matches!(s.paint, drawcraft_color::Paint::Solid { .. } | drawcraft_color::Paint::None))) { 2 } else { 0 })
}
/// Preferred rasterizer thread count set by the app (Preferences → Performance); negative = automatic.
static THREADS_OVERRIDE: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1);
/// Override the worker-thread count used by renderers created from now on (`None` = automatic).
pub fn set_default_threads(n: Option<u16>) {
THREADS_OVERRIDE.store(n.map_or(-1, i32::from), std::sync::atomic::Ordering::Relaxed);
}
/// Rasterizer worker threads: 0 on wasm; otherwise up to 4 (vello's sweet spot), leaving a core free.
fn default_threads() -> u16 {
pub fn default_threads() -> u16 {
#[cfg(target_arch = "wasm32")]
{
0
}
#[cfg(not(target_arch = "wasm32"))]
{
let o = THREADS_OVERRIDE.load(std::sync::atomic::Ordering::Relaxed);
if o >= 0 {
return o.min(64) as u16;
}
if std::env::var_os("DRAWCRAFT_RENDER_THREADS").is_some() {
return std::env::var("DRAWCRAFT_RENDER_THREADS").ok().and_then(|v| v.parse().ok()).unwrap_or(0);
}

View File

@ -129,7 +129,7 @@ impl Renderer {
pub(crate) fn draw_live_body(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc<Node>, cache: bool) {
match &a.kind {
NodeKind::Mesh(m) => self.draw_mesh(ctx, f, a, m, cache),
NodeKind::Blend { .. } | NodeKind::Envelope { .. } => {
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) => {
let items = self.live_expanded(a, cache);
for c in items.iter() {
self.draw_arc(ctx, f, c);

View File

@ -13,7 +13,7 @@ fn color(c: &drawcraft_color::Color, alpha: f32) -> peniko::Color {
}
/// Set the context paint. Returns false if nothing should be drawn.
pub fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, _doc: &Document) -> bool {
pub fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, doc: &Document) -> bool {
match p {
Paint::None => false,
Paint::Solid { color: c, .. } => {
@ -50,11 +50,7 @@ pub fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, _doc: &Docume
ctx.set_paint(grad);
true
}
Paint::Pattern { .. } => {
// Patterns render as a neutral mid-grey until pattern swatches land (M10.5).
ctx.set_paint(peniko::Color::from_rgba8(128, 128, 128, 255));
true
}
Paint::Pattern { pattern, xf } => crate::pattern::set_pattern_paint(ctx, pattern, *xf, doc),
}
}

View File

@ -0,0 +1,342 @@
//! Pattern fills and the pattern-editing preview.
//!
//! A pattern paint is drawn as a repeating image: the tiling's rectangular super-tile
//! ([`PatternDef::period`]) is rasterized once at the current device scale (rounded to a power of
//! two, so it is re-rasterized only when the zoom changes by more than 2×) and used as an image
//! paint with `Extend::Repeat`, with the paint transform mapping pixels → pattern space →
//! document. Tiles are cached per pattern name and invalidated when the definition changes (art
//! `Arc` identity, tile, tile type, overlap) — the pattern's "revision".
//!
//! Pattern editing mode (`Document::pattern_edit`) draws only the temporary tile layer, with
//! dimmed copies of it at the neighbouring tile positions and the tile edge.
use std::cell::RefCell;
use std::collections::HashMap;
use std::sync::Arc;
use drawcraft_doc::pattern::{Overlap, PatternDef, TileType};
use drawcraft_doc::{Document, Node};
use drawcraft_geom::{Affine, Rect, Shape};
use vello_cpu::peniko::{self, Extend, ImageQuality, ImageSampler};
use vello_cpu::{Pixmap, RenderContext};
use crate::{Frame, RenderOptions, Rendered, Renderer};
/// Largest super-tile raster side (pixels).
const MAX_TILE_PX: f64 = 2048.0;
struct TileEntry {
art: Vec<Arc<Node>>,
params: (Rect, TileType, Overlap),
scale: f64,
sx: f64,
sy: f64,
pixmap: Arc<Pixmap>,
}
#[derive(Default)]
struct PatternCache {
tiles: HashMap<String, TileEntry>,
renderer: Option<Box<Renderer>>,
depth: u32,
}
thread_local! {
static CACHE: RefCell<PatternCache> = RefCell::new(PatternCache::default());
}
fn same_art(a: &[Arc<Node>], b: &[Arc<Node>]) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| Arc::ptr_eq(x, y))
}
/// Power-of-two raster scale for a pattern seen at device scale `s`, capped so the super-tile
/// stays under [`MAX_TILE_PX`].
pub(crate) fn raster_scale(s: f64, period: (f64, f64)) -> f64 {
let s = s.clamp(1e-3, 1e3);
let p2 = 2f64.powf(s.log2().ceil());
let cap = MAX_TILE_PX / period.0.max(period.1).max(1e-6);
p2.min(cap).max(1e-3)
}
/// Rasterize `region` (pattern space) of the infinite tiling into a `w`×`h` pixmap.
fn rasterize(r: &mut Renderer, doc: &Document, def: &PatternDef, region: Rect, w: u16, h: u16) -> Pixmap {
let (w, h) = (w.max(1), h.max(1));
let mut ctx = RenderContext::new(w, h);
let view = Affine::scale_non_uniform(w as f64 / region.width().max(1e-9), h as f64 / region.height().max(1e-9))
* Affine::translate(-region.origin().to_vec2());
let opts = RenderOptions::default();
r.stamp += 1;
let px_rect = Rect::new(0.0, 0.0, w as f64, h as f64);
for o in def.offsets_covering(region) {
let v = view * def.instance_xf(o);
let frame = Frame { doc, view: v, visible: v.inverse().transform_rect_bbox(px_rect), px: 1.0 / v.determinant().abs().sqrt().max(1e-12), opts: &opts };
for a in &def.art {
r.draw_arc(&mut ctx, &frame, a);
}
}
ctx.flush();
let mut pm = Pixmap::new(w, h);
ctx.render(&mut pm, &mut r.resources);
pm
}
/// Run `f` with the shared off-screen renderer (None when nested too deep: a pattern whose art
/// uses a pattern whose art uses a pattern…).
fn with_tile_renderer<T>(f: impl FnOnce(&mut Renderer) -> T) -> Option<T> {
let r = CACHE.with(|c| {
let mut c = c.borrow_mut();
if c.depth >= 3 {
return None;
}
c.depth += 1;
Some(c.renderer.take().unwrap_or_else(|| {
let mut r = Renderer::new();
r.threads = 0;
Box::new(r)
}))
})?;
let mut r = r;
let out = f(&mut r);
CACHE.with(|c| {
let mut c = c.borrow_mut();
c.depth -= 1;
if c.renderer.is_none() {
c.renderer = Some(r);
}
});
Some(out)
}
/// Set a pattern paint on `ctx` (its current transform maps the paint's user space to pixels).
pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine, doc: &Document) -> bool {
let Some(def) = doc.pattern(name) else {
ctx.set_paint(peniko::Color::from_rgba8(128, 128, 128, 255));
return true;
};
if def.art.is_empty() {
return false;
}
let dev = *ctx.transform() * xf;
let s = dev.determinant().abs().sqrt();
let (pw, ph) = def.period();
let scale = raster_scale(s, (pw, ph));
let params = (def.tile, def.tile_type, def.overlap);
let hit = CACHE.with(|c| {
c.borrow().tiles.get(name).filter(|e| e.scale == scale && e.params == params && same_art(&e.art, &def.art)).map(|e| (e.pixmap.clone(), e.sx, e.sy))
});
let (pm, sx, sy) = match hit {
Some(h) => h,
None => {
let w = (pw * scale).ceil().clamp(1.0, 4096.0) as u16;
let h = (ph * scale).ceil().clamp(1.0, 4096.0) as u16;
let Some(pm) = with_tile_renderer(|r| rasterize(r, doc, def, Rect::new(0.0, 0.0, pw, ph), w, h)) else {
ctx.set_paint(peniko::Color::from_rgba8(128, 128, 128, 255));
return true;
};
let pm = Arc::new(pm);
let (sx, sy) = (w as f64 / pw, h as f64 / ph);
CACHE.with(|c| {
let mut c = c.borrow_mut();
if c.tiles.len() > 64 {
c.tiles.clear();
}
c.tiles.insert(name.to_string(), TileEntry { art: def.art.clone(), params, scale, sx, sy, pixmap: pm.clone() });
});
(pm, sx, sy)
}
};
let sampler = ImageSampler { x_extend: Extend::Repeat, y_extend: Extend::Repeat, quality: ImageQuality::Medium, alpha: 1.0 };
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler });
ctx.set_paint_transform(xf * Affine::scale_non_uniform(1.0 / sx, 1.0 / sy));
true
}
/// A swatch thumbnail of pattern `name`: the tiling around the tile, fitted into `size`².
pub fn render_pattern_swatch(doc: &Document, name: &str, size: u32) -> Option<Rendered> {
let def = doc.pattern(name)?;
let (w, h) = (def.width(), def.height());
let side = w.max(h);
let region = Rect::from_center_size((w / 2.0, h / 2.0), (side, side));
let px = size.clamp(1, 1024) as u16;
let pm = with_tile_renderer(|r| rasterize(r, doc, def, region, px, px))?;
Some(Rendered { width: px as u32, height: px as u32, pixels: pm.data_as_u8_slice().to_vec() })
}
impl Renderer {
/// Pattern editing mode: draw the tile layer, its dimmed copies and the tile edge instead of
/// the document. Returns false when not in pattern editing mode.
pub(crate) fn draw_pattern_edit(&mut self, ctx: &mut RenderContext, f: &Frame) -> bool {
let Some(pe) = &f.doc.pattern_edit else { return false };
let Some(layer) = f.doc.layers.iter().find(|l| l.id == pe.layer) else { return false };
let Some(def) = f.doc.pattern(&pe.pattern) else { return false };
let children: Vec<Arc<Node>> = layer.children().cloned().unwrap_or_default();
if layer.visible && !children.is_empty() {
let dim = (def.dim_copies / 100.0).clamp(0.0, 1.0);
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, None, Some(dim), None, None);
for o in def.preview_offsets() {
let view = f.view * Affine::translate(o);
let visible = f.visible - o;
let frame = Frame { doc: f.doc, view, visible, px: f.px, opts: f.opts };
for c in &children {
self.draw_arc(ctx, &frame, c);
}
}
ctx.pop_layer();
}
self.draw_arc(ctx, f, layer);
if def.show_tile_edge {
let bp = def.tile.to_path(0.1);
self.hairline(ctx, f, &bp, [0x2d, 0x8c, 0xeb, 255]);
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use drawcraft_color::{Color, Paint};
use drawcraft_doc::pattern::{PatternEdit, RepeatSpec, pattern_paint};
use drawcraft_doc::{Appearance, NodeId, NodeKind};
use drawcraft_geom::shapes;
/// A 20×20 tile with a red 10×10 square in its top-left corner.
fn doc_with_pattern(tt: TileType) -> Document {
let mut d = Document::new(200.0, 200.0);
let red = Node::path(NodeId(900), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0));
let mut def = PatternDef::new("Checks", vec![Arc::new(red)]);
def.tile = Rect::new(0.0, 0.0, 20.0, 20.0);
def.tile_type = tt;
d.patterns.push(def);
let id = d.alloc_id();
let n = Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0));
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
d
}
fn render(d: &Document, scale: f64) -> Rendered {
let px = (200.0 * scale) as u32;
Renderer::new().render(d, px, px, Affine::scale(scale), &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
}
fn is_red(p: [u8; 4]) -> bool {
p[0] > 200 && p[1] < 60 && p[2] < 60
}
fn is_white(p: [u8; 4]) -> bool {
p[0] > 200 && p[1] > 200 && p[2] > 200
}
#[test]
fn pattern_fill_is_periodic() {
let d = doc_with_pattern(TileType::Grid);
let r = render(&d, 1.0);
for (x, y) in [(5, 5), (25, 5), (125, 145), (185, 65)] {
assert!(is_red(r.pixel(x, y)), "({x},{y}) {:?}", r.pixel(x, y));
}
for (x, y) in [(15, 5), (5, 15), (135, 135), (175, 195)] {
assert!(is_white(r.pixel(x, y)), "({x},{y}) {:?}", r.pixel(x, y));
}
// Periodic: pixel (x, y) equals (x + 20k, y + 20m).
for y in (1..20).step_by(3) {
for x in (1..20).step_by(3) {
if x == 10 || y == 10 {
continue;
}
assert_eq!(r.pixel(x, y), r.pixel(x + 60, y + 100));
}
}
}
#[test]
fn pattern_fill_is_clipped_to_shape() {
let mut d = doc_with_pattern(TileType::Grid);
let id = d.layers[0].children().unwrap()[0].id;
*d.node_mut(id).unwrap().path_data_mut().unwrap() = shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0));
let r = render(&d, 1.0);
assert!(is_red(r.pixel(45, 45)));
assert!(is_white(r.pixel(65, 65)));
}
#[test]
fn brick_pattern_shifts_rows() {
let d = doc_with_pattern(TileType::BrickByRow { offset: 0.5 });
let r = render(&d, 1.0);
assert!(is_red(r.pixel(5, 5)));
// Row 1 is shifted by half a tile.
assert!(is_white(r.pixel(5, 25)) && is_red(r.pixel(15, 25)), "{:?} {:?}", r.pixel(5, 25), r.pixel(15, 25));
assert!(is_red(r.pixel(5, 45)));
}
#[test]
fn pattern_follows_zoom_and_paint_transform() {
let d = doc_with_pattern(TileType::Grid);
let r = render(&d, 2.0);
assert!(is_red(r.pixel(10, 10)) && is_white(r.pixel(30, 10)) && is_red(r.pixel(50, 10)));
let mut d2 = d.clone();
let id = d2.layers[0].children().unwrap()[0].id;
drawcraft_doc::pattern::transform_pattern_paints(d2.node_mut(id).unwrap(), Affine::translate((10.0, 0.0)));
let r = render(&d2, 1.0);
assert!(is_white(r.pixel(5, 5)) && is_red(r.pixel(15, 5)));
}
#[test]
fn missing_pattern_is_grey() {
let mut d = doc_with_pattern(TileType::Grid);
d.patterns.clear();
let p = render(&d, 1.0).pixel(50, 50);
assert_eq!(p, [128, 128, 128, 255]);
}
#[test]
fn swatch_thumbnail_renders() {
let d = doc_with_pattern(TileType::Grid);
let r = render_pattern_swatch(&d, "Checks", 20).unwrap();
assert!(is_red(r.pixel(3, 3)));
assert_eq!(r.pixel(15, 15)[3], 0);
assert!(render_pattern_swatch(&d, "Nope", 20).is_none());
}
#[test]
fn edit_mode_draws_only_tile_layer_with_copies() {
let mut d = doc_with_pattern(TileType::Grid);
let l = d.add_layer(Some("Pattern Editing Mode"));
let art = d.patterns[0].art[0].clone();
d.insert(Some(l), 0, (*art).clone()).unwrap();
d.pattern_edit = Some(PatternEdit { pattern: "Checks".into(), layer: l, original: None });
d.patterns[0].dim_copies = 50.0;
d.patterns[0].show_tile_edge = false;
let r = render(&d, 1.0);
// The tile itself at full strength, copies dimmed, the filled document rect hidden.
assert!(is_red(r.pixel(5, 5)));
let c = r.pixel(25, 5);
assert!(c[0] > 200 && c[1] > 100 && c[1] < 160, "{c:?}");
assert!(is_white(r.pixel(15, 5)));
assert!(is_white(r.pixel(125, 125)), "outside the copies");
}
#[test]
fn repeat_renders_instances() {
let mut d = Document::new(200.0, 200.0);
let sq = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
let id = d.alloc_id();
let l = d.layers[0].id;
d.insert(Some(l), 0, Node::new(id, NodeKind::Repeat(RepeatSpec::grid(vec![Arc::new(sq)], 10.0, 10.0)))).unwrap();
let r = render(&d, 1.0);
for (x, y) in [(5, 5), (25, 5), (45, 45)] {
assert_eq!(r.pixel(x, y), [0, 0, 0, 255], "({x},{y})");
}
assert!(is_white(r.pixel(15, 5)) && is_white(r.pixel(65, 5)));
}
#[test]
fn raster_scale_is_power_of_two_and_capped() {
assert_eq!(raster_scale(1.0, (10.0, 10.0)), 1.0);
assert_eq!(raster_scale(1.5, (10.0, 10.0)), 2.0);
assert_eq!(raster_scale(3.0, (10.0, 10.0)), 4.0);
// A change within 2× keeps the raster.
assert_eq!(raster_scale(2.1, (10.0, 10.0)), raster_scale(3.9, (10.0, 10.0)));
assert!(raster_scale(64.0, (1000.0, 10.0)) * 1000.0 <= MAX_TILE_PX);
}
}

292
crates/render/src/proof.rs Normal file
View File

@ -0,0 +1,292 @@
//! Soft proofing (View → Proof Colors), Overprint Preview and Separations Preview.
//!
//! * **Proof Colors** post-processes the rendered frame: every pixel goes display sRGB → proof
//! space (CMYK press or RGB/colour-blindness simulation) → display, through a cached 17³ LUT.
//! * **Separations Preview** recolours the *document* before rendering: each paint colour is split
//! into process inks (colour-managed) plus a spot ink when it's linked to a spot swatch. With one
//! plate visible the plate renders as greyscale ink coverage (black = 100%); with several, the
//! visible inks are composited through the proof profile, spots multiplied on top.
//! Placed raster images are not separated (limitation).
//! * **Overprint Preview:** the document model has no per-object overprint attribute, so
//! overprinting is limited to objects listed in `Document.unknown["overprintBlack"]` (set by
//! Edit → Edit Colors → Overprint Black) whose fill or stroke is 100% K. Such objects are drawn
//! with Multiply, which is what overprinting black ink does to the inks below.
//!
//! The app's current view state lives in [`view`] / [`set_view`]; the canvas copies it into
//! [`crate::RenderOptions`] with [`active_proof`] and [`overprint_preview_on`].
use std::borrow::Cow;
use std::sync::{Arc, RwLock};
use drawcraft_color::cms::{self, Cms, PROCESS_PLATES};
pub use drawcraft_color::cms::{Intent, ProofSetup, ProofTarget};
use drawcraft_color::{BlendMode, Color, Paint};
use drawcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind};
use crate::RenderOptions;
/// `Document.unknown` key listing the ids of objects marked Overprint Black.
pub const OVERPRINT_KEY: &str = "overprintBlack";
/// Ink coverage of one paint colour.
#[derive(Clone, Debug, PartialEq)]
pub struct Inks {
/// Process inks C, M, Y, K (0..1).
pub cmyk: [f32; 4],
/// Spot ink (swatch name, tint 0..1).
pub spot: Option<(String, f32)>,
}
/// A printing plate.
#[derive(Clone, Debug, PartialEq)]
pub struct Plate {
pub name: String,
pub spot: bool,
/// Display colour of the ink (for the panel swatch).
pub rgb: [f32; 3],
}
/// The plates of `doc`: the four process plates plus one per spot swatch.
pub fn plates(doc: &Document) -> Vec<Plate> {
let c = cms::active();
let mut v: Vec<Plate> = PROCESS_PLATES
.iter()
.enumerate()
.map(|(i, n)| {
let mut ink = [0.0; 4];
ink[i] = 1.0;
Plate { name: (*n).into(), spot: false, rgb: c.cmyk_to_srgb(ink, false) }
})
.collect();
for sw in &doc.swatches {
if let (true, Paint::Solid { color, .. }) = (sw.spot, &sw.paint)
&& !v.iter().any(|p| p.name == sw.name)
{
v.push(Plate { name: sw.name.clone(), spot: true, rgb: c.display_rgb(color) });
}
}
v
}
fn spot_swatch<'a>(doc: &'a Document, name: &str) -> Option<&'a Color> {
doc.swatches.iter().find(|s| s.spot && s.name == name).and_then(|s| match &s.paint {
Paint::Solid { color, .. } => Some(color),
_ => None,
})
}
/// Separate one colour into inks. Colours linked to a spot swatch print on that plate only, with
/// the tint given by the colour's ink total relative to the swatch's.
pub fn inks(doc: &Document, c: &Cms, color: &Color, swatch: Option<&str>, intent: Intent) -> Inks {
if let Some(name) = swatch
&& let Some(sc) = spot_swatch(doc, name)
{
let full: f32 = c.to_cmyk(sc, intent).iter().sum();
let this: f32 = c.to_cmyk(color, intent).iter().sum();
let tint = if full <= 1e-4 { 1.0 } else { (this / full).clamp(0.0, 1.0) };
return Inks { cmyk: [0.0; 4], spot: Some((name.to_string(), tint)) };
}
Inks { cmyk: c.to_cmyk(color, intent), spot: None }
}
fn map_paint(p: &mut Paint, f: &mut dyn FnMut(&Color, Option<&str>) -> Color) {
match p {
Paint::Solid { color, swatch } => *color = f(color, swatch.as_deref()),
Paint::Gradient(g) => {
for s in &mut g.gradient.stops {
s.color = f(&s.color, None);
}
}
_ => {}
}
}
/// Apply `f` to every colour of a node tree (fills, strokes, gradient stops, text runs, mesh
/// points), keeping swatch links.
pub fn map_node_colors(n: &mut Node, f: &mut dyn FnMut(&Color, Option<&str>) -> Color) {
for it in &mut n.appearance.items {
match it {
AppearanceItem::Fill(l) => map_paint(&mut l.paint, f),
AppearanceItem::Stroke(l) => map_paint(&mut l.paint, f),
}
}
match &mut n.kind {
NodeKind::Text(t) => {
for r in &mut t.runs {
map_paint(&mut r.style.fill, f);
map_paint(&mut r.style.stroke, f);
}
}
NodeKind::Mesh(m) => {
for p in &mut m.points {
p.color = f(&p.color, None);
}
}
_ => {}
}
if let Some(ch) = n.children_mut() {
for c in ch.iter_mut() {
map_node_colors(Arc::make_mut(c), f);
}
}
}
/// Apply `f` to every colour in the document's art and symbol definitions.
pub fn map_document_colors(doc: &mut Document, f: &mut dyn FnMut(&Color, Option<&str>) -> Color) {
for l in &mut doc.layers {
map_node_colors(Arc::make_mut(l), f);
}
for s in &mut doc.symbols {
map_node_colors(Arc::make_mut(&mut s.art), f);
}
}
/// Ids marked Overprint Black.
pub fn overprint_ids(doc: &Document) -> Vec<NodeId> {
doc.unknown
.get(OVERPRINT_KEY)
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| v.as_u64()).map(NodeId).collect())
.unwrap_or_default()
}
/// 100% black ink with no other process ink.
pub fn is_pure_black(c: &Color) -> bool {
match *c {
Color::Cmyk { c, m, y, k } => k >= 0.995 && c <= 0.005 && m <= 0.005 && y <= 0.005,
Color::Gray { k } => k >= 0.995,
Color::Rgb { .. } => false,
}
}
fn has_pure_black(n: &Node) -> bool {
n.appearance.items.iter().any(|it| match it {
AppearanceItem::Fill(l) => l.paint.color().is_some_and(|c| is_pure_black(&c)),
AppearanceItem::Stroke(l) => l.paint.color().is_some_and(|c| is_pure_black(&c)),
})
}
fn plate_color(doc: &Document, c: &Cms, proof: &ProofSetup, visible: &[String], color: &Color, swatch: Option<&str>) -> Color {
let ink = inks(doc, c, color, swatch, proof.intent);
let rgb = if visible.len() == 1 {
let name = &visible[0];
let v = match PROCESS_PLATES.iter().position(|p| p == name) {
Some(i) => ink.cmyk[i],
None => ink.spot.as_ref().filter(|(n, _)| n == name).map_or(0.0, |s| s.1),
};
[1.0 - v; 3]
} else {
let mut cmyk = ink.cmyk;
for (i, p) in PROCESS_PLATES.iter().enumerate() {
if !visible.iter().any(|v| v == p) {
cmyk[i] = 0.0;
}
}
let mut rgb = c.proof_cmyk_to_srgb(cmyk, proof);
if let Some((name, t)) = &ink.spot
&& visible.contains(name)
&& let Some(sc) = spot_swatch(doc, name)
{
let s = c.display_rgb(sc);
for i in 0..3 {
rgb[i] *= 1.0 - t * (1.0 - s[i]);
}
}
rgb
};
let [r, g, b] = c.srgb_to_rgb(rgb);
Color::Rgb { r, g, b }
}
/// The document as it should be drawn for these options (overprints, separations).
pub(crate) fn prepare<'a>(doc: &'a Document, opts: &RenderOptions) -> Cow<'a, Document> {
let seps = opts.proof.as_ref().and_then(|p| p.separations.as_ref().map(|s| (p, s)));
let overprint = opts.overprint_preview || seps.is_some();
let ids = if overprint { overprint_ids(doc) } else { vec![] };
if ids.is_empty() && seps.is_none() {
return Cow::Borrowed(doc);
}
let mut d = doc.clone();
for id in ids {
if let Some(n) = d.node_mut(id)
&& n.blend == BlendMode::Normal
&& has_pure_black(n)
{
n.blend = BlendMode::Multiply;
}
}
if let Some((proof, visible)) = seps {
let c = cms::active();
let src = doc.clone();
map_document_colors(&mut d, &mut |col, sw| plate_color(&src, &c, proof, visible, col, sw));
}
Cow::Owned(d)
}
/// Soft-proof the rendered (premultiplied RGBA8) pixels in place.
pub(crate) fn post(pixels: &mut [u8], opts: &RenderOptions) {
let Some(proof) = opts.proof.as_ref() else { return };
if proof.separations.is_some() || matches!(proof.target, ProofTarget::MonitorRgb | ProofTarget::Srgb) {
return;
}
let lut = cms::active().proof_lut(proof);
let mut last_in = [0u8; 4];
let mut last_out = [0u8; 4];
for px in pixels.chunks_exact_mut(4) {
let a = px[3];
if a == 0 {
continue;
}
if *px == last_in {
px.copy_from_slice(&last_out);
continue;
}
last_in.copy_from_slice(px);
let un = |c: u8| if a == 255 { c } else { ((c as u32 * 255 + a as u32 / 2) / a as u32).min(255) as u8 };
let out = lut.apply8([un(px[0]), un(px[1]), un(px[2])]);
for i in 0..3 {
px[i] = if a == 255 { out[i] } else { ((out[i] as u32 * a as u32 + 127) / 255) as u8 };
}
last_out.copy_from_slice(px);
}
}
/// View-level proof state (View → Proof Setup / Proof Colors / Overprint Preview, Separations
/// Preview panel). Process-wide: the app has one proof state for all windows.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ProofView {
/// View → Proof Setup.
pub setup: ProofSetup,
/// View → Proof Colors.
pub proof_colors: bool,
/// View → Overprint Preview.
pub overprint: bool,
/// Separations Preview: `Some(visible plates)` when on.
pub separations: Option<Vec<String>>,
}
static VIEW: RwLock<Option<ProofView>> = RwLock::new(None);
pub fn view() -> ProofView {
VIEW.read().unwrap_or_else(|e| e.into_inner()).clone().unwrap_or_default()
}
pub fn set_view(v: ProofView) {
*VIEW.write().unwrap_or_else(|e| e.into_inner()) = Some(v);
}
/// The proof to pass in [`RenderOptions::proof`] for the current view state.
pub fn active_proof() -> Option<ProofSetup> {
let v = view();
match v.separations {
Some(s) => Some(ProofSetup { separations: Some(s), ..v.setup }),
None if v.proof_colors => Some(ProofSetup { separations: None, ..v.setup }),
None => None,
}
}
/// Whether [`RenderOptions::overprint_preview`] should be on (Separations Preview implies it).
pub fn overprint_preview_on() -> bool {
let v = view();
v.overprint || v.separations.is_some()
}

View File

@ -29,6 +29,8 @@ pub(crate) fn export(doc: &Document, opts: &ExportOptions) -> String {
used_ids: HashSet::new(),
names: HashMap::new(),
depth: 1,
patterns: HashMap::new(),
pattern_nest: 0,
};
w.assign_name_ids();
for l in &doc.layers {
@ -83,6 +85,9 @@ struct Writer<'a> {
used_ids: HashSet<String>,
names: HashMap<NodeId, String>,
depth: usize,
/// `<pattern>` def ids by pattern name + placement.
patterns: HashMap<String, String>,
pattern_nest: u32,
}
pub(crate) fn blend_css(b: BlendMode) -> &'static str {
@ -258,12 +263,57 @@ impl Writer<'_> {
fn paint(&mut self, p: &Paint, bounds: Option<Rect>) -> String {
match p {
Paint::None | Paint::Pattern { .. } => "none".into(),
Paint::None => "none".into(),
Paint::Pattern { pattern, xf } => match self.pattern_def(pattern, *xf) {
Some(id) => format!("url(#{id})"),
None => "none".into(),
},
Paint::Solid { color, .. } => color.to_hex(),
Paint::Gradient(g) => format!("url(#{})", self.gradient_def(g, bounds.unwrap_or(Rect::new(0.0, 0.0, 1.0, 1.0)))),
}
}
/// A `<pattern>` def for pattern `name` placed by `xf`: one period (super-tile) of the tiling,
/// with every instance that reaches into it.
fn pattern_def(&mut self, name: &str, xf: Affine) -> Option<String> {
let doc = self.doc;
let def = doc.pattern(name)?;
let m = self.xf * xf;
let key = format!("{name}|{:?}", m.as_coeffs());
if let Some(id) = self.patterns.get(&key) {
return Some(id.clone());
}
if self.pattern_nest > 4 {
return None;
}
let id = self.fresh_id("pattern");
self.patterns.insert(key, id.clone());
let (pw, ph) = def.period();
let (saved_body, saved_xf, saved_depth) = (std::mem::take(&mut self.body), self.xf, self.depth);
self.depth = 3;
self.pattern_nest += 1;
for o in def.offsets_covering(Rect::new(0.0, 0.0, pw, ph)) {
self.xf = def.instance_xf(o);
for a in &def.art {
self.node(a);
}
}
self.pattern_nest -= 1;
let content = std::mem::replace(&mut self.body, saved_body);
self.xf = saved_xf;
self.depth = saved_depth;
let head = format!(
"<pattern id=\"{id}\" patternUnits=\"userSpaceOnUse\" width=\"{}\" height=\"{}\" patternTransform=\"{}\">",
self.num(pw),
self.num(ph),
self.matrix(m)
);
self.def(1, &head);
self.defs.push_str(&content);
self.def(1, "</pattern>");
Some(id)
}
fn gradient_def(&mut self, g: &GradientPaint, bounds: Rect) -> String {
let mut geom = g.resolve(bounds);
geom.transform(self.xf);
@ -486,7 +536,7 @@ impl Writer<'_> {
out.push((self.path_d(&p, self.xf), FillRule::NonZero));
}
NodeKind::Text(_) | NodeKind::SymbolInstance { .. } => {}
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) => {
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
let g = drawcraft_doc::live::expand_deep(n, None);
self.clip_shapes(&g, out);
}
@ -585,7 +635,7 @@ impl Writer<'_> {
self.xf = saved;
}
// Live blends/envelopes/meshes export their evaluated (expanded) form.
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) => {
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
let g = drawcraft_doc::live::expand_deep(n, None);
self.node(&g);
}

166
crates/tools/src/distort.rs Normal file
View File

@ -0,0 +1,166 @@
//! Distortion tools: Width (Shift+W), the Liquify family (Warp Shift+R, Twirl, Pucker, Bloat,
//! Scallop, Crystallize, Wrinkle), Puppet Warp, Perspective Grid (Shift+P) and Perspective
//! Selection (Shift+V).
//!
//! Like every tool they only emit actions. The geometry kernels the engine commands run live here
//! too (so tools, engine and tests share one implementation):
//! - [`liquify`]: deterministic brush deformation of anchors and handles with adaptive subdivision;
//! - [`arap`]: As-Rigid-As-Possible mesh deformation (Igarashi, Moscovich & Hughes 2005) with a
//! small banded Cholesky solver;
//! - [`perspective`]: the perspective grid model (one/two/three-point) and plane homographies;
//! - [`pathutil`]: arc-length helpers for width points.
pub mod arap;
pub mod liquify;
pub mod pathutil;
pub mod perspective;
mod puppet;
mod width;
use std::sync::Arc;
use drawcraft_color::Paint;
use drawcraft_doc::{Appearance, AppearanceItem, Node, NodeKind};
use drawcraft_geom::{Affine, Point, Vec2};
use crate::Tool;
pub use liquify::LiquifyTool;
pub use perspective::{PerspectiveGridTool, PerspectiveSelectionTool};
pub use puppet::{PuppetWarpTool, mesh_for, mesh_input};
pub use width::WidthTool;
/// Create a distortion tool by id (None = not one of ours).
pub fn create(id: &str) -> Option<Box<dyn Tool>> {
Some(match id {
"width" => Box::new(WidthTool::default()),
"warp" | "twirl" | "pucker" | "bloat" | "scallop" | "crystallize" | "wrinkle" => Box::new(LiquifyTool::new(id)),
"puppetWarp" => Box::new(PuppetWarpTool::default()),
"perspectiveGrid" => Box::new(PerspectiveGridTool::default()),
"perspectiveSelection" => Box::new(PerspectiveSelectionTool::default()),
_ => return None,
})
}
/// Apply a point map to every anchor and handle of `n` and its descendants. Gradient vectors are
/// mapped too; objects without editable points (text, images, symbols, meshes, live objects) get
/// the map's affine approximation at their centre. Live shapes become plain paths.
pub fn warp_node_with(n: &mut Node, f: &dyn Fn(Point) -> Point) {
warp_paints(&mut n.appearance, f);
match &mut n.kind {
NodeKind::Path { path, live, .. } => {
*live = None;
for sp in &mut path.subpaths {
for a in &mut sp.anchors {
a.p = f(a.p);
a.h_in = f(a.h_in);
a.h_out = f(a.h_out);
}
}
}
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } | NodeKind::Blend { children, .. } => {
for c in children.iter_mut() {
warp_node_with(Arc::make_mut(c), f);
}
}
_ => {
if let Some(b) = n.geometric_bounds() {
let a = affine_near(f, b.center(), (b.width().max(b.height()) * 0.05).max(0.5));
n.transform(a, false);
}
}
}
}
fn warp_paints(ap: &mut Appearance, f: &dyn Fn(Point) -> Point) {
for it in &mut ap.items {
let p = match it {
AppearanceItem::Fill(fl) => &mut fl.paint,
AppearanceItem::Stroke(s) => &mut s.paint,
};
if let Paint::Gradient(g) = p
&& let Some(geom) = &mut g.geom
{
geom.start = f(geom.start);
geom.end = f(geom.end);
}
}
}
/// The affine map that best matches `f` near `p` (finite differences with step `eps`).
pub fn affine_near(f: &dyn Fn(Point) -> Point, p: Point, eps: f64) -> Affine {
let o = f(p);
let ex = (f(p + Vec2::new(eps, 0.0)) - f(p - Vec2::new(eps, 0.0))) / (2.0 * eps);
let ey = (f(p + Vec2::new(0.0, eps)) - f(p - Vec2::new(0.0, eps))) / (2.0 * eps);
let lin = Affine::new([ex.x, ex.y, ey.x, ey.y, 0.0, 0.0]);
Affine::translate(o.to_vec2()) * lin * Affine::translate(-p.to_vec2())
}
/// Every anchor and handle position in `n` (for meshes and bounds).
pub fn collect_points(n: &Node, out: &mut Vec<Point>) {
n.walk(&mut |c| match &c.kind {
NodeKind::Path { path, .. } => {
for sp in &path.subpaths {
for a in &sp.anchors {
out.extend([a.p, a.h_in, a.h_out]);
}
}
}
NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Compound { .. } | NodeKind::Blend { .. } => {}
_ => {
if let Some(b) = c.geometric_bounds() {
out.extend([Point::new(b.x0, b.y0), Point::new(b.x1, b.y1)]);
}
}
});
}
/// Selection blue used by the distortion tool overlays.
pub(crate) const BLUE: [u8; 3] = [0x4a, 0x7c, 0xff];
/// A small diamond polygon at `p` (half-diagonal `r`).
pub(crate) fn diamond(p: Point, r: f64) -> drawcraft_geom::BezPath {
crate::xform::polygon(&[Point::new(p.x, p.y - r), Point::new(p.x + r, p.y), Point::new(p.x, p.y + r), Point::new(p.x - r, p.y)], true)
}
/// A circle / ellipse outline for brush feedback.
pub(crate) fn ellipse_path(c: Point, rx: f64, ry: f64, angle_deg: f64) -> drawcraft_geom::BezPath {
use drawcraft_geom::Shape;
let e = drawcraft_geom::kurbo::Ellipse::new(c, (rx.max(0.01), ry.max(0.01)), angle_deg.to_radians());
e.to_path(0.05)
}
#[cfg(test)]
mod tests {
use super::*;
use drawcraft_geom::{PathData, Rect, shapes};
#[test]
fn create_covers_all_distort_tools() {
for id in ["width", "warp", "twirl", "pucker", "bloat", "scallop", "crystallize", "wrinkle", "puppetWarp", "perspectiveGrid", "perspectiveSelection"] {
assert_eq!(create(id).map(|t| t.id()), Some(id));
}
assert!(create("pen").is_none());
}
#[test]
fn warp_node_maps_all_points_and_drops_live_shape() {
let mut n = Node::path(drawcraft_doc::NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
warp_node_with(&mut n, &|p| Point::new(p.x * 2.0, p.y + 1.0));
let b = n.geometric_bounds().unwrap();
assert_eq!((b.x0, b.y0, b.x1, b.y1), (0.0, 1.0, 20.0, 11.0));
if let NodeKind::Path { live, .. } = &n.kind {
assert!(live.is_none());
}
let _ = PathData::default();
}
#[test]
fn affine_near_recovers_affine_maps() {
let a = Affine::new([2.0, 0.5, -0.3, 1.5, 10.0, -4.0]);
let got = affine_near(&|p| a * p, Point::new(3.0, 7.0), 0.5);
for (x, y) in a.as_coeffs().iter().zip(got.as_coeffs()) {
assert!((x - y).abs() < 1e-9);
}
}
}

View File

@ -0,0 +1,573 @@
//! As-Rigid-As-Possible shape manipulation (Igarashi, Moscovich & Hughes, SIGGRAPH 2005),
//! implemented from the paper.
//!
//! - The mesh is a regular grid of triangles over the artwork: grid cells are kept when their
//! centre is inside the filled outline or near any path (so separate limbs stay separate).
//! - Step 1 (similarity): every triangle vertex is expressed in the local frame of the opposite
//! edge, `v2 = v0 + x·(v1 − v0) + y·R90(v1 − v0)`; the quadratic error of those relations over
//! all triangles (rotation + uniform scale free) plus soft pin constraints is minimised.
//! - Step 2 (scale adjustment): each triangle's rest shape is rigidly fitted (optimal 2D rotation)
//! to its step-1 result, then the vertices are solved so every edge vector matches its fitted
//! triangle's, which removes the scaling step 1 allows.
//! - Pins are arbitrary points (barycentric combinations of a triangle's vertices), so both steps
//! use weighted soft constraints. Systems are symmetric positive definite and banded (vertices
//! are numbered row by row), solved with a banded Cholesky factorisation written here.
//! - Artwork points (anchors and handles) follow the mesh through barycentric coordinates in
//! their rest triangle.
use drawcraft_geom::kurbo::ParamCurveNearest;
use drawcraft_geom::{BezPath, Point, Rect, Shape, Vec2};
/// Banded symmetric positive definite matrix (lower band stored row by row).
#[derive(Clone, Debug)]
pub struct BandMatrix {
n: usize,
bw: usize,
/// Row i holds columns i-bw..=i at offsets 0..=bw.
a: Vec<f64>,
}
impl BandMatrix {
pub fn new(n: usize, bw: usize) -> Self {
Self { n, bw, a: vec![0.0; n * (bw + 1)] }
}
pub fn len(&self) -> usize {
self.n
}
pub fn is_empty(&self) -> bool {
self.n == 0
}
fn idx(&self, i: usize, j: usize) -> usize {
// j <= i, i - j <= bw
i * (self.bw + 1) + (j + self.bw - i)
}
/// Add `v` at (i, j) (and implicitly (j, i)); diagonal entries are added once.
pub fn add(&mut self, i: usize, j: usize, v: f64) {
let (i, j) = if i >= j { (i, j) } else { (j, i) };
debug_assert!(i - j <= self.bw, "outside band");
let k = self.idx(i, j);
self.a[k] += v;
}
pub fn get(&self, i: usize, j: usize) -> f64 {
let (i, j) = if i >= j { (i, j) } else { (j, i) };
if i - j > self.bw { 0.0 } else { self.a[self.idx(i, j)] }
}
/// In-place Cholesky factorisation (L·Lᵀ). Returns false if the matrix isn't positive definite.
pub fn factor(&mut self) -> bool {
let (n, bw) = (self.n, self.bw);
for i in 0..n {
let j0 = i.saturating_sub(bw);
for j in j0..=i {
let mut s = self.a[self.idx(i, j)];
let k0 = j0.max(j.saturating_sub(bw));
for k in k0..j {
s -= self.a[self.idx(i, k)] * self.a[self.idx(j, k)];
}
if i == j {
if s <= 0.0 || !s.is_finite() {
return false;
}
let k = self.idx(i, i);
self.a[k] = s.sqrt();
} else {
let k = self.idx(i, j);
self.a[k] = s / self.a[self.idx(j, j)];
}
}
}
true
}
/// Solve with a factored matrix.
pub fn solve(&self, b: &mut [f64]) {
let (n, bw) = (self.n, self.bw);
for i in 0..n {
let mut s = b[i];
for k in i.saturating_sub(bw)..i {
s -= self.a[self.idx(i, k)] * b[k];
}
b[i] = s / self.a[self.idx(i, i)];
}
for i in (0..n).rev() {
let mut s = b[i];
for k in i + 1..(i + bw + 1).min(n) {
s -= self.a[self.idx(k, i)] * b[k];
}
b[i] = s / self.a[self.idx(i, i)];
}
}
}
/// A triangle mesh on a regular grid.
#[derive(Clone, Debug)]
pub struct Mesh {
pub verts: Vec<Point>,
pub tris: Vec<[usize; 3]>,
origin: Point,
cell: f64,
nx: usize,
ny: usize,
/// Per grid cell: its two triangles (None = cell not part of the mesh).
cells: Vec<Option<[usize; 2]>>,
}
/// Mesh construction options.
#[derive(Clone, Copy, Debug)]
pub struct MeshOptions {
/// Cells along the longer side.
pub resolution: usize,
/// Expand Mesh: extra reach around the outline, points.
pub expand: f64,
}
impl Default for MeshOptions {
fn default() -> Self {
Self { resolution: 20, expand: 3.0 }
}
}
impl Mesh {
/// Build a mesh covering `bounds` restricted to the region of `outlines` (filled interiors of
/// closed subpaths plus a band around every path). With no outlines, the whole bounds.
pub fn build(outlines: &[BezPath], bounds: Rect, opt: MeshOptions) -> Mesh {
let side = bounds.width().max(bounds.height()).max(1e-3);
let cell = (side / opt.resolution.max(1) as f64).max(1e-3);
let pad = opt.expand.max(0.0) + cell * 0.01;
let b = bounds.inflate(pad, pad);
let nx = ((b.width() / cell).ceil() as usize).max(1);
let ny = ((b.height() / cell).ceil() as usize).max(1);
let origin = Point::new(b.x0, b.y0);
let reach = cell * 0.75 + opt.expand.max(0.0);
let keep = |c: Point| -> bool {
if outlines.is_empty() {
return true;
}
outlines.iter().any(|o| {
let bb = o.bounding_box().inflate(reach, reach);
if !bb.contains(c) {
return false;
}
if closed_winding(o, c) != 0 {
return true;
}
o.segments().any(|s| s.nearest(c, 1e-3).distance_sq <= reach * reach)
})
};
let mut vid = vec![usize::MAX; (nx + 1) * (ny + 1)];
let mut verts = vec![];
let mut kept = vec![false; nx * ny];
for j in 0..ny {
for i in 0..nx {
let c = Point::new(origin.x + (i as f64 + 0.5) * cell, origin.y + (j as f64 + 0.5) * cell);
kept[j * nx + i] = keep(c);
}
}
// Number vertices row by row (keeps the systems banded).
for j in 0..=ny {
for i in 0..=nx {
let used = [(i.wrapping_sub(1), j.wrapping_sub(1)), (i, j.wrapping_sub(1)), (i.wrapping_sub(1), j), (i, j)]
.iter()
.any(|&(ci, cj)| ci < nx && cj < ny && kept[cj * nx + ci]);
if used {
vid[j * (nx + 1) + i] = verts.len();
verts.push(Point::new(origin.x + i as f64 * cell, origin.y + j as f64 * cell));
}
}
}
let mut tris = vec![];
let mut cells = vec![None; nx * ny];
for j in 0..ny {
for i in 0..nx {
if !kept[j * nx + i] {
continue;
}
let v = |di: usize, dj: usize| vid[(j + dj) * (nx + 1) + i + di];
let (v00, v10, v01, v11) = (v(0, 0), v(1, 0), v(0, 1), v(1, 1));
let t0 = tris.len();
tris.push([v00, v10, v11]);
tris.push([v00, v11, v01]);
cells[j * nx + i] = Some([t0, t0 + 1]);
}
}
Mesh { verts, tris, origin, cell, nx, ny, cells }
}
/// Band width (in vertices) of the vertex adjacency.
fn vertex_band(&self) -> usize {
self.tris.iter().map(|t| t.iter().max().unwrap() - t.iter().min().unwrap()).max().unwrap_or(0)
}
/// The triangle and barycentric coordinates of `p` (extrapolated from the nearest mesh cell
/// when `p` is outside the mesh).
pub fn locate(&self, p: Point) -> Option<(usize, [f64; 3])> {
let fi = ((p.x - self.origin.x) / self.cell).floor();
let fj = ((p.y - self.origin.y) / self.cell).floor();
let inside = fi >= 0.0 && fj >= 0.0 && (fi as usize) < self.nx && (fj as usize) < self.ny;
let cell = if inside && self.cells[fj as usize * self.nx + fi as usize].is_some() {
fj as usize * self.nx + fi as usize
} else {
// Nearest kept cell centre.
let mut best = None;
for (k, c) in self.cells.iter().enumerate() {
if c.is_none() {
continue;
}
let cc = Point::new(self.origin.x + ((k % self.nx) as f64 + 0.5) * self.cell, self.origin.y + ((k / self.nx) as f64 + 0.5) * self.cell);
let d = cc.distance_squared(p);
if best.is_none_or(|(bd, _)| d < bd) {
best = Some((d, k));
}
}
best?.1
};
let [ta, tb] = self.cells[cell]?;
// Pick the triangle on p's side of the diagonal v00–v11.
let v00 = self.verts[self.tris[ta][0]];
let local = (p - v00) / self.cell;
let t = if local.x >= local.y { ta } else { tb };
Some((t, self.bary(t, p)))
}
fn bary(&self, t: usize, p: Point) -> [f64; 3] {
let [a, b, c] = self.tris[t].map(|i| self.verts[i]);
let (v0, v1, v2) = (b - a, c - a, p - a);
let den = v0.cross(v1);
if den.abs() < 1e-18 {
return [1.0, 0.0, 0.0];
}
let l1 = v2.cross(v1) / den;
let l2 = v0.cross(v2) / den;
[1.0 - l1 - l2, l1, l2]
}
/// Map a rest-space point through deformed vertex positions.
pub fn map(&self, deformed: &[Point], p: Point) -> Point {
match self.locate(p) {
Some((t, w)) => {
let [a, b, c] = self.tris[t].map(|i| deformed[i].to_vec2());
(a * w[0] + b * w[1] + c * w[2]).to_point()
}
None => p,
}
}
/// Signed area of triangle `t` for the given vertex positions.
pub fn tri_area(&self, pos: &[Point], t: usize) -> f64 {
let [a, b, c] = self.tris[t].map(|i| pos[i]);
(b - a).cross(c - a) / 2.0
}
}
/// Winding number of the closed subpaths of `o` at `p` (open subpaths ignored).
fn closed_winding(o: &BezPath, p: Point) -> i32 {
let mut total = 0;
let mut cur = BezPath::new();
let mut closed = false;
let flush = |cur: &mut BezPath, closed: bool, total: &mut i32| {
if closed && !cur.elements().is_empty() {
*total += cur.winding(p);
}
*cur = BezPath::new();
};
for el in o.elements() {
match el {
drawcraft_geom::PathEl::MoveTo(_) => {
flush(&mut cur, closed, &mut total);
closed = false;
cur.push(*el);
}
drawcraft_geom::PathEl::ClosePath => {
closed = true;
cur.push(*el);
}
e => cur.push(*e),
}
}
flush(&mut cur, closed, &mut total);
total
}
/// Weight of the soft pin constraints relative to the shape terms.
const PIN_WEIGHT: f64 = 1.0e5;
/// Tikhonov regularisation toward the rest pose (keeps pin-less islands in place).
const REG: f64 = 1.0e-7;
/// A pin in rest space and where it should go.
#[derive(Clone, Copy, Debug)]
pub struct Pin {
pub rest: Point,
pub target: Point,
}
/// Run both ARAP steps. Returns the deformed vertex positions (the rest mesh if fewer than one
/// pin lands on it or a system is singular).
pub fn deform(mesh: &Mesh, pins: &[Pin]) -> Vec<Point> {
let n = mesh.verts.len();
if n == 0 || pins.is_empty() {
return mesh.verts.clone();
}
let located: Vec<(usize, [f64; 3], Point)> = pins.iter().filter_map(|p| mesh.locate(p.rest).map(|(t, w)| (t, w, p.target))).collect();
let vb = mesh.vertex_band();
// ---- step 1: similarity-invariant error, 2n unknowns interleaved (x0, y0, x1, y1, ...).
let mut g = BandMatrix::new(2 * n, 2 * vb + 1);
for tri in &mesh.tris {
for k in 0..3 {
let (a, b, c) = (tri[k], tri[(k + 1) % 3], tri[(k + 2) % 3]);
let (pa, pb, pc) = (mesh.verts[a], mesh.verts[b], mesh.verts[c]);
let e = pb - pa;
let l2 = e.hypot2();
if l2 < 1e-18 {
continue;
}
let d = pc - pa;
let x = d.dot(e) / l2;
let y = d.dot(Vec2::new(-e.y, e.x)) / l2;
// Residual coefficients over (ax, ay, bx, by, cx, cy); see module docs.
let vars = [2 * a, 2 * a + 1, 2 * b, 2 * b + 1, 2 * c, 2 * c + 1];
let rx = [x - 1.0, -y, -x, y, 1.0, 0.0];
let ry = [y, x - 1.0, -y, -x, 0.0, 1.0];
for r in [rx, ry] {
for i in 0..6 {
for j in 0..=i {
let v = r[i] * r[j];
if v != 0.0 {
g.add(vars[i], vars[j], v);
}
}
}
}
}
}
let mut rhs = vec![0.0; 2 * n];
for (t, w, target) in &located {
let tri = mesh.tris[*t];
for comp in 0..2 {
for i in 0..3 {
for j in 0..=i {
g.add(2 * tri[i] + comp, 2 * tri[j] + comp, PIN_WEIGHT * w[i] * w[j]);
}
rhs[2 * tri[i] + comp] += PIN_WEIGHT * w[i] * if comp == 0 { target.x } else { target.y };
}
}
}
for (i, v) in mesh.verts.iter().enumerate() {
g.add(2 * i, 2 * i, REG);
g.add(2 * i + 1, 2 * i + 1, REG);
rhs[2 * i] += REG * v.x;
rhs[2 * i + 1] += REG * v.y;
}
if !g.factor() {
return mesh.verts.clone();
}
g.solve(&mut rhs);
let step1: Vec<Point> = (0..n).map(|i| Point::new(rhs[2 * i], rhs[2 * i + 1])).collect();
// ---- step 2: fit rigid triangles, then match edge vectors (x and y separable).
let mut l = BandMatrix::new(n, vb);
let mut bx = vec![0.0; n];
let mut by = vec![0.0; n];
for tri in &mesh.tris {
let rest = tri.map(|i| mesh.verts[i]);
let cur = tri.map(|i| step1[i]);
let rc = centroid(&rest);
let cc = centroid(&cur);
let (mut sc, mut ss) = (0.0, 0.0);
for k in 0..3 {
let (r, c) = (rest[k] - rc, cur[k] - cc);
sc += r.dot(c);
ss += r.cross(c);
}
let th = ss.atan2(sc);
let (s, co) = th.sin_cos();
let fit = rest.map(|r| {
let d = r - rc;
cc + Vec2::new(d.x * co - d.y * s, d.x * s + d.y * co)
});
for k in 0..3 {
let (i, j) = (tri[k], tri[(k + 1) % 3]);
let e = fit[k] - fit[(k + 1) % 3];
// (v_i - v_j - e)^2
l.add(i, i, 1.0);
l.add(j, j, 1.0);
l.add(i, j, -1.0);
bx[i] += e.x;
bx[j] -= e.x;
by[i] += e.y;
by[j] -= e.y;
}
}
for (t, w, target) in &located {
let tri = mesh.tris[*t];
for i in 0..3 {
for j in 0..=i {
l.add(tri[i], tri[j], PIN_WEIGHT * w[i] * w[j]);
}
bx[tri[i]] += PIN_WEIGHT * w[i] * target.x;
by[tri[i]] += PIN_WEIGHT * w[i] * target.y;
}
}
for (i, v) in step1.iter().enumerate() {
l.add(i, i, REG);
bx[i] += REG * v.x;
by[i] += REG * v.y;
}
if !l.factor() {
return step1;
}
l.solve(&mut bx);
l.solve(&mut by);
(0..n).map(|i| Point::new(bx[i], by[i])).collect()
}
fn centroid(p: &[Point; 3]) -> Point {
Point::new((p[0].x + p[1].x + p[2].x) / 3.0, (p[0].y + p[1].y + p[2].y) / 3.0)
}
/// Pick `count` well-spread pin positions inside the mesh (farthest-point sampling over the
/// kept cell centres, starting from the one nearest the centroid).
pub fn auto_pins(mesh: &Mesh, count: usize) -> Vec<Point> {
let centres: Vec<Point> = mesh
.cells
.iter()
.enumerate()
.filter(|(_, c)| c.is_some())
.map(|(k, _)| Point::new(mesh.origin.x + ((k % mesh.nx) as f64 + 0.5) * mesh.cell, mesh.origin.y + ((k / mesh.nx) as f64 + 0.5) * mesh.cell))
.collect();
if centres.is_empty() {
return vec![];
}
let c = centres.iter().fold(Vec2::ZERO, |a, p| a + p.to_vec2()) / centres.len() as f64;
let first = *centres.iter().min_by(|a, b| a.distance_squared(c.to_point()).total_cmp(&b.distance_squared(c.to_point()))).unwrap();
let mut out = vec![first];
while out.len() < count.min(centres.len()) {
let next = centres
.iter()
.max_by(|a, b| {
let da = out.iter().map(|o| o.distance_squared(**a)).fold(f64::MAX, f64::min);
let db = out.iter().map(|o| o.distance_squared(**b)).fold(f64::MAX, f64::min);
da.total_cmp(&db)
})
.copied()
.unwrap();
out.push(next);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use drawcraft_geom::shapes;
#[test]
fn band_cholesky_solves_spd_systems() {
// Tridiagonal [4 1; 1 4 1; ...] vs a dense reference.
let n = 12;
let mut m = BandMatrix::new(n, 1);
for i in 0..n {
m.add(i, i, 4.0);
if i > 0 {
m.add(i, i - 1, 1.0);
}
}
let x: Vec<f64> = (0..n).map(|i| i as f64 * 0.5 - 2.0).collect();
let mut b: Vec<f64> = (0..n).map(|i| (0..n).map(|j| m.get(i, j) * x[j]).sum()).collect();
assert!(m.factor());
m.solve(&mut b);
for i in 0..n {
assert!((b[i] - x[i]).abs() < 1e-12);
}
let mut bad = BandMatrix::new(2, 1);
bad.add(0, 0, 1.0);
bad.add(1, 0, 2.0);
bad.add(1, 1, 1.0);
assert!(!bad.factor(), "indefinite");
}
fn rect_mesh() -> Mesh {
let r = Rect::new(0.0, 0.0, 200.0, 100.0);
Mesh::build(&[shapes::rectangle(r).to_bezpath()], r, MeshOptions { resolution: 16, expand: 0.0 })
}
#[test]
fn mesh_covers_shape_and_locates_points() {
let m = rect_mesh();
assert!(!m.tris.is_empty());
for p in [Point::new(10.0, 10.0), Point::new(150.0, 70.0), Point::new(199.0, 99.0)] {
let (t, w) = m.locate(p).unwrap();
assert!(w.iter().all(|v| *v >= -1e-9), "{w:?}");
assert!((m.map(&m.verts, p) - p).hypot() < 1e-9);
let _ = t;
}
// An L-shaped outline drops the empty corner cells.
let mut l = BezPath::new();
l.move_to((0.0, 0.0));
l.line_to((100.0, 0.0));
l.line_to((100.0, 20.0));
l.line_to((20.0, 20.0));
l.line_to((20.0, 100.0));
l.line_to((0.0, 100.0));
l.close_path();
let lm = Mesh::build(&[l], Rect::new(0.0, 0.0, 100.0, 100.0), MeshOptions { resolution: 10, expand: 0.0 });
assert!(lm.tris.len() < m.tris.len() / 2, "{} tris", lm.tris.len());
assert!(lm.cells.iter().filter(|c| c.is_some()).count() < 60);
}
#[test]
fn pins_at_rest_leave_the_mesh_unchanged() {
let m = rect_mesh();
let pins: Vec<Pin> = [(20.0, 50.0), (180.0, 50.0), (100.0, 20.0)].iter().map(|&(x, y)| Pin { rest: Point::new(x, y), target: Point::new(x, y) }).collect();
let d = deform(&m, &pins);
let err = m.verts.iter().zip(&d).map(|(a, b)| a.distance(*b)).fold(0.0, f64::max);
assert!(err < 1e-4, "{err}");
}
#[test]
fn rigid_motion_is_reproduced_exactly() {
let m = rect_mesh();
let rot = |p: Point| {
let (s, c) = 0.5f64.sin_cos();
Point::new(p.x * c - p.y * s + 30.0, p.x * s + p.y * c - 10.0)
};
let pins: Vec<Pin> = [(20.0, 50.0), (180.0, 50.0), (100.0, 90.0)].iter().map(|&(x, y)| Pin { rest: Point::new(x, y), target: rot(Point::new(x, y)) }).collect();
let d = deform(&m, &pins);
let err = m.verts.iter().zip(&d).map(|(a, b)| rot(*a).distance(*b)).fold(0.0, f64::max);
assert!(err < 1e-2, "{err}");
}
#[test]
fn pins_are_satisfied_and_free_regions_stay_rigid() {
let m = rect_mesh();
// Hold the left end, lift the right end.
let pins = vec![
Pin { rest: Point::new(10.0, 30.0), target: Point::new(10.0, 30.0) },
Pin { rest: Point::new(10.0, 70.0), target: Point::new(10.0, 70.0) },
Pin { rest: Point::new(190.0, 50.0), target: Point::new(180.0, -30.0) },
];
let d = deform(&m, &pins);
for p in &pins {
let got = m.map(&d, p.rest);
assert!(got.distance(p.target) < 0.05, "pin {:?} → {got:?}", p.target);
}
// Area is preserved (as-rigid-as-possible): total within a few percent, no flipped triangles.
let (a0, a1): (f64, f64) = (0..m.tris.len()).map(|t| (m.tri_area(&m.verts, t), m.tri_area(&d, t))).fold((0.0, 0.0), |s, v| (s.0 + v.0, s.1 + v.1));
assert!((a1 / a0 - 1.0).abs() < 0.05, "area ratio {}", a1 / a0);
assert!((0..m.tris.len()).all(|t| m.tri_area(&d, t) * m.tri_area(&m.verts, t) > 0.0));
// Each triangle stays nearly congruent: edge lengths change by < 25%.
let worst = m
.tris
.iter()
.flat_map(|t| (0..3).map(move |k| (t[k], t[(k + 1) % 3])))
.map(|(i, j)| (d[i].distance(d[j]) / m.verts[i].distance(m.verts[j]) - 1.0).abs())
.fold(0.0, f64::max);
assert!(worst < 0.25, "edge stretch {worst}");
}
#[test]
fn auto_pins_are_spread_inside_the_shape() {
let m = rect_mesh();
let p = auto_pins(&m, 3);
assert_eq!(p.len(), 3);
assert!(p.iter().all(|q| Rect::new(0.0, 0.0, 200.0, 100.0).contains(*q)));
assert!(p[1].distance(p[2]) > 100.0);
}
}

View File

@ -0,0 +1,638 @@
//! Liquify tools: Warp (Shift+R), Twirl, Pucker, Bloat, Scallop, Crystallize, Wrinkle.
//!
//! A drag previews `object.liquify {tool, points, width, height, angle, intensity, detail,
//! simplify, rate?, complexity?, horizontal?, vertical?, ids?}` with every pointer sample so far;
//! the engine re-applies the whole stroke to the interaction snapshot with [`apply_stroke`], so the
//! result is a pure function of the parameters (replay reproduces it exactly).
//!
//! The kernel resamples the stroke into dabs spaced a fraction of the brush apart. For each dab it
//! first subdivides the segments the brush touches (Detail: more anchors where the brush passes,
//! lines become curves so the result stays smooth), then moves anchors and handles by the tool's
//! displacement field weighted by a smooth falloff `(1 − r²)²` inside the (elliptical, rotated)
//! brush. Noise for Scallop/Crystallize/Wrinkle is a hash of the dab and anchor indices.
use drawcraft_geom::kurbo::ParamCurveArclen;
use drawcraft_geom::{Anchor, AnchorKind, PathData, Point, Rect, SubPath, Vec2};
use serde_json::{Value, json};
use super::ellipse_path;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LiquifyKind {
Warp,
Twirl,
Pucker,
Bloat,
Scallop,
Crystallize,
Wrinkle,
}
impl LiquifyKind {
pub fn parse(s: &str) -> Option<Self> {
Some(match s {
"warp" => Self::Warp,
"twirl" => Self::Twirl,
"pucker" => Self::Pucker,
"bloat" => Self::Bloat,
"scallop" => Self::Scallop,
"crystallize" => Self::Crystallize,
"wrinkle" => Self::Wrinkle,
_ => return None,
})
}
pub fn id(self) -> &'static str {
match self {
Self::Warp => "warp",
Self::Twirl => "twirl",
Self::Pucker => "pucker",
Self::Bloat => "bloat",
Self::Scallop => "scallop",
Self::Crystallize => "crystallize",
Self::Wrinkle => "wrinkle",
}
}
pub fn label(self) -> &'static str {
match self {
Self::Warp => "Warp",
Self::Twirl => "Twirl",
Self::Pucker => "Pucker",
Self::Bloat => "Bloat",
Self::Scallop => "Scallop",
Self::Crystallize => "Crystallize",
Self::Wrinkle => "Wrinkle",
}
}
}
/// Brush and tool options (Warp Tool Options and friends).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LiquifyParams {
pub kind: LiquifyKind,
/// Brush width / height in points and rotation in degrees.
pub width: f64,
pub height: f64,
pub angle: f64,
/// 0..1.
pub intensity: f64,
/// 1..10: anchor density added where the brush passes.
pub detail: f64,
/// 0..100: removal of redundant flat anchors afterwards.
pub simplify: f64,
/// Twirl rate, degrees (−180..180).
pub rate: f64,
/// Scallop/Crystallize/Wrinkle complexity (0..15).
pub complexity: f64,
/// Wrinkle horizontal / vertical amount (0..1).
pub horizontal: f64,
pub vertical: f64,
pub affect_anchors: bool,
pub affect_in: bool,
pub affect_out: bool,
}
impl LiquifyParams {
pub fn new(kind: LiquifyKind) -> Self {
Self {
kind,
width: 100.0,
height: 100.0,
angle: 0.0,
intensity: 0.5,
detail: 2.0,
simplify: 50.0,
rate: 40.0,
complexity: 1.0,
horizontal: 0.0,
vertical: 1.0,
affect_anchors: true,
affect_in: true,
affect_out: true,
}
}
/// Parse command parameters (percentages above 1 are accepted for intensity/horizontal/vertical).
pub fn from_json(p: &Value) -> Option<Self> {
let kind = LiquifyKind::parse(p.get("tool")?.as_str()?)?;
let mut s = Self::new(kind);
let f = |k: &str| p.get(k).and_then(Value::as_f64);
let pct = |v: f64| if v > 1.0 { v / 100.0 } else { v };
if let Some(d) = f("diameter") {
s.width = d;
s.height = d;
}
s.width = f("width").unwrap_or(s.width).clamp(0.5, 10_000.0);
s.height = f("height").unwrap_or(s.height).clamp(0.5, 10_000.0);
s.angle = f("angle").unwrap_or(0.0);
s.intensity = pct(f("intensity").unwrap_or(0.5)).clamp(0.0, 1.0);
s.detail = f("detail").unwrap_or(2.0).clamp(1.0, 10.0);
s.simplify = f("simplify").unwrap_or(50.0).clamp(0.0, 100.0);
s.rate = f("rate").unwrap_or(40.0).clamp(-180.0, 180.0);
s.complexity = f("complexity").unwrap_or(1.0).clamp(0.0, 15.0);
s.horizontal = pct(f("horizontal").unwrap_or(0.0)).clamp(0.0, 1.0);
s.vertical = pct(f("vertical").unwrap_or(1.0)).clamp(0.0, 1.0);
let b = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(true);
s.affect_anchors = b("affectAnchors");
s.affect_in = b("affectIn");
s.affect_out = b("affectOut");
Some(s)
}
pub fn to_json(&self) -> Value {
let mut v = json!({
"tool": self.kind.id(), "width": self.width, "height": self.height, "angle": self.angle,
"intensity": self.intensity, "detail": self.detail, "simplify": self.simplify,
});
match self.kind {
LiquifyKind::Twirl => v["rate"] = json!(self.rate),
LiquifyKind::Scallop | LiquifyKind::Crystallize => v["complexity"] = json!(self.complexity),
LiquifyKind::Wrinkle => {
v["complexity"] = json!(self.complexity);
v["horizontal"] = json!(self.horizontal);
v["vertical"] = json!(self.vertical);
}
_ => {}
}
v
}
fn radii(&self) -> (f64, f64) {
(self.width / 2.0, self.height / 2.0)
}
/// Spacing between dabs.
pub fn dab_spacing(&self) -> f64 {
let (rx, ry) = self.radii();
(rx.min(ry) * 0.2).max(0.5)
}
/// Target segment length inside the brush.
fn detail_spacing(&self) -> f64 {
let (rx, ry) = self.radii();
let extra = match self.kind {
LiquifyKind::Scallop | LiquifyKind::Crystallize | LiquifyKind::Wrinkle => 1.0 + self.complexity * 0.5,
_ => 1.0,
};
(rx.min(ry) * 2.0 / (self.detail * 2.0 + 1.0) / extra).max(0.5)
}
/// Bounding box of the brush at `c`.
pub fn brush_bounds(&self, c: Point) -> Rect {
let r = self.width.max(self.height) / 2.0;
Rect::new(c.x - r, c.y - r, c.x + r, c.y + r)
}
/// Falloff weight of `q` for a brush centred at `c` (0 outside).
pub fn falloff(&self, c: Point, q: Point) -> f64 {
let (rx, ry) = self.radii();
let (s, co) = (-self.angle.to_radians()).sin_cos();
let d = q - c;
let u = Vec2::new(d.x * co - d.y * s, d.x * s + d.y * co);
let r2 = (u.x / rx).powi(2) + (u.y / ry).powi(2);
if r2 >= 1.0 { 0.0 } else { (1.0 - r2).powi(2) }
}
}
/// Resample a stroke polyline into dabs spaced `spacing` apart (the first point is always a dab).
pub fn dabs(points: &[Point], spacing: f64) -> Vec<Point> {
let mut out: Vec<Point> = vec![];
let Some(&first) = points.first() else { return out };
out.push(first);
let mut last = first;
let mut carry = 0.0;
for w in points.windows(2) {
let (a, b) = (w[0], w[1]);
let len = a.distance(b);
if len < 1e-12 {
continue;
}
let mut s = spacing - carry;
while s <= len {
last = a.lerp(b, s / len);
out.push(last);
s += spacing;
}
carry = len - (s - spacing);
if out.len() > 20_000 {
break;
}
}
let end = *points.last().unwrap();
if end.distance(last) > spacing * 0.25 {
out.push(end);
}
out
}
fn hash(mut x: u64) -> u64 {
// splitmix64
x = x.wrapping_add(0x9e37_79b9_7f4a_7c15);
x = (x ^ (x >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
x = (x ^ (x >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
x ^ (x >> 31)
}
/// Deterministic noise in [-1, 1].
fn noise(a: u64, b: u64, c: u64) -> f64 {
let h = hash(a.wrapping_mul(0x1000_0000_01b3) ^ hash(b.wrapping_mul(31) ^ hash(c)));
(h >> 11) as f64 / (1u64 << 53) as f64 * 2.0 - 1.0
}
const MAX_ANCHORS: usize = 20_000;
/// Split the segments of `sp` that the brush at `c` touches until they are at most `spacing` long.
/// Straight segments become curves first so the deformation stays smooth.
fn subdivide(sp: &mut SubPath, prm: &LiquifyParams, c: Point, spacing: f64) {
let bb = prm.brush_bounds(c);
let mut seg = 0;
while seg < sp.segment_count() {
if sp.anchors.len() >= MAX_ANCHORS {
return;
}
let cub = sp.segment(seg);
let sb = drawcraft_geom::kurbo::ParamCurveExtrema::bounding_box(&cub);
let touched = sb.intersect(bb).area() > 0.0 || (sb.width() == 0.0 || sb.height() == 0.0) && sb.inflate(1e-6, 1e-6).intersect(bb).area() > 0.0;
if !touched || !(0..=8).any(|i| prm.falloff(c, drawcraft_geom::kurbo::ParamCurve::eval(&cub, i as f64 / 8.0)) > 0.0) {
seg += 1;
continue;
}
let len = cub.arclen(1e-3);
let k = (len / spacing).ceil() as usize;
if k <= 1 {
seg += 1;
continue;
}
let n = sp.anchors.len();
if sp.segment_is_line(seg) {
let (i0, i1) = (seg % n, (seg + 1) % n);
let (a, b) = (sp.anchors[i0].p, sp.anchors[i1].p);
sp.anchors[i0].h_out = a.lerp(b, 1.0 / 3.0);
sp.anchors[i1].h_in = a.lerp(b, 2.0 / 3.0);
}
// Split into k equal-parameter pieces: split at 1/k, then 1/(k-1) of the rest, ...
let mut cur = seg;
for j in 0..k - 1 {
let t = 1.0 / (k - j) as f64;
cur = sp.insert_anchor(cur, t);
}
seg = cur + 1;
}
}
/// Displace one point (`which`: 0 anchor, 1 in-handle, 2 out-handle).
#[allow(clippy::too_many_arguments)]
fn displace(prm: &LiquifyParams, c: Point, prev: Point, dab: u64, key: u64, q: Point, anchor_new: Option<Point>, anchor_old: Point) -> Point {
let f = prm.falloff(c, q);
let i = prm.intensity;
let (rx, ry) = prm.radii();
let r = rx.max(ry);
let d = q - c;
let len = d.hypot();
let dir = if len > 1e-9 { d / len } else { Vec2::ZERO };
match prm.kind {
LiquifyKind::Warp => q + (c - prev) * (i * f),
LiquifyKind::Twirl => {
let a = prm.rate.to_radians() * i * f * 0.25;
let (s, co) = a.sin_cos();
c + Vec2::new(d.x * co - d.y * s, d.x * s + d.y * co)
}
LiquifyKind::Pucker => q - d * (i * f * 0.2),
LiquifyKind::Bloat => q + dir * (i * f * 0.1 * r),
LiquifyKind::Scallop => match anchor_new {
None => q - d * (i * f * 0.12),
Some(a) => {
// Handles swing sideways (curls) and stretch: arc-like details on the outline.
let h = q - anchor_old;
let phi = noise(dab, key, 7) * std::f64::consts::FRAC_PI_2 * i * f * (1.0 + prm.complexity * 0.2);
let (s, co) = phi.sin_cos();
a + Vec2::new(h.x * co - h.y * s, h.x * s + h.y * co) * (1.0 + 0.6 * i * f)
}
},
LiquifyKind::Crystallize => match anchor_new {
None => q + dir * (i * f * 0.1 * r * (0.5 + 0.5 * noise(dab, key, 3).abs())),
// Handles are pulled in: spikes.
Some(a) => a + (q - anchor_old) * (1.0 - 0.6 * i * f),
},
LiquifyKind::Wrinkle => {
let amp = i * f * 0.06 * r * (1.0 + prm.complexity * 0.1);
q + Vec2::new(noise(dab, key, 11) * prm.horizontal * amp, noise(dab, key, 13) * prm.vertical * amp)
}
}
}
/// Apply one dab to a subpath (after subdivision).
fn apply_dab(sp: &mut SubPath, prm: &LiquifyParams, c: Point, prev: Point, dab: u64, salt: u64) -> bool {
let bb = prm.brush_bounds(c);
let mut changed = false;
for (ai, a) in sp.anchors.iter_mut().enumerate() {
if !bb.contains(a.p) && !bb.contains(a.h_in) && !bb.contains(a.h_out) {
continue;
}
let key = hash(salt ^ (ai as u64).wrapping_mul(0x9e37_79b9));
let old = *a;
let np = if prm.affect_anchors { displace(prm, c, prev, dab, key, old.p, None, old.p) } else { old.p };
let moved = np - old.p;
let handle = |h: Point, affect: bool, k: u64| -> Point {
if h.distance(old.p) < 1e-12 {
return np;
}
if !affect {
return h + moved;
}
match prm.kind {
LiquifyKind::Scallop | LiquifyKind::Crystallize => displace(prm, c, prev, dab, key ^ k, h, Some(np), old.p),
_ => displace(prm, c, prev, dab, key ^ k, h, None, old.p),
}
};
let hi = handle(old.h_in, prm.affect_in, 1);
let ho = handle(old.h_out, prm.affect_out, 2);
if np != old.p || hi != old.h_in || ho != old.h_out {
changed = true;
*a = Anchor { p: np, h_in: hi, h_out: ho, kind: if old.kind == AnchorKind::Smooth && prm.kind == LiquifyKind::Crystallize { AnchorKind::Corner } else { old.kind } };
}
}
changed
}
/// Distance from `p` to segment `ab`.
fn dist_seg(p: Point, a: Point, b: Point) -> f64 {
let ab = b - a;
let l2 = ab.hypot2();
if l2 < 1e-18 {
return p.distance(a);
}
let t = ((p - a).dot(ab) / l2).clamp(0.0, 1.0);
p.distance(a + ab * t)
}
/// Remove anchors lying flat between their neighbours (all control points within `tol` of the chord).
fn simplify(sp: &mut SubPath, tol: f64, region: Rect) {
if tol <= 0.0 {
return;
}
let mut i = 1;
loop {
let n = sp.anchors.len();
let min = if sp.closed { 4 } else { 3 };
if n < min {
return;
}
let last = if sp.closed { n } else { n - 1 };
if i >= last {
return;
}
let (pa, k, pb) = (sp.anchors[i - 1], sp.anchors[i], sp.anchors[(i + 1) % n]);
let flat = region.contains(k.p)
&& [k.p, k.h_in, k.h_out, pa.h_out, pb.h_in].iter().all(|q| dist_seg(*q, pa.p, pb.p) <= tol)
&& (k.p - pa.p).dot(pb.p - k.p) > 0.0;
if flat {
let lab = pa.p.distance(pb.p);
let (lak, lkb) = (pa.p.distance(k.p).max(1e-9), k.p.distance(pb.p).max(1e-9));
sp.anchors[i - 1].h_out = pa.p + (pa.h_out - pa.p) * (lab / lak);
sp.anchors[(i + 1) % n].h_in = pb.p + (pb.h_in - pb.p) * (lab / lkb);
sp.anchors.remove(i);
} else {
i += 1;
}
}
}
/// Apply a liquify stroke to `path`. `salt` decorrelates noise between paths. Returns whether
/// anything moved.
pub fn apply_stroke(path: &mut PathData, dab_pts: &[Point], prm: &LiquifyParams, salt: u64) -> bool {
let spacing = prm.detail_spacing();
let mut changed = false;
let mut region: Option<Rect> = None;
for (si, sp) in path.subpaths.iter_mut().enumerate() {
let salt = hash(salt ^ (si as u64 + 1).wrapping_mul(0x1234_5678_9abc_def1));
for (di, c) in dab_pts.iter().enumerate() {
let prev = if di == 0 { *c } else { dab_pts[di - 1] };
let bb = prm.brush_bounds(*c);
let Some(spb) = sp_bounds(sp) else { continue };
if spb.intersect(bb).area() <= 0.0 && !(spb.width() == 0.0 || spb.height() == 0.0) {
continue;
}
subdivide(sp, prm, *c, spacing);
if apply_dab(sp, prm, *c, prev, di as u64, salt) {
changed = true;
region = Some(region.map_or(bb, |r| r.union(bb)));
}
}
}
if changed && let Some(r) = region {
let tol = prm.simplify / 100.0 * spacing * 0.02;
for sp in &mut path.subpaths {
simplify(sp, tol, r);
}
}
changed
}
fn sp_bounds(sp: &SubPath) -> Option<Rect> {
let mut it = sp.anchors.iter().flat_map(|a| [a.p, a.h_in, a.h_out]);
let f = it.next()?;
Some(it.fold(Rect::from_points(f, f), |r, p| r.union_pt(p)))
}
// ---------- the tool ----------
pub struct LiquifyTool {
pub params: LiquifyParams,
points: Vec<Point>,
active: bool,
/// Alt-drag resizes the brush from this centre.
sizing: Option<Point>,
hover: Option<Point>,
}
impl LiquifyTool {
pub fn new(id: &str) -> Self {
let kind = LiquifyKind::parse(id).unwrap_or(LiquifyKind::Warp);
Self { params: LiquifyParams::new(kind), points: vec![], active: false, sizing: None, hover: None }
}
/// The command + params for the stroke so far.
pub fn command(&self, cx: &ToolContext) -> (String, Value) {
let mut v = self.params.to_json();
v["points"] = Value::Array(self.points.iter().map(|p| json!([p.x, p.y])).collect());
if !cx.selection.is_empty() {
v["ids"] = crate::json_ids(&cx.selection.objects);
}
("object.liquify".into(), v)
}
}
impl Tool for LiquifyTool {
fn id(&self) -> &'static str {
self.params.kind.id()
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
self.hover = Some(p);
match ev.kind {
PointerKind::Down if ev.mods.alt => {
self.sizing = Some(p);
vec![]
}
PointerKind::Down => {
self.points = vec![p];
self.active = true;
let (c, v) = self.command(cx);
vec![Action::Begin(self.params.kind.label().into()), Action::Preview(c, v)]
}
PointerKind::Drag => {
if let Some(c) = self.sizing {
self.params.width = ((p.x - c.x).abs() * 2.0).max(1.0);
self.params.height = if ev.mods.shift { self.params.width } else { ((p.y - c.y).abs() * 2.0).max(1.0) };
self.hover = Some(c);
return vec![];
}
if !self.active || self.points.last().is_some_and(|l| l.distance(p) < cx.tol(2.0)) {
return vec![];
}
self.points.push(p);
let (c, v) = self.command(cx);
vec![Action::Preview(c, v)]
}
PointerKind::Up => {
if self.sizing.take().is_some() {
return vec![];
}
if !self.active {
return vec![];
}
self.active = false;
self.points.clear();
vec![Action::Commit]
}
_ => vec![],
}
}
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
let Some(c) = self.hover else { return vec![] };
vec![Overlay::Path { path: ellipse_path(c, self.params.width / 2.0, self.params.height / 2.0, self.params.angle), color: [0x80, 0x80, 0x80], width: 1.0, dashed: false }]
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
Cursor::Crosshair
}
fn options(&self) -> Value {
self.params.to_json()
}
fn set_option(&mut self, key: &str, value: &Value) {
let mut v = self.params.to_json();
v[key] = value.clone();
if key == "diameter" {
v["width"] = value.clone();
v["height"] = value.clone();
}
if let Some(p) = LiquifyParams::from_json(&v) {
self.params = p;
}
}
fn busy(&self) -> bool {
self.active
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
self.hover = None;
self.sizing = None;
if std::mem::take(&mut self.active) { vec![Action::Commit] } else { vec![] }
}
}
#[cfg(test)]
mod tests {
use super::*;
use drawcraft_geom::shapes;
fn square() -> PathData {
shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0))
}
fn prm(kind: LiquifyKind) -> LiquifyParams {
LiquifyParams { intensity: 1.0, ..LiquifyParams::new(kind) }
}
#[test]
fn dabs_are_evenly_spaced() {
let d = dabs(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0)], 10.0);
assert_eq!(d.len(), 11);
assert!(d.windows(2).all(|w| (w[0].distance(w[1]) - 10.0).abs() < 1e-9));
assert_eq!(dabs(&[Point::new(5.0, 5.0)], 10.0), vec![Point::new(5.0, 5.0)]);
}
#[test]
fn warp_is_deterministic_and_localized() {
let pts = [Point::new(200.0, 100.0), Point::new(240.0, 100.0)];
let p = prm(LiquifyKind::Warp);
let d = dabs(&pts, p.dab_spacing());
let mut a = square();
let mut b = square();
assert!(apply_stroke(&mut a, &d, &p, 7));
apply_stroke(&mut b, &d, &p, 7);
assert_eq!(a, b, "same input, same output");
// The right edge bulged outward near y = 100; the left edge (x = 0) did not move.
let bb = a.bounds().unwrap();
assert!(bb.x1 > 210.0, "{bb:?}");
assert!(a.anchors().all(|(_, _, an)| an.p.x > 150.0 || (an.p.x - 0.0).abs() < 1e-9));
assert!(a.anchor_count() > 4, "detail added anchors");
// Anchors outside the brush are untouched.
assert!(a.anchors().any(|(_, _, an)| an.p == Point::new(0.0, 0.0)));
}
#[test]
fn pucker_and_bloat_move_toward_and_away_from_centre() {
let c = Point::new(200.0, 100.0);
let p = prm(LiquifyKind::Pucker);
let mut a = square();
apply_stroke(&mut a, &[c], &p, 1);
let near = |pd: &PathData| pd.anchors().filter(|(_, _, an)| an.p.distance(c) < 50.0).map(|(_, _, an)| an.p.x).fold(f64::MIN, f64::max);
assert!(near(&a) <= 200.0 + 1e-9);
let mut b = square();
apply_stroke(&mut b, &[c, c, c], &prm(LiquifyKind::Bloat), 1);
assert!(b.bounds().unwrap().x1 > 200.5);
// Bloat pushes the edge point at the brush centre along the normal (x only).
assert!(b.anchors().any(|(_, _, an)| (an.p.y - 100.0).abs() < 1e-6 && an.p.x > 200.5));
}
#[test]
fn twirl_rotates_about_the_brush_centre() {
let c = Point::new(100.0, 100.0);
let mut pd = PathData::single(SubPath::polyline(&[Point::new(80.0, 100.0), Point::new(120.0, 100.0)], false));
let p = LiquifyParams { detail: 1.0, simplify: 0.0, ..prm(LiquifyKind::Twirl) };
apply_stroke(&mut pd, &[c], &p, 0);
// Rotation preserves the distance to the centre.
for (_, _, a) in pd.anchors() {
let d0 = if a.p.x < 100.0 { 20.0 } else { 20.0 };
if a.p.distance(c) > 1.0 {
assert!((a.p.distance(c) - d0).abs() < 1e-6 || a.p.distance(c) < 20.0);
}
}
let first = pd.subpaths[0].anchors[0].p;
assert!(first.y != 100.0 && (first.distance(c) - 20.0).abs() < 1e-9);
}
#[test]
fn noisy_tools_are_reproducible() {
for kind in [LiquifyKind::Scallop, LiquifyKind::Crystallize, LiquifyKind::Wrinkle] {
let p = LiquifyParams { horizontal: 1.0, ..prm(kind) };
let d = dabs(&[Point::new(200.0, 50.0), Point::new(200.0, 150.0)], p.dab_spacing());
let (mut a, mut b) = (square(), square());
assert!(apply_stroke(&mut a, &d, &p, 3), "{kind:?}");
apply_stroke(&mut b, &d, &p, 3);
assert_eq!(a, b, "{kind:?}");
assert_ne!(a, square());
}
}
#[test]
fn params_round_trip_through_json() {
let mut p = LiquifyParams::new(LiquifyKind::Wrinkle);
p.horizontal = 0.3;
p.width = 60.0;
let q = LiquifyParams::from_json(&p.to_json()).unwrap();
assert_eq!(p, q);
let r = LiquifyParams::from_json(&json!({"tool": "warp", "diameter": 40, "intensity": 80})).unwrap();
assert_eq!((r.width, r.height, r.intensity), (40.0, 40.0, 0.8));
}
}

View File

@ -0,0 +1,84 @@
//! Arc-length helpers: width points live at a fraction (0..1) of a subpath's length.
use drawcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveDeriv};
use drawcraft_geom::{PathData, Point, SubPath, Vec2};
const ACC: f64 = 1e-4;
/// Arc length of each segment.
pub fn seg_lengths(sp: &SubPath) -> Vec<f64> {
(0..sp.segment_count()).map(|i| sp.segment(i).arclen(ACC)).collect()
}
/// Fraction of the subpath's length at segment `seg`, parameter `t`.
pub fn fraction_at(sp: &SubPath, seg: usize, t: f64) -> f64 {
let l = seg_lengths(sp);
let total: f64 = l.iter().sum();
if total <= 1e-12 {
return 0.0;
}
let before: f64 = l[..seg.min(l.len())].iter().sum();
let part = if seg < l.len() { sp.segment(seg).subsegment(0.0..t.clamp(0.0, 1.0)).arclen(ACC) } else { 0.0 };
((before + part) / total).clamp(0.0, 1.0)
}
/// Point and unit tangent at fraction `f` of the subpath's length.
pub fn eval_fraction(sp: &SubPath, f: f64) -> Option<(Point, Vec2)> {
let l = seg_lengths(sp);
let total: f64 = l.iter().sum();
if l.is_empty() {
return sp.anchors.first().map(|a| (a.p, Vec2::new(1.0, 0.0)));
}
let mut target = f.clamp(0.0, 1.0) * total;
for (i, len) in l.iter().enumerate() {
if target <= *len || i == l.len() - 1 {
let c = sp.segment(i);
let t = if *len > 1e-12 { c.inv_arclen(target.min(*len), ACC) } else { 0.0 };
return Some((c.eval(t), tangent(&c, t)));
}
target -= len;
}
None
}
/// Unit tangent of a cubic at `t` (falls back to the chord for degenerate derivatives).
pub fn tangent(c: &drawcraft_geom::CubicBez, t: f64) -> Vec2 {
let d = c.deriv().eval(t).to_vec2();
let d = if d.hypot() > 1e-9 {
d
} else {
let d2 = c.eval((t + 0.01).min(1.0)) - c.eval((t - 0.01).max(0.0));
if d2.hypot() > 1e-12 { d2 } else { c.p3 - c.p0 }
};
let h = d.hypot();
if h > 1e-12 { d / h } else { Vec2::new(1.0, 0.0) }
}
/// The left normal of a tangent in y-down document space (left of the direction of travel).
pub fn left_normal(t: Vec2) -> Vec2 {
Vec2::new(t.y, -t.x)
}
/// Nearest point of `path` to `p`: (subpath index, fraction along it, point, unit tangent, distance).
pub fn nearest_fraction(path: &PathData, p: Point) -> Option<(usize, f64, Point, Vec2, f64)> {
let (si, seg, t, q, d) = path.nearest(p)?;
let sp = &path.subpaths[si];
Some((si, fraction_at(sp, seg, t), q, tangent(&sp.segment(seg), t), d))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fractions_round_trip_on_a_polyline() {
let sp = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0)], false);
assert!((fraction_at(&sp, 0, 0.5) - 0.25).abs() < 1e-6);
assert!((fraction_at(&sp, 1, 0.5) - 0.75).abs() < 1e-6);
let (p, t) = eval_fraction(&sp, 0.75).unwrap();
assert!((p - Point::new(100.0, 50.0)).hypot() < 1e-3);
assert!((t - Vec2::new(0.0, 1.0)).hypot() < 1e-6);
// Walking +x, the left side is up (y-down space).
assert_eq!(left_normal(Vec2::new(1.0, 0.0)), Vec2::new(0.0, -1.0));
}
}

View File

@ -0,0 +1,759 @@
//! Perspective Grid: the grid model, plane homographies, overlays, and the Perspective Grid
//! (Shift+P) and Perspective Selection (Shift+V) tools.
//!
//! The grid is stored in the document (`Document.unknown["perspectiveGrid"]`) as a
//! [`PerspectiveGrid`]. Every plane is a homography `H` from plane coordinates `(u, v)` in points
//! to the page, built from homogeneous columns: `H = [c1 c2 O]` where `O` is the ground-level
//! origin (the corner where the planes meet) and `c1`/`c2` are the images of the plane axes'
//! points at infinity — a vanishing point scaled by `1/distance` for receding axes, a direction
//! for axes parallel to the picture plane. Along a receding axis `u ↦ O + (VP − O)·u/(u + d)`,
//! the projective foreshortening of a camera `d` points from the origin.
//!
//! - 1-point: Left = the flat front plane, Right = the side wall receding to `vpLeft`, Ground
//! recedes to `vpLeft` too.
//! - 2-point: Left recedes to `vpLeft`, Right to `vpRight`, verticals stay vertical; Ground spans
//! both vanishing points.
//! - 3-point: as 2-point, but verticals converge to `vpVertical`.
use std::collections::BTreeMap;
use drawcraft_doc::{Document, NodeId};
use drawcraft_geom::{Point, Rect};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext};
/// Key under `Document.unknown`.
pub const DOC_KEY: &str = "perspectiveGrid";
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Plane {
#[default]
Left,
Right,
Ground,
/// No active plane (widget key 4).
None,
}
impl Plane {
pub fn parse(s: &str) -> Option<Self> {
Some(match s {
"left" => Self::Left,
"right" => Self::Right,
"ground" | "horizontal" => Self::Ground,
"none" => Self::None,
_ => return None,
})
}
pub fn id(self) -> &'static str {
match self {
Self::Left => "left",
Self::Right => "right",
Self::Ground => "ground",
Self::None => "none",
}
}
/// Illustrator's plane colours: left blue, right orange, ground green.
pub fn color(self) -> [u8; 3] {
match self {
Self::Left => [0x33, 0x66, 0xff],
Self::Right => [0xff, 0x8c, 0x1a],
Self::Ground => [0x2e, 0xb8, 0x4a],
Self::None => [0x99, 0x99, 0x99],
}
}
pub const ALL: [Plane; 3] = [Plane::Left, Plane::Right, Plane::Ground];
}
/// A 3×3 projective transform (row-major).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Homography(pub [[f64; 3]; 3]);
impl Homography {
/// From the images of (1,0,0), (0,1,0) and (0,0,1).
pub fn from_cols(c1: [f64; 3], c2: [f64; 3], c3: [f64; 3]) -> Self {
Self([[c1[0], c2[0], c3[0]], [c1[1], c2[1], c3[1]], [c1[2], c2[2], c3[2]]])
}
/// Map a point; None when it lands on or beyond the horizon (w ≤ 0).
pub fn apply(&self, p: Point) -> Option<Point> {
let m = &self.0;
let x = m[0][0] * p.x + m[0][1] * p.y + m[0][2];
let y = m[1][0] * p.x + m[1][1] * p.y + m[1][2];
let w = m[2][0] * p.x + m[2][1] * p.y + m[2][2];
if w <= 1e-9 || !x.is_finite() || !y.is_finite() {
return None;
}
Some(Point::new(x / w, y / w))
}
pub fn det(&self) -> f64 {
let m = &self.0;
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
}
/// Inverse.
pub fn inverse(&self) -> Option<Self> {
let m = &self.0;
let d = self.det();
if d.abs() < 1e-15 {
return None;
}
let c = |r0: usize, c0: usize, r1: usize, c1: usize| m[r0][c0] * m[r1][c1] - m[r0][c1] * m[r1][c0];
let adj = [
[c(1, 1, 2, 2), -c(0, 1, 2, 2), c(0, 1, 1, 2)],
[-c(1, 0, 2, 2), c(0, 0, 2, 2), -c(0, 0, 1, 2)],
[c(1, 0, 2, 1), -c(0, 0, 2, 1), c(0, 0, 1, 1)],
];
let mut out = [[0.0; 3]; 3];
for (r, row) in adj.iter().enumerate() {
for (k, v) in row.iter().enumerate() {
out[r][k] = v / d;
}
}
// H⁻¹·(x, y, 1) = (u, v, 1)/w_H, so points in front of the camera keep w > 0.
Some(Self(out))
}
}
fn two() -> u8 {
2
}
fn yes() -> bool {
true
}
/// The perspective grid definition (Define Grid) plus view state.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PerspectiveGrid {
/// 1, 2 or 3-point perspective.
#[serde(default = "two")]
pub kind: u8,
/// Ground-level origin (where the planes meet), page coordinates.
pub origin: [f64; 2],
/// Horizon height (page y).
pub horizon: f64,
/// Left vanishing point x (the single vanishing point in 1-point perspective).
pub vp_left: f64,
/// Right vanishing point x.
pub vp_right: f64,
/// Third (vertical) vanishing point, 3-point only.
pub vp_vertical: [f64; 2],
/// Viewing distance (points): how fast receding axes foreshorten.
pub distance: f64,
/// Gridline every (points, in plane units).
pub cell: f64,
/// Horizontal extent of the planes (points, plane units).
pub extent: f64,
/// Vertical extent of the wall planes (points, plane units).
pub height: f64,
#[serde(default = "yes")]
pub visible: bool,
/// Active plane (Plane Switching widget).
#[serde(default)]
pub plane: Plane,
/// Objects attached to planes (node id → plane).
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub attached: BTreeMap<String, Plane>,
}
impl PerspectiveGrid {
/// Illustrator-like preset for `kind` fitted to an artboard.
pub fn preset(kind: u8, ab: Rect) -> Self {
let (w, h) = (ab.width().max(1.0), ab.height().max(1.0));
let kind = kind.clamp(1, 3);
let horizon = ab.y0 + 0.42 * h;
let ground = ab.y0 + 0.78 * h;
let ox = if kind == 1 { ab.x0 + 0.3 * w } else { ab.x0 + 0.5 * w };
let (vl, vr) = if kind == 1 { (ab.x0 + 0.55 * w, ab.x1) } else { (ab.x0 + 0.02 * w, ab.x1 - 0.02 * w) };
Self {
kind,
origin: [ox, ground],
horizon,
vp_left: vl,
vp_right: vr,
vp_vertical: [ox, horizon - 3.0 * (ground - horizon)],
distance: 0.5 * w,
cell: (w / 30.0).max(1.0).round(),
extent: 0.6 * w,
height: (ground - ab.y0) * 0.8,
visible: true,
plane: Plane::Left,
attached: BTreeMap::new(),
}
}
/// The document's grid, if one was defined.
pub fn from_doc(doc: &Document) -> Option<Self> {
doc.unknown.get(DOC_KEY).and_then(|v| serde_json::from_value(v.clone()).ok())
}
/// The document's grid, or the default 2-point preset for the first artboard (hidden).
pub fn effective(doc: &Document) -> Self {
Self::from_doc(doc).unwrap_or_else(|| {
let ab = doc.artboards.first().map(|a| a.rect).unwrap_or(Rect::new(0.0, 0.0, 612.0, 792.0));
Self { visible: false, ..Self::preset(2, ab) }
})
}
/// Store into the document.
pub fn store(&self, doc: &mut Document) {
let mut g = self.clone();
g.attached.retain(|k, _| k.parse::<u64>().ok().is_some_and(|id| doc.node(NodeId(id)).is_some()));
if let Ok(v) = serde_json::to_value(&g) {
doc.unknown.insert(DOC_KEY.into(), v);
}
}
/// Definition only (for tool previews and the Define Grid dialog).
pub fn definition_json(&self) -> Value {
let mut v = serde_json::to_value(self).unwrap_or(Value::Null);
if let Some(o) = v.as_object_mut() {
o.remove("attached");
}
v
}
/// Merge `patch` (any subset of the fields) into this grid.
pub fn merged(&self, patch: &Value) -> Result<Self, String> {
let mut v = serde_json::to_value(self).map_err(|e| e.to_string())?;
if let (Some(o), Some(p)) = (v.as_object_mut(), patch.as_object()) {
for (k, val) in p {
if k == "ids" || k == "attached" {
continue;
}
o.insert(k.clone(), val.clone());
}
}
let g: Self = serde_json::from_value(v).map_err(|e| e.to_string())?;
g.validate()?;
Ok(g)
}
pub fn validate(&self) -> Result<(), String> {
if !(1..=3).contains(&self.kind) {
return Err("kind must be 1, 2 or 3".into());
}
let nums = [self.origin[0], self.origin[1], self.horizon, self.vp_left, self.vp_right, self.vp_vertical[0], self.vp_vertical[1], self.distance, self.cell, self.extent, self.height];
if nums.iter().any(|v| !v.is_finite() || v.abs() > 4.0e6) {
return Err("grid values must be finite".into());
}
if self.distance <= 0.0 || self.cell <= 0.0 || self.extent <= 0.0 || self.height <= 0.0 {
return Err("distance, cell, extent and height must be positive".into());
}
if (self.origin[1] - self.horizon).abs() < 1e-6 {
return Err("ground level must differ from the horizon".into());
}
Ok(())
}
pub fn planes(&self) -> [Plane; 3] {
Plane::ALL
}
/// The homography of `plane` (plane coordinates in points → page).
pub fn homography(&self, plane: Plane) -> Option<Homography> {
let l = 1.0 / self.distance.max(1e-9);
let o = [self.origin[0], self.origin[1], 1.0];
let vl = [self.vp_left * l, self.horizon * l, l];
let vr = [self.vp_right * l, self.horizon * l, l];
let up = if self.kind == 3 { [self.vp_vertical[0] * l, self.vp_vertical[1] * l, l] } else { [0.0, -1.0, 0.0] };
let flat = [1.0, 0.0, 0.0];
let (c1, c2) = match (self.kind, plane) {
(_, Plane::None) => return None,
(1, Plane::Left) => (flat, [0.0, -1.0, 0.0]),
(1, Plane::Right) => (vl, [0.0, -1.0, 0.0]),
(1, Plane::Ground) => (flat, vl),
(_, Plane::Left) => (vl, up),
(_, Plane::Right) => (vr, up),
(_, Plane::Ground) => (vr, vl),
};
let h = Homography::from_cols(c1, c2, o);
(h.det().abs() > 1e-15).then_some(h)
}
/// Plane-coordinate extent of a plane.
pub fn domain(&self, plane: Plane) -> Rect {
match plane {
Plane::Ground => Rect::new(0.0, 0.0, self.extent, self.extent),
_ => Rect::new(0.0, 0.0, self.extent, self.height),
}
}
/// Page → plane coordinates.
pub fn to_plane(&self, plane: Plane, p: Point) -> Option<Point> {
self.homography(plane)?.inverse()?.apply(p)
}
/// Plane → page coordinates.
pub fn to_page(&self, plane: Plane, q: Point) -> Option<Point> {
self.homography(plane)?.apply(q)
}
/// The map that puts flat art with bounds `b` onto `plane`: an axis-aligned affine map sends
/// `b` to the plane rectangle whose opposite corners project to `b`'s top-left and
/// bottom-right, then the plane homography projects it (so a drawn rectangle keeps the two
/// corners the user dragged between).
pub fn attach_map(&self, plane: Plane, b: Rect) -> Option<impl Fn(Point) -> Option<Point>> {
let h = self.homography(plane)?;
let hi = h.inverse()?;
let a = hi.apply(Point::new(b.x0, b.y0))?;
let c = hi.apply(Point::new(b.x1, b.y1))?;
let sx = if b.width().abs() > 1e-9 { (c.x - a.x) / b.width() } else { 1.0 };
let sy = if b.height().abs() > 1e-9 { (c.y - a.y) / b.height() } else { sx.abs() * if c.y < a.y { -1.0 } else { 1.0 } };
let sx = if b.width().abs() > 1e-9 { sx } else { sy.abs() };
let (x0, y0) = (b.x0, b.y0);
Some(move |p: Point| h.apply(Point::new(a.x + (p.x - x0) * sx, a.y + (p.y - y0) * sy)))
}
/// The map that slides art lying on `plane` by the plane-space offset between `from` and `to`.
pub fn move_map(&self, plane: Plane, from: Point, to: Point) -> Option<impl Fn(Point) -> Option<Point>> {
let h = self.homography(plane)?;
let hi = h.inverse()?;
let d = hi.apply(to)? - hi.apply(from)?;
Some(move |p: Point| h.apply(hi.apply(p)? + d))
}
pub fn attached_plane(&self, id: NodeId) -> Option<Plane> {
self.attached.get(&id.0.to_string()).copied()
}
/// Grid lines of `plane` (page space).
pub fn lines(&self, plane: Plane) -> Vec<(Point, Point)> {
let Some(h) = self.homography(plane) else { return vec![] };
let d = self.domain(plane);
let mut out = vec![];
let step = |len: f64| {
let mut s = self.cell;
while len / s > 120.0 {
s *= 2.0;
}
s
};
let (su, sv) = (step(d.width()), step(d.height()));
let mut u = 0.0;
while u <= d.width() + 1e-9 {
if let (Some(a), Some(b)) = (h.apply(Point::new(u, 0.0)), h.apply(Point::new(u, d.height()))) {
out.push((a, b));
}
u += su;
}
let mut v = 0.0;
while v <= d.height() + 1e-9 {
if let (Some(a), Some(b)) = (h.apply(Point::new(0.0, v)), h.apply(Point::new(d.width(), v))) {
out.push((a, b));
}
v += sv;
}
out
}
/// Plane Switching widget geometry: (plane, quad) faces and the "no plane" circle (centre, r).
pub fn widget(&self, doc: &Document, tol: f64) -> (Vec<(Plane, [Point; 4])>, (Point, f64)) {
let ab = doc.artboards.first().map(|a| a.rect).unwrap_or(Rect::new(0.0, 0.0, 612.0, 792.0));
let s = 40.0 * tol;
let (cx, cy) = (ab.x0 + 10.0 * tol + s / 2.0, ab.y0 + 10.0 * tol + s / 2.0);
let p = |dx: f64, dy: f64| Point::new(cx + dx * s, cy + dy * s);
let faces = vec![
(Plane::Left, [p(-0.5, -0.25), p(0.0, 0.0), p(0.0, 0.5), p(-0.5, 0.25)]),
(Plane::Right, [p(0.0, 0.0), p(0.5, -0.25), p(0.5, 0.25), p(0.0, 0.5)]),
(Plane::Ground, [p(0.0, -0.5), p(0.5, -0.25), p(0.0, 0.0), p(-0.5, -0.25)]),
];
(faces, (p(0.62, 0.62), 0.12 * s))
}
/// Which widget control is at `p`.
pub fn widget_hit(&self, doc: &Document, tol: f64, p: Point) -> Option<Plane> {
let (faces, (c, r)) = self.widget(doc, tol);
if p.distance(c) <= r * 1.5 {
return Some(Plane::None);
}
faces.into_iter().find(|(_, q)| in_quad(q, p)).map(|(pl, _)| pl)
}
}
fn in_quad(q: &[Point; 4], p: Point) -> bool {
let mut sign = 0.0;
for i in 0..4 {
let (a, b) = (q[i], q[(i + 1) % 4]);
let c = (b - a).cross(p - a);
if c.abs() < 1e-12 {
continue;
}
if sign == 0.0 {
sign = c.signum();
} else if c.signum() != sign {
return false;
}
}
true
}
/// Grid overlays (lines, horizon, vanishing points, widget) drawn whenever the grid is visible or
/// a perspective tool is active. `tol` = document units per screen pixel.
pub fn grid_overlays(doc: &Document, tol: f64, tool_id: &str) -> Vec<Overlay> {
let g = PerspectiveGrid::effective(doc);
let tool = matches!(tool_id, "perspectiveGrid" | "perspectiveSelection");
if !g.visible && !tool {
return vec![];
}
let mut out = vec![];
for pl in g.planes() {
let color = pl.color();
for (a, b) in g.lines(pl) {
out.push(Overlay::Line { a, b, color, dashed: false });
}
if pl == g.plane
&& let Some(h) = g.homography(pl)
{
let d = g.domain(pl);
let corners: Vec<Point> = [(d.x0, d.y0), (d.x1, d.y0), (d.x1, d.y1), (d.x0, d.y1)].iter().filter_map(|&(x, y)| h.apply(Point::new(x, y))).collect();
if corners.len() == 4 {
out.push(Overlay::Path { path: crate::xform::polygon(&corners, true), color, width: 2.0, dashed: false });
}
}
}
let xs = [g.vp_left, g.vp_right, g.origin[0]];
let (x0, x1) = (xs.iter().cloned().fold(f64::MAX, f64::min) - 50.0 * tol, xs.iter().cloned().fold(f64::MIN, f64::max) + 50.0 * tol);
out.push(Overlay::Line { a: Point::new(x0, g.horizon), b: Point::new(x1, g.horizon), color: [0x60, 0x60, 0x60], dashed: true });
// Widget.
let (faces, (c, r)) = g.widget(doc, tol);
for (pl, q) in faces {
let [cr, cg, cb] = pl.color();
out.push(Overlay::Highlight { quad: q, color: [cr, cg, cb, if pl == g.plane { 230 } else { 70 }] });
out.push(Overlay::Path { path: crate::xform::polygon(&q, true), color: [0x40, 0x40, 0x40], width: 1.0, dashed: false });
}
out.push(Overlay::Path { path: super::ellipse_path(c, r, r, 0.0), color: if g.plane == Plane::None { [0x20, 0x20, 0x20] } else { [0x99, 0x99, 0x99] }, width: 1.5, dashed: false });
out
}
// ---------- Perspective Grid tool ----------
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Handle {
VpLeft,
VpRight,
VpVertical,
Horizon,
Origin,
ExtentLeft,
ExtentRight,
Height,
}
fn handles(g: &PerspectiveGrid) -> Vec<(Handle, Point)> {
let mut v = vec![(Handle::VpLeft, Point::new(g.vp_left, g.horizon))];
if g.kind != 1 {
v.push((Handle::VpRight, Point::new(g.vp_right, g.horizon)));
}
if g.kind == 3 {
v.push((Handle::VpVertical, Point::new(g.vp_vertical[0], g.vp_vertical[1])));
}
v.push((Handle::Origin, Point::new(g.origin[0], g.origin[1])));
let (lp, rp) = (Plane::Left, Plane::Right);
if let Some(p) = g.to_page(lp, Point::new(g.extent, 0.0)) {
v.push((Handle::ExtentLeft, p));
}
if let Some(p) = g.to_page(rp, Point::new(g.extent, 0.0)) {
v.push((Handle::ExtentRight, p));
}
if let Some(p) = g.to_page(Plane::Left, Point::new(0.0, g.height)) {
v.push((Handle::Height, p));
}
// The horizon handle sits on the horizon above the origin.
v.push((Handle::Horizon, Point::new(g.origin[0], g.horizon)));
v
}
/// Apply a handle drag to a copy of the grid.
fn drag_handle(g: &PerspectiveGrid, h: Handle, p: Point) -> PerspectiveGrid {
let mut n = g.clone();
match h {
Handle::VpLeft => {
n.vp_left = p.x;
n.horizon = p.y;
}
Handle::VpRight => {
n.vp_right = p.x;
n.horizon = p.y;
}
Handle::VpVertical => n.vp_vertical = [p.x, p.y],
Handle::Horizon => n.horizon = p.y,
Handle::Origin => n.origin = [p.x, p.y],
Handle::ExtentLeft | Handle::ExtentRight | Handle::Height => {
let pl = if h == Handle::ExtentRight { Plane::Right } else { Plane::Left };
if let Some(q) = g.to_plane(pl, p) {
match h {
Handle::Height => n.height = q.y.max(g.cell),
_ => n.extent = q.x.max(g.cell),
}
}
}
}
if n.validate().is_ok() { n } else { g.clone() }
}
#[derive(Default)]
pub struct PerspectiveGridTool {
drag: Option<Handle>,
}
impl Tool for PerspectiveGridTool {
fn id(&self) -> &'static str {
"perspectiveGrid"
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
let g = PerspectiveGrid::effective(cx.doc);
match ev.kind {
PointerKind::Down => {
let mut pre = vec![];
if !g.visible {
pre.push(Action::Exec("perspective.grid.show".into(), json!({"visible": true})));
}
if let Some(pl) = g.widget_hit(cx.doc, cx.tol(1.0), p) {
pre.push(Action::Exec("perspective.plane.set".into(), json!({"plane": pl.id()})));
return pre;
}
let tol = cx.tol(6.0);
if let Some((h, _)) = handles(&g).into_iter().filter(|(_, q)| q.distance(p) <= tol).min_by(|a, b| a.1.distance(p).total_cmp(&b.1.distance(p))) {
self.drag = Some(h);
pre.push(Action::Begin("Edit Perspective Grid".into()));
}
pre
}
PointerKind::Drag => match self.drag {
Some(h) => vec![Action::Preview("perspective.grid.set".into(), drag_handle(&g, h, p).definition_json())],
None => vec![],
},
PointerKind::Up => match self.drag.take() {
Some(_) => vec![Action::Commit],
None => vec![],
},
_ => vec![],
}
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let g = PerspectiveGrid::effective(cx.doc);
handles(&g)
.into_iter()
.map(|(h, p)| match h {
Handle::VpLeft | Handle::VpRight | Handle::VpVertical => Overlay::Anchor { p, color: [0x20, 0x20, 0x20], filled: true, size: 7.0 },
_ => Overlay::Anchor { p, color: [0x20, 0x20, 0x20], filled: false, size: 6.0 },
})
.collect()
}
fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor {
let g = PerspectiveGrid::effective(cx.doc);
let tol = cx.tol(6.0);
match handles(&g).into_iter().find(|(_, q)| q.distance(p) <= tol).map(|h| h.0) {
Some(Handle::Horizon | Handle::Height) => Cursor::ResizeV,
Some(Handle::ExtentLeft | Handle::ExtentRight) => Cursor::ResizeH,
Some(_) => Cursor::Move,
None => Cursor::Arrow,
}
}
fn busy(&self) -> bool {
self.drag.is_some()
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
if self.drag.take().is_some() { vec![Action::Commit] } else { vec![] }
}
}
// ---------- Perspective Selection tool ----------
#[derive(Default)]
pub struct PerspectiveSelectionTool {
drag: Option<(Vec<NodeId>, Point)>,
}
impl Tool for PerspectiveSelectionTool {
fn id(&self) -> &'static str {
"perspectiveSelection"
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
let g = PerspectiveGrid::effective(cx.doc);
match ev.kind {
PointerKind::Down => {
if g.visible
&& let Some(pl) = g.widget_hit(cx.doc, cx.tol(1.0), p)
{
return vec![Action::Exec("perspective.plane.set".into(), json!({"plane": pl.id()}))];
}
let Some(h) = drawcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options()) else {
return vec![Action::Exec("select.set".into(), json!({"ids": []}))];
};
let top = h.top_object(cx.isolation);
let mut acts = vec![];
let ids = if cx.selection.objects.contains(&top) {
cx.selection.objects.clone()
} else {
acts.push(Action::Exec("select.set".into(), json!({"ids": [top.0]})));
vec![top]
};
acts.push(Action::Begin("Move in Perspective".into()));
self.drag = Some((ids, p));
acts
}
PointerKind::Drag => match &self.drag {
Some((ids, from)) => {
let mut v = json!({"ids": crate::json_ids(ids), "from": [from.x, from.y], "to": [p.x, p.y]});
if g.plane != Plane::None {
v["plane"] = json!(g.plane.id());
}
vec![Action::Preview("perspective.move".into(), v)]
}
None => vec![],
},
PointerKind::Up => match self.drag.take() {
Some(_) => vec![Action::Commit],
None => vec![],
},
_ => vec![],
}
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
// Attached objects in the selection get their plane's colour outline.
let g = PerspectiveGrid::effective(cx.doc);
let mut out = vec![];
for id in &cx.selection.objects {
if let (Some(pl), Some(n)) = (g.attached_plane(*id), cx.doc.node(*id))
&& let Some(b) = n.geometric_bounds()
{
out.push(Overlay::Path { path: crate::xform::polygon(&crate::xform::rect_corners(b), true), color: pl.color(), width: 1.0, dashed: true });
}
}
out
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
Cursor::Arrow
}
fn busy(&self) -> bool {
self.drag.is_some()
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
if self.drag.take().is_some() { vec![Action::Commit] } else { vec![] }
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use drawcraft_doc::Selection;
fn grid(kind: u8) -> PerspectiveGrid {
PerspectiveGrid::preset(kind, Rect::new(0.0, 0.0, 800.0, 600.0))
}
#[test]
fn homography_maps_plane_corners_to_the_vanishing_points() {
for kind in [1u8, 2, 3] {
let g = grid(kind);
for pl in Plane::ALL {
let h = g.homography(pl).unwrap();
// The plane origin is the ground-level origin.
let o = h.apply(Point::new(0.0, 0.0)).unwrap();
assert!((o - Point::new(g.origin[0], g.origin[1])).hypot() < 1e-9, "{kind} {pl:?}");
// Far along a receding axis, points approach the vanishing point on the horizon.
let far = h.apply(Point::new(1e9, 0.0)).unwrap();
let expect = match (kind, pl) {
(1, Plane::Left | Plane::Ground) => None,
(1, Plane::Right) => Some(Point::new(g.vp_left, g.horizon)),
(_, Plane::Left) => Some(Point::new(g.vp_left, g.horizon)),
_ => Some(Point::new(g.vp_right, g.horizon)),
};
if let Some(e) = expect {
assert!((far - e).hypot() < 1e-3, "{kind} {pl:?} {far:?}");
}
}
}
// 2-point walls: verticals stay vertical and unforeshortened at the origin edge.
let g = grid(2);
let top = g.to_page(Plane::Left, Point::new(0.0, 100.0)).unwrap();
assert!((top - Point::new(g.origin[0], g.origin[1] - 100.0)).hypot() < 1e-9);
// Foreshortening: u = distance lands halfway to the vanishing point.
let mid = g.to_page(Plane::Right, Point::new(g.distance, 0.0)).unwrap();
let want = Point::new(g.origin[0], g.origin[1]).lerp(Point::new(g.vp_right, g.horizon), 0.5);
assert!((mid - want).hypot() < 1e-9);
}
#[test]
fn homography_round_trips() {
for kind in [1u8, 2, 3] {
let g = grid(kind);
for pl in Plane::ALL {
for q in [Point::new(10.0, 20.0), Point::new(300.0, 5.0), Point::new(0.0, 0.0), Point::new(123.0, 77.0)] {
let p = g.to_page(pl, q).unwrap();
let back = g.to_plane(pl, p).unwrap();
assert!((back - q).hypot() < 1e-6, "{kind} {pl:?} {q:?} → {back:?}");
}
}
}
assert!(grid(2).homography(Plane::None).is_none());
}
#[test]
fn attach_map_keeps_the_dragged_corners() {
let g = grid(2);
let b = Rect::new(450.0, 300.0, 550.0, 420.0);
let m = g.attach_map(Plane::Right, b).unwrap();
assert!((m(Point::new(b.x0, b.y0)).unwrap() - Point::new(b.x0, b.y0)).hypot() < 1e-6);
assert!((m(Point::new(b.x1, b.y1)).unwrap() - Point::new(b.x1, b.y1)).hypot() < 1e-6);
// The other corners lie on grid lines through the plane: the top-right corner is on the
// line from the top-left corner to the right vanishing point.
let tr = m(Point::new(b.x1, b.y0)).unwrap();
let vp = Point::new(g.vp_right, g.horizon);
assert!((tr - Point::new(b.x0, b.y0)).cross(vp - Point::new(b.x0, b.y0)).abs() < 1e-6);
assert!(tr.y != b.y0);
}
#[test]
fn move_map_slides_within_the_plane() {
let g = grid(2);
let from = g.to_page(Plane::Left, Point::new(50.0, 50.0)).unwrap();
let to = g.to_page(Plane::Left, Point::new(80.0, 60.0)).unwrap();
let m = g.move_map(Plane::Left, from, to).unwrap();
let p = g.to_page(Plane::Left, Point::new(10.0, 10.0)).unwrap();
let q = g.to_plane(Plane::Left, m(p).unwrap()).unwrap();
assert!((q - Point::new(40.0, 20.0)).hypot() < 1e-6);
}
#[test]
fn grid_serde_and_merge() {
let g = grid(3);
let mut d = Document::new(800.0, 600.0);
g.store(&mut d);
assert_eq!(PerspectiveGrid::from_doc(&d), Some(g.clone()));
let m = g.merged(&json!({"kind": 1, "cell": 25})).unwrap();
assert_eq!((m.kind, m.cell), (1, 25.0));
assert!(g.merged(&json!({"distance": -1})).is_err());
}
#[test]
fn grid_tool_widget_and_handles() {
let mut d = Document::new(800.0, 600.0);
grid(2).store(&mut d);
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let g = PerspectiveGrid::effective(&d);
let mut t = PerspectiveGridTool::default();
// Click the right face of the widget.
let (faces, _) = g.widget(&d, 1.0);
let q = faces[1].1;
let centre = Point::new((q[0].x + q[1].x + q[2].x + q[3].x) / 4.0, (q[0].y + q[1].y + q[2].y + q[3].y) / 4.0);
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, centre.x, centre.y)), vec![Action::Exec("perspective.plane.set".into(), json!({"plane": "right"}))]);
// Drag the left vanishing point.
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, g.vp_left, g.horizon));
assert_eq!(acts, vec![Action::Begin("Edit Perspective Grid".into())]);
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, g.vp_left - 40.0, g.horizon + 10.0));
let Action::Preview(cmd, v) = &acts[0] else { panic!() };
assert_eq!(cmd, "perspective.grid.set");
assert_eq!(v["vpLeft"], json!(g.vp_left - 40.0));
assert_eq!(v["horizon"], json!(g.horizon + 10.0));
assert!(!grid_overlays(&d, 1.0, "selection").is_empty());
}
}

View File

@ -0,0 +1,227 @@
//! Puppet Warp tool.
//!
//! With artwork selected the tool places three pins automatically (spread over the mesh). Click
//! on the artwork adds a pin, Alt-click deletes one, Delete/Backspace removes the selected pin,
//! and dragging a pin warps the art: every drag previews
//! `object.puppetWarp {ids, pins, moved}` (pins = current pin positions, moved = the same list
//! with the dragged pin at the pointer), committed on release as one undo step. The mesh follows
//! the current shape, so successive drags compose.
use drawcraft_doc::{Document, NodeId};
use drawcraft_geom::{BezPath, Point, Rect};
use serde_json::{Value, json};
use super::arap::{Mesh, MeshOptions, auto_pins};
use super::{BLUE, collect_points};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
/// Outlines and bounds of the given objects (what the mesh is built over).
pub fn mesh_input(doc: &Document, ids: &[NodeId]) -> Option<(Vec<BezPath>, Rect)> {
let mut outlines = vec![];
let mut pts = vec![];
for id in ids {
let n = doc.node(*id)?;
collect_points(n, &mut pts);
n.walk(&mut |c| {
if let Some(p) = c.path_data() {
outlines.push(p.to_bezpath());
}
});
}
let first = *pts.first()?;
let b = pts.iter().fold(Rect::from_points(first, first), |r, p| r.union_pt(*p));
Some((outlines, b))
}
/// The mesh the engine uses for `ids` (shared so tool overlays match the command).
pub fn mesh_for(doc: &Document, ids: &[NodeId], expand: f64) -> Option<Mesh> {
let (o, b) = mesh_input(doc, ids)?;
Some(Mesh::build(&o, b, MeshOptions { expand, ..Default::default() }))
}
#[derive(Default)]
pub struct PuppetWarpTool {
ids: Vec<NodeId>,
pins: Vec<Point>,
selected: Option<usize>,
drag: Option<(usize, Point)>,
/// Control bar: Show Mesh.
show_mesh: bool,
/// Control bar: Expand Mesh (points).
expand: f64,
}
impl PuppetWarpTool {
/// Re-seed pins when the selection changed.
fn sync(&mut self, cx: &ToolContext) {
let ids = cx.selection.objects.clone();
if ids == self.ids {
return;
}
self.ids = ids;
self.selected = None;
self.pins = if self.ids.is_empty() { vec![] } else { mesh_for(cx.doc, &self.ids, self.expand_or_default()).map(|m| auto_pins(&m, 3)).unwrap_or_default() };
}
fn expand_or_default(&self) -> f64 {
if self.expand > 0.0 { self.expand } else { 3.0 }
}
fn pin_at(&self, cx: &ToolContext, p: Point) -> Option<usize> {
let tol = cx.tol(7.0);
self.pins.iter().enumerate().filter(|(_, q)| q.distance(p) <= tol).min_by(|a, b| a.1.distance(p).total_cmp(&b.1.distance(p))).map(|(i, _)| i)
}
fn params(&self, i: usize, to: Point) -> Value {
let mut moved = self.pins.clone();
moved[i] = to;
let pj = |v: &[Point]| Value::Array(v.iter().map(|p| json!([p.x, p.y])).collect());
json!({"ids": crate::json_ids(&self.ids), "pins": pj(&self.pins), "moved": pj(&moved), "expand": self.expand_or_default()})
}
}
impl Tool for PuppetWarpTool {
fn id(&self) -> &'static str {
"puppetWarp"
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
match ev.kind {
PointerKind::Down => {
self.sync(cx);
if self.ids.is_empty() {
// Click an object to select it; pins appear on the next event.
if let Some(h) = drawcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options()) {
return vec![Action::Exec("select.set".into(), json!({"ids": [h.top_object(cx.isolation).0]}))];
}
return vec![];
}
if let Some(i) = self.pin_at(cx, p) {
if ev.mods.alt {
self.pins.remove(i);
self.selected = None;
return vec![];
}
self.selected = Some(i);
self.drag = Some((i, p));
return vec![Action::Begin("Puppet Warp".into())];
}
let inside = cx.doc.bounds_of(&self.ids, false).is_some_and(|b| b.inflate(cx.tol(4.0), cx.tol(4.0)).contains(p));
if inside && !ev.mods.alt {
self.pins.push(p);
self.selected = Some(self.pins.len() - 1);
}
vec![]
}
PointerKind::Drag => match self.drag {
Some((i, _)) => {
self.drag = Some((i, p));
vec![Action::Preview("object.puppetWarp".into(), self.params(i, p))]
}
None => vec![],
},
PointerKind::Up => match self.drag.take() {
Some((i, _)) => {
self.pins[i] = p;
vec![Action::Commit]
}
None => vec![],
},
_ => vec![],
}
}
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
if matches!(key, ToolKey::Delete | ToolKey::Backspace)
&& let Some(i) = self.selected.take()
&& i < self.pins.len()
{
self.pins.remove(i);
}
vec![]
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let mut out = vec![];
if self.show_mesh
&& cx.selection.objects == self.ids
&& let Some(m) = mesh_for(cx.doc, &self.ids, self.expand_or_default())
{
let mut bp = BezPath::new();
for t in &m.tris {
bp.move_to(m.verts[t[0]]);
bp.line_to(m.verts[t[1]]);
bp.line_to(m.verts[t[2]]);
bp.close_path();
}
out.push(Overlay::Path { path: bp, color: [0x9a, 0x9a, 0x9a], width: 0.5, dashed: false });
}
let r = cx.tol(5.0);
for (i, q) in self.pins.iter().enumerate() {
let q = match self.drag {
Some((d, to)) if d == i => to,
_ => *q,
};
let circle = super::ellipse_path(q, r, r, 0.0);
out.push(Overlay::Path { path: circle, color: BLUE, width: if self.selected == Some(i) { 3.0 } else { 1.5 }, dashed: false });
out.push(Overlay::Anchor { p: q, color: BLUE, filled: self.selected == Some(i), size: 3.0 });
}
out
}
fn cursor(&self, cx: &ToolContext, p: Point, mods: Mods) -> Cursor {
match self.pin_at(cx, p) {
Some(_) if mods.alt => Cursor::PenDelete,
Some(_) => Cursor::Move,
None => Cursor::PenAdd,
}
}
fn options(&self) -> Value {
json!({"showMesh": self.show_mesh, "expand": self.expand_or_default(), "pins": self.pins.iter().map(|p| json!([p.x, p.y])).collect::<Vec<_>>()})
}
fn set_option(&mut self, key: &str, value: &Value) {
match key {
"showMesh" => self.show_mesh = value.as_bool().unwrap_or(self.show_mesh),
"expand" => self.expand = value.as_f64().unwrap_or(self.expand).clamp(0.0, 1000.0),
"selectAllPins" => self.selected = None,
_ => {}
}
}
fn busy(&self) -> bool {
self.drag.is_some()
}
fn notify(&mut self, cx: &ToolContext, _what: &str) {
self.sync(cx);
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
self.ids.clear();
self.pins.clear();
if self.drag.take().is_some() { vec![Action::Commit] } else { vec![] }
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use drawcraft_doc::Selection;
#[test]
fn puppet_tool_autopins_adds_and_drags() {
let (d, id) = doc_with_rect();
let s = Selection { objects: vec![id], ..Default::default() };
let p = paint();
let c = cx(&d, &s, &p);
let mut t = PuppetWarpTool::default();
// Click inside the rect away from auto pins: adds a pin.
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 130.0, 170.0));
assert!(acts.is_empty());
assert_eq!(t.pins.len(), 4, "3 auto pins + 1");
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 130.0, 170.0));
// Drag the new pin.
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 130.0, 170.0)), vec![Action::Begin("Puppet Warp".into())]);
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 120.0, 190.0));
let Action::Preview(cmd, v) = &acts[0] else { panic!() };
assert_eq!(cmd, "object.puppetWarp");
assert_eq!(v["moved"][3], json!([120.0, 190.0]));
assert_eq!(v["pins"][3], json!([130.0, 170.0]));
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 120.0, 190.0)), vec![Action::Commit]);
// Alt-click deletes it.
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 120.0, 190.0).with_mods(Mods { alt: true, ..Default::default() }));
assert_eq!(t.pins.len(), 3);
}
}

View File

@ -0,0 +1,328 @@
//! Width tool (Shift+W).
//!
//! Hovering a stroked path shows a hollow width-point diamond with the stroke's width at that
//! spot. Dragging outward from the path creates (or, on an existing width point's handle end,
//! edits) a width point — symmetric by default, Alt changes only the side being dragged. Dragging
//! a width point's centre slides it along the path. Delete/Backspace removes the selected width
//! point. Gestures preview `stroke.widthPoint.set {id, t, left, right, index?}` (side widths in
//! points) and commit on release; Delete runs `stroke.widthPoint.remove {id, index}`.
use drawcraft_doc::{Document, NodeId, NodeKind, StrokeLayer};
use drawcraft_geom::{Point, Vec2};
use serde_json::json;
use super::pathutil::{eval_fraction, left_normal, nearest_fraction};
use super::{BLUE, diamond};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
/// Where the pointer is relative to a stroked path.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Spot {
id: NodeId,
sub: usize,
/// Fraction along the subpath.
t: f64,
p: Point,
/// Left normal.
n: Vec2,
/// Side widths (points) at `t`.
left: f64,
right: f64,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Mode {
/// Change widths; `Some(left?)` = only one side (Alt).
Width(Option<bool>),
/// Slide along the path.
Move,
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct Drag {
spot: Spot,
index: Option<usize>,
mode: Mode,
left: f64,
right: f64,
t: f64,
}
#[derive(Default)]
pub struct WidthTool {
hover: Option<Spot>,
drag: Option<Drag>,
/// Selected width point: (path, position t).
selected: Option<(NodeId, f64)>,
}
fn stroke_of(doc: &Document, id: NodeId) -> Option<&StrokeLayer> {
doc.node(id)?.appearance.stroke().filter(|s| s.visible && s.width > 0.0 && !s.paint.is_none())
}
/// Side widths (points) of a stroke at fraction `t`.
pub fn sides_at(st: &StrokeLayer, t: f64) -> (f64, f64) {
let (l, r) = st.profile.as_ref().map(|p| p.at(t)).unwrap_or((1.0, 1.0));
(l * st.width / 2.0, r * st.width / 2.0)
}
impl WidthTool {
/// The stroked path nearest to `p` within reach (stroke half-width + a few pixels).
fn spot_at(cx: &ToolContext, p: Point) -> Option<Spot> {
let consider = |id: NodeId| -> Option<(f64, Spot)> {
if !cx.doc.is_editable(id) {
return None;
}
let n = cx.doc.node(id)?;
let NodeKind::Path { path, guide: false, .. } = &n.kind else { return None };
let st = stroke_of(cx.doc, id)?;
let widest = st.profile.as_ref().map_or(1.0, |pr| pr.points.iter().map(|q| q.1.max(q.2)).fold(1.0, f64::max));
let reach = widest * st.width / 2.0 + cx.tol(4.0);
if !path.bounds()?.inflate(reach, reach).contains(p) {
return None;
}
let (sub, t, q, tan, d) = nearest_fraction(path, p)?;
if d > reach {
return None;
}
let (left, right) = sides_at(st, t);
Some((d, Spot { id, sub, t, p: q, n: left_normal(tan), left, right }))
};
let best_of = |ids: &mut dyn Iterator<Item = NodeId>| ids.filter_map(consider).min_by(|a, b| a.0.total_cmp(&b.0));
if let Some(b) = best_of(&mut cx.selection.objects.iter().copied()) {
return Some(b.1);
}
let mut all = vec![];
cx.doc.walk(|n| {
if matches!(n.kind, NodeKind::Path { .. }) {
all.push(n.id);
}
});
best_of(&mut all.into_iter().rev()).map(|b| b.1)
}
/// Width points of `id`: (index, t, on-path point, left normal, left, right).
fn points_of(cx: &ToolContext, id: NodeId, sub: usize) -> Vec<(usize, f64, Point, Vec2, f64, f64)> {
let Some(st) = stroke_of(cx.doc, id) else { return vec![] };
let Some(pr) = &st.profile else { return vec![] };
let Some(NodeKind::Path { path, .. }) = cx.doc.node(id).map(|n| &n.kind) else { return vec![] };
let Some(sp) = path.subpaths.get(sub) else { return vec![] };
pr.points
.iter()
.enumerate()
.filter_map(|(i, (t, l, r))| {
let (p, tan) = eval_fraction(sp, *t)?;
Some((i, *t, p, left_normal(tan), l * st.width / 2.0, r * st.width / 2.0))
})
.collect()
}
fn params(d: &Drag) -> serde_json::Value {
let mut v = json!({"id": d.spot.id.0, "t": d.t, "left": d.left, "right": d.right});
if let Some(i) = d.index {
v["index"] = json!(i);
}
v
}
}
impl Tool for WidthTool {
fn id(&self) -> &'static str {
"width"
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
match ev.kind {
PointerKind::Move => {
self.hover = Self::spot_at(cx, p);
vec![]
}
PointerKind::Down => {
let Some(spot) = Self::spot_at(cx, p) else {
self.selected = None;
return vec![];
};
let tol = cx.tol(5.0);
// An existing width point under the pointer: its centre (move) or a handle end (width).
let mut hit = None;
for (i, t, q, n, l, r) in Self::points_of(cx, spot.id, spot.sub) {
if q.distance(p) <= tol {
hit = Some((i, t, q, n, l, r, Mode::Move));
break;
}
if (q + n * l).distance(p) <= tol {
hit = Some((i, t, q, n, l, r, Mode::Width(ev.mods.alt.then_some(true))));
break;
}
if (q - n * r).distance(p) <= tol {
hit = Some((i, t, q, n, l, r, Mode::Width(ev.mods.alt.then_some(false))));
break;
}
}
let drag = match hit {
Some((i, t, q, n, l, r, mode)) => {
Drag { spot: Spot { t, p: q, n, left: l, right: r, ..spot }, index: Some(i), mode, left: l, right: r, t }
}
None => {
let side = (p - spot.p).dot(spot.n) >= 0.0;
Drag { spot, index: None, mode: Mode::Width(ev.mods.alt.then_some(side)), left: spot.left, right: spot.right, t: spot.t }
}
};
self.selected = Some((spot.id, drag.t));
self.drag = Some(drag);
vec![Action::Begin("Width Point".into())]
}
PointerKind::Drag => {
let Some(d) = &mut self.drag else { return vec![] };
match d.mode {
Mode::Width(side) => {
let off = (p - d.spot.p).dot(d.spot.n);
match side {
None => {
d.left = off.abs();
d.right = off.abs();
}
Some(true) => d.left = off.max(0.0),
Some(false) => d.right = (-off).max(0.0),
}
}
Mode::Move => {
if let Some(NodeKind::Path { path, .. }) = cx.doc.node(d.spot.id).map(|n| &n.kind)
&& let Some(sp) = path.subpaths.get(d.spot.sub)
{
let one = drawcraft_geom::PathData::single(sp.clone());
if let Some((_, t, q, tan, _)) = nearest_fraction(&one, p) {
d.t = t;
d.spot.p = q;
d.spot.n = left_normal(tan);
}
}
}
}
let v = Self::params(d);
vec![Action::Preview("stroke.widthPoint.set".into(), v)]
}
PointerKind::Up => match self.drag.take() {
Some(d) => {
self.selected = Some((d.spot.id, d.t));
vec![Action::Commit]
}
None => vec![],
},
_ => vec![],
}
}
fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
if !matches!(key, ToolKey::Delete | ToolKey::Backspace) {
return vec![];
}
let Some((id, t)) = self.selected else { return vec![] };
let Some(pr) = stroke_of(cx.doc, id).and_then(|s| s.profile.as_ref()) else { return vec![] };
let Some((index, _)) = pr.points.iter().enumerate().min_by(|a, b| (a.1.0 - t).abs().total_cmp(&(b.1.0 - t).abs())) else { return vec![] };
self.selected = None;
vec![Action::Exec("stroke.widthPoint.remove".into(), json!({"id": id.0, "index": index}))]
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let mut out = vec![];
let r = cx.tol(4.0);
let focus = self.drag.map(|d| d.spot).or(self.hover);
let show = |id: NodeId, sub: usize, out: &mut Vec<Overlay>| {
for (_, t, q, n, l, rr) in Self::points_of(cx, id, sub) {
let sel = self.selected.is_some_and(|(sid, st)| sid == id && (st - t).abs() < 1e-6);
out.push(Overlay::Line { a: q + n * l, b: q - n * rr, color: BLUE, dashed: false });
out.push(Overlay::Path { path: diamond(q, r), color: BLUE, width: if sel { 2.5 } else { 1.0 }, dashed: false });
out.push(Overlay::Handle { p: q + n * l, color: BLUE });
out.push(Overlay::Handle { p: q - n * rr, color: BLUE });
}
};
if let Some(s) = focus {
show(s.id, s.sub, &mut out);
}
if let Some((id, _)) = self.selected
&& focus.is_none_or(|s| s.id != id)
{
show(id, 0, &mut out);
}
if let Some(d) = &self.drag {
let q = d.spot.p;
out.push(Overlay::Line { a: q + d.spot.n * d.left, b: q - d.spot.n * d.right, color: BLUE, dashed: false });
out.push(Overlay::Path { path: diamond(q, r), color: BLUE, width: 2.0, dashed: false });
out.push(Overlay::Measure { p: q + d.spot.n * (d.left + r * 3.0), text: format!("W: {:.2} pt", d.left + d.right) });
} else if let Some(h) = &self.hover {
out.push(Overlay::Line { a: h.p + h.n * h.left, b: h.p - h.n * h.right, color: BLUE, dashed: true });
out.push(Overlay::Path { path: diamond(h.p, r), color: BLUE, width: 1.0, dashed: false });
}
out
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
Cursor::Crosshair
}
fn busy(&self) -> bool {
self.drag.is_some()
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
self.hover = None;
if self.drag.take().is_some() { vec![Action::Commit] } else { vec![] }
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use drawcraft_doc::{Appearance, Node, Selection, WidthProfile};
use drawcraft_geom::{PathData, SubPath};
fn doc_line() -> (Document, NodeId) {
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let id = d.alloc_id();
let mut ap = Appearance::default_art();
ap.stroke_mut().unwrap().width = 10.0;
let path = PathData::single(SubPath::polyline(&[Point::new(100.0, 200.0), Point::new(300.0, 200.0)], false));
d.insert(Some(l), 0, Node::path(id, path, ap)).unwrap();
(d, id)
}
#[test]
fn drag_outward_creates_symmetric_width_point() {
let (d, id) = doc_line();
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let mut t = WidthTool::default();
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 201.0)), vec![Action::Begin("Width Point".into())]);
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 150.0, 220.0));
let Action::Preview(cmd, v) = &acts[0] else { panic!("{acts:?}") };
assert_eq!(cmd, "stroke.widthPoint.set");
assert_eq!(v["id"], json!(id.0));
assert!((v["t"].as_f64().unwrap() - 0.25).abs() < 1e-3);
assert!((v["left"].as_f64().unwrap() - 20.0).abs() < 1e-6 && (v["right"].as_f64().unwrap() - 20.0).abs() < 1e-6);
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 150.0, 220.0)), vec![Action::Commit]);
}
#[test]
fn alt_drag_changes_one_side_and_move_slides() {
let (mut d, id) = doc_line();
let n = d.node_mut(id).unwrap();
n.appearance.stroke_mut().unwrap().profile = Some(WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 2.0, 2.0), (1.0, 1.0, 1.0)] });
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let mut t = WidthTool::default();
// Alt-drag the left handle end (y = 200 - 10) further up.
let alt = Mods { alt: true, ..Default::default() };
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 200.0, 190.0).with_mods(alt));
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 200.0, 170.0).with_mods(alt));
let Action::Preview(_, v) = &acts[0] else { panic!() };
assert_eq!(v["index"], json!(1));
assert!((v["left"].as_f64().unwrap() - 30.0).abs() < 1e-6 && (v["right"].as_f64().unwrap() - 10.0).abs() < 1e-6);
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 200.0, 170.0));
// Drag the centre along the path.
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 200.0, 200.0));
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 205.0));
let Action::Preview(_, v) = &acts[0] else { panic!() };
assert!((v["t"].as_f64().unwrap() - 0.75).abs() < 1e-3);
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 250.0, 205.0));
let del = t.key(&c, ToolKey::Delete, Mods::default());
assert!(matches!(&del[0], Action::Exec(c, _) if c == "stroke.widthPoint.remove"));
}
}

View File

@ -11,6 +11,7 @@ pub mod bbox;
pub mod builder;
pub mod catalog;
pub mod direct;
pub mod distort;
pub mod draw2;
pub mod guides;
pub mod meshblend;
@ -255,6 +256,7 @@ pub fn create(id: &str) -> Box<dyn Tool> {
.or_else(|| draw2::create(other))
.or_else(|| xform::create(other))
.or_else(|| meshblend::create(other))
.or_else(|| distort::create(other))
.unwrap_or_else(|| Box::new(NoopTool(tool_info(other).map(|t| t.id).unwrap_or("selection")))),
}
}

View File

@ -35,10 +35,14 @@ pub fn app_bar(app: &mut DrawcraftApp, ui: &mut Ui) {
let right = egui::Rect::from_min_max(egui::pos2(full.right() - 340.0, full.top()), full.right_bottom());
let mut rui = ui.new_child(egui::UiBuilder::new().max_rect(right).layout(egui::Layout::right_to_left(egui::Align::Center)));
let ui = &mut rui;
let (wr, wresp) = ui.allocate_exact_size(vec2(112.0, 24.0), Sense::click());
// Workspace switcher: shows the current workspace, opens Window → Workspace.
let ws = ui.painter().layout_no_wrap(app.ui.workspace.clone(), egui::FontId::proportional(12.0), t.text);
let (wr, wresp) = ui.allocate_exact_size(vec2((ws.size().x + 36.0).clamp(112.0, 190.0), 24.0), Sense::click());
ui.painter().rect_filled(wr, CornerRadius::same(4), if wresp.hovered() { t.hover } else { t.panel });
ui.painter().text(wr.left_center() + vec2(10.0, 0.0), egui::Align2::LEFT_CENTER, "Essentials", egui::FontId::proportional(12.0), t.text);
ui.painter().with_clip_rect(wr.shrink2(vec2(4.0, 0.0))).galley(wr.left_center() + vec2(10.0, -ws.size().y / 2.0), ws, t.text);
icons::paint(ui, "chevron-down", egui::Rect::from_center_size(wr.right_center() - vec2(12.0, 0.0), vec2(12.0, 12.0)), t.text_dim);
let wresp = wresp.on_hover_text("Switch workspace");
egui::Popup::menu(&wresp).show(|ui| crate::workspaces::popup(app, ui));
ui.add_space(8.0);
// Search box → command palette.
let (r, resp) = ui.allocate_exact_size(vec2(200.0, 24.0), Sense::click());

View File

@ -110,6 +110,7 @@ fn title(kind: &str) -> &'static str {
"splitIntoGrid" => "Split Into Grid",
"documentSetup" => "Document Setup",
"preferences" => "Preferences",
"shortcuts" => "Keyboard Shortcuts",
"artboardOptions" => "Artboard Options",
"allTools" => "All Tools",
"exportForScreens" => "Export for Screens",
@ -121,6 +122,12 @@ fn title(kind: &str) -> &'static str {
/// Apply the open dialog (OK).
pub fn confirm(app: &mut DrawcraftApp) -> Result<Value, String> {
let Some(d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) };
match d.kind.as_str() {
"preferences" => return crate::prefs_dialog::confirm(app),
"shortcuts" => return crate::shortcut_editor::confirm(app),
"newWorkspace" | "manageWorkspaces" => return crate::workspaces::confirm(app),
_ => {}
}
let copy = d.bool("copy");
if d.kind == "recolor" {
let map = recolor_map(&d);
@ -206,11 +213,6 @@ pub fn confirm(app: &mut DrawcraftApp) -> Result<Value, String> {
"simplify" => app.run("object.path.simplify", json!({"tolerance": d.f64("tolerance", 1.0)})),
"splitIntoGrid" => app.run("object.path.splitIntoGrid", json!({"rows": d.f64("rows", 2.0), "columns": d.f64("columns", 2.0), "gutter": d.f64("gutter", 12.0)})),
"documentSetup" => app.run("document.setUnits", json!({"units": d.str("units")})),
"preferences" => {
app.session.prefs.keyboard_increment = d.f64("keyboardIncrement", 1.0).max(0.001);
app.session.prefs.scale_strokes = d.bool("scaleStrokes");
Ok(Value::Null)
}
_ => Ok(Value::Null),
};
app.ui.dialog = None;
@ -256,6 +258,12 @@ pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) {
app.ui.about = open;
}
let Some(mut d) = app.ui.dialog.clone() else { return };
match d.kind.as_str() {
"preferences" => return crate::prefs_dialog::show(app, ctx),
"shortcuts" => return crate::shortcut_editor::show(app, ctx),
"newWorkspace" | "manageWorkspaces" => return crate::workspaces::show(app, ctx),
_ => {}
}
let t = Tokens::get(ctx);
let mut ok = false;
let mut cancel = false;
@ -331,9 +339,6 @@ pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) {
}
check(ui, &mut d, "copy", "Copy (make a transformed copy)");
}
if d.kind == "preferences" {
check(ui, &mut d, "scaleStrokes", "Scale Strokes & Effects");
}
}
}
ui.add_space(16.0);

View File

@ -17,12 +17,15 @@ pub mod io;
pub mod menus;
pub mod palette;
pub mod panels;
pub mod prefs_dialog;
pub mod render_worker;
pub mod shortcut_editor;
pub mod shortcuts;
pub mod state;
pub mod theme;
pub mod toolbar;
pub mod widgets;
pub mod workspaces;
use std::sync::mpsc::{Receiver, Sender};
use std::sync::{Arc, Mutex};
@ -330,6 +333,8 @@ impl DrawcraftApp {
}
self.last_time = now;
self.sync_views();
shortcut_editor::sync(&self.ui);
prefs_dialog::apply_runtime(self, ctx);
self.drain_control(ctx);
if !self.synthetic.is_empty() {
ctx.request_repaint();

View File

@ -54,7 +54,15 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
("file.exportForScreens", "Export for Screens…", "Cmd+Alt+E", "{path?, scale?}"),
("file.documentSetup", "Document Setup…", "Cmd+Alt+P", "{}"),
("file.newDialog", "New…", "Cmd+N", "{} opens the New Document dialog"),
("edit.preferences", "Preferences…", "Cmd+K", "{}"),
("edit.preferences", "Preferences…", "Cmd+K", "{category?} open Preferences (engine: prefs.get / prefs.set / prefs.list)"),
("edit.keyboardShortcuts", "Keyboard Shortcuts…", "Cmd+Alt+Shift+K", "{}"),
("shortcuts.set", "Set Keyboard Shortcut", "", "{id: command id or tool:<id>, shortcut: \"Cmd+Shift+K\" | \"\" (none) | null (default), force?}"),
("shortcuts.list", "List Keyboard Shortcuts", "", "{query?} → [{id, label, group, shortcut, default, overridden}]"),
("shortcuts.conflicts", "Keyboard Shortcut Conflicts", "", "{} → [{shortcut, ids}]"),
("shortcuts.reset", "Reset Keyboard Shortcuts", "", "{}"),
("shortcuts.preset", "Keyboard Shortcut Set", "", "{name: \"DrawCraft Defaults\" | \"Illustrator Defaults\"}"),
("shortcuts.export", "Export Keyboard Shortcuts…", "", "{path?}"),
("shortcuts.import", "Import Keyboard Shortcuts…", "", "{path? | data?}"),
("view.outline", "Outline", "Cmd+Y", "{} toggle Outline/Preview"),
("view.pixelPreview", "Pixel Preview", "Cmd+Alt+Y", "{}"),
("view.zoomIn", "Zoom In", "Cmd+=", "{}"),
@ -84,7 +92,13 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
("window.dock", "Panels", "Tab", "{} show/hide all panels"),
("window.panel", "Show Panel", "", "{panel: id} e.g. layers, swatches, stroke"),
("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"),
("window.workspace.reset", "Reset Essentials", "", "{}"),
("window.workspace", "Workspace", "", "{name} switch workspace (Essentials, Essentials Classic, Painting, …)"),
("window.workspace.reset", "Reset Essentials", "", "{} reset the current workspace"),
("window.workspace.new", "New Workspace…", "", "{name?} save the current layout"),
("window.workspace.manage", "Manage Workspaces…", "", "{}"),
("window.workspace.delete", "Delete Workspace", "", "{name}"),
("window.workspace.rename", "Rename Workspace", "", "{name, to}"),
("window.workspace.list", "List Workspaces", "", "{}"),
("window.newWindow", "New Window", "", "{}"),
("tool.select", "Select Tool", "", "{tool: id} (see tools)"),
("tool.setOption", "Tool Option", "", "{key, value}"),
@ -103,6 +117,9 @@ pub fn run_ui_command(app: &mut DrawcraftApp, id: &str, p: &Value) -> Option<Res
if let Some(r) = crate::panels::character::intercept_text_command(app, id) {
return Some(r);
}
if let Some(r) = crate::shortcut_editor::run_command(app, id, p).or_else(|| crate::workspaces::run_command(app, id, p)) {
return Some(r);
}
let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string);
let flag = |b: &mut bool| {
*b = !*b;
@ -166,10 +183,7 @@ pub fn run_ui_command(app: &mut DrawcraftApp, id: &str, p: &Value) -> Option<Res
Ok(Value::Null)
}
"edit.preferences" => {
app.ui.dialog = Some(crate::state::Dialog::new(
"preferences",
json!({"keyboardIncrement": app.session.prefs.keyboard_increment, "scaleStrokes": app.session.prefs.scale_strokes}),
));
crate::prefs_dialog::open(app, s("category").as_deref());
Ok(Value::Null)
}
"view.outline" => flag(&mut app.ui.view.outline),
@ -269,16 +283,12 @@ pub fn run_ui_command(app: &mut DrawcraftApp, id: &str, p: &Value) -> Option<Res
"window.brightness" => match s("brightness").as_deref().and_then(Brightness::parse) {
Some(b) => {
app.ui.brightness = b;
app.session.prefs.ui_brightness = b.id().into();
app.canvas.key = None;
Ok(json!(b.id()))
}
None => Err("brightness must be dark|mediumDark|mediumLight|light".into()),
},
"window.workspace.reset" => {
let b = app.ui.brightness;
app.ui = crate::state::UiState { brightness: b, ..Default::default() };
Ok(Value::Null)
}
"window.newWindow" => Err("multiple windows land with M11.5".into()),
"tool.select" => match s("tool") {
Some(t) if drawcraft_tools::tool_info(&t).is_some() => {
@ -370,6 +380,7 @@ pub fn checked(app: &DrawcraftApp, id: &str, p: &Value) -> Option<bool> {
_ => app.ui.open_panel.as_deref() == Some(panel),
}
}
"window.workspace" => p.get("name").and_then(Value::as_str) == Some(app.ui.workspace.as_str()),
"window.brightness" => p.get("brightness").and_then(Value::as_str).and_then(Brightness::parse) == Some(app.ui.brightness),
"file.documentColorMode" => {
let cmyk = app.session.active().is_some_and(|d| d.doc.color_mode == drawcraft_engine::doc::ColorMode::Cmyk);
@ -384,6 +395,7 @@ pub fn dynamic_label(app: &DrawcraftApp, id: &str, label: &str) -> String {
let v = &app.ui.view;
match id {
"view.outline" => if v.outline { "Preview" } else { "Outline" }.into(),
"window.workspace.reset" => format!("Reset {}", app.ui.workspace),
"view.edges" => if v.edges { "Hide Edges" } else { "Show Edges" }.into(),
"view.artboards" => if v.artboards { "Hide Artboards" } else { "Show Artboards" }.into(),
"view.rulers" => if v.rulers { "Hide Rulers" } else { "Show Rulers" }.into(),
@ -398,11 +410,9 @@ pub fn dynamic_label(app: &DrawcraftApp, id: &str, label: &str) -> String {
}
}
/// Effective shortcut of a command: the user's override (Edit → Keyboard Shortcuts) or the default.
pub fn shortcut_of(id: &str) -> Option<&'static str> {
if let Some(c) = drawcraft_engine::find_command(id) {
return c.shortcut;
}
UI_COMMANDS.iter().find(|c| c.0 == id).map(|c| c.2).filter(|s| !s.is_empty())
crate::shortcut_editor::command_shortcut(id)
}
/// Is a command currently enabled?
@ -538,7 +548,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
todos("Color Settings…", "Cmd+Shift+K"),
todo("Assign Profile…"),
Sep,
todos("Keyboard Shortcuts…", "Cmd+Alt+Shift+K"),
c("Keyboard Shortcuts…", "edit.keyboardShortcuts"),
c("Preferences…", "edit.preferences"),
],
),
@ -893,17 +903,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
vec![
c("New Window", "window.newWindow"),
sub("Arrange", vec![todo("Cascade"), todo("Tile"), todo("Float in Window"), todo("Consolidate All Windows")]),
sub(
"Workspace",
vec![
todo("Essentials"),
todo("Essentials Classic"),
todo("Painting"),
todo("Typography"),
Sep,
c("Reset Essentials", "window.workspace.reset"),
],
),
sub("Workspace", crate::workspaces::menu_items()),
Sep,
c("Control", "window.control"),
c("Contextual Task Bar", "window.taskBar"),

View File

@ -15,13 +15,13 @@ pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) {
for c in drawcraft_engine::command_specs() {
let path = c.menu.join(" › ");
let label = if path.is_empty() { c.label.to_string() } else { format!("{path} › {}", c.label) };
items.push((label, c.id.to_string(), c.shortcut.unwrap_or("").to_string()));
items.push((label, c.id.to_string(), menus::shortcut_of(c.id).unwrap_or("").to_string()));
}
for c in menus::UI_COMMANDS {
items.push((c.1.to_string(), c.0.to_string(), c.2.to_string()));
items.push((c.1.to_string(), c.0.to_string(), menus::shortcut_of(c.0).unwrap_or("").to_string()));
}
for tool in drawcraft_tools::catalog::all_tools() {
items.push((tool.label.to_string(), format!("tool:{}", tool.id), tool.shortcut.unwrap_or("").to_string()));
items.push((tool.label.to_string(), format!("tool:{}", tool.id), crate::shortcut_editor::tool_shortcut(tool.id).unwrap_or("").to_string()));
}
let matches: Vec<&(String, String, String)> = items
.iter()

View File

@ -0,0 +1,352 @@
//! Edit → Preferences (⌘K): Illustrator-style category list + fields, rendered from the engine's
//! preference table ([`PREF_SPECS`]). The dialog edits a working copy in `Dialog::fields`
//! (one field per preference key, so agents can `ui.dialog.set` any of them) and OK applies it
//! through `prefs.set`. Also: persistence of engine prefs inside `UiState` and the per-frame
//! application of the UI-side preferences (brightness, canvas colour, UI scaling, render threads).
use drawcraft_engine::Prefs;
use drawcraft_engine::cmd::prefscmds::{PREF_CATEGORIES, PREF_SPECS, PrefKind};
use serde_json::{Map, Value, json};
use crate::state::Dialog;
use crate::theme::{self, Brightness, Tokens};
use crate::{DrawcraftApp, widgets};
/// UI-only fields shown in the dialog (backed by `UiState`, not engine prefs).
const UI_FIELDS: &[(&str, &str, &str)] = &[
("__smartGuides", "Smart Guides", "Smart Guides (View → Smart Guides)"),
("__snapToGrid", "Guides & Grid", "Snap to Grid"),
("__taskBar", "General", "Enable Contextual Task Bar"),
];
pub fn open(app: &mut DrawcraftApp, category: Option<&str>) {
let mut fields: Map<String, Value> = app.session.prefs.to_json().as_object().cloned().unwrap_or_default();
let cat = category.and_then(|c| PREF_CATEGORIES.iter().find(|x| x.eq_ignore_ascii_case(c))).copied().unwrap_or(PREF_CATEGORIES[0]);
fields.insert("__category".into(), json!(cat));
fields.insert("__smartGuides".into(), json!(app.ui.view.smart_guides));
fields.insert("__snapToGrid".into(), json!(app.ui.view.snap_to_grid));
fields.insert("__taskBar".into(), json!(app.ui.task_bar));
app.ui.dialog = Some(Dialog { kind: "preferences".into(), fields });
}
/// OK: validate and apply every preference in the dialog (nothing changes on error).
pub fn confirm(app: &mut DrawcraftApp) -> Result<Value, String> {
let Some(d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) };
let values: Map<String, Value> = d.fields.iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone())).collect();
app.run("prefs.set", json!({ "values": values }))?;
app.ui.view.smart_guides = d.fields.get("__smartGuides").and_then(Value::as_bool).unwrap_or(app.ui.view.smart_guides);
app.ui.view.snap_to_grid = d.fields.get("__snapToGrid").and_then(Value::as_bool).unwrap_or(app.ui.view.snap_to_grid);
app.ui.task_bar = d.fields.get("__taskBar").and_then(Value::as_bool).unwrap_or(app.ui.task_bar);
app.ui.dialog = None;
app.ui.engine_prefs = app.session.prefs.to_json();
Ok(Value::Null)
}
/// Copy the engine prefs into `UiState` so the host persists them with the UI state.
pub fn snapshot(app: &mut DrawcraftApp) {
app.ui.engine_prefs = app.session.prefs.to_json();
}
/// Restore engine prefs from `UiState` after the host loaded saved UI state.
pub fn restore(app: &mut DrawcraftApp) {
let mut p: Prefs = serde_json::from_value(app.ui.engine_prefs.clone()).unwrap_or_default();
if app.ui.engine_prefs.is_null() {
// Older preference files: keep the saved brightness.
p.ui_brightness = app.ui.brightness.id().into();
}
app.session.apply_prefs(p);
}
#[derive(Clone, Copy, PartialEq)]
struct Applied {
brightness: Brightness,
white_canvas: bool,
threads: i32,
}
/// Per frame: push UI-side preferences into egui / the renderer when they change.
pub fn apply_runtime(app: &mut DrawcraftApp, ctx: &egui::Context) {
let p = &app.session.prefs;
if let Some(b) = Brightness::parse(&p.ui_brightness)
&& b != app.ui.brightness
{
app.ui.brightness = b;
}
let want = Applied { brightness: app.ui.brightness, white_canvas: p.canvas_color == "white", threads: p.render_threads };
let id = egui::Id::new("dc-applied-prefs");
let prev: Option<Applied> = ctx.data(|d| d.get_temp::<Option<Applied>>(id)).flatten();
if prev != Some(want) {
theme::apply(ctx, want.brightness);
if want.white_canvas {
let mut t = Tokens::get(ctx);
t.pasteboard = egui::Color32::WHITE;
ctx.data_mut(|d| d.insert_temp(egui::Id::NULL, t));
}
if prev.is_some_and(|pr| pr.threads != want.threads) {
drawcraft_render::set_default_threads(u16::try_from(want.threads).ok());
app.canvas.renderer.threads = drawcraft_render::default_threads();
app.canvas.worker = None;
app.canvas.worker_started = false;
}
app.canvas.key = None;
ctx.data_mut(|d| d.insert_temp(id, Some(want)));
}
let z = app.session.prefs.ui_scaling.clamp(0.75, 2.0) as f32;
if (ctx.zoom_factor() - z).abs() > 1e-3 {
ctx.set_zoom_factor(z);
}
}
pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) {
let Some(mut d) = app.ui.dialog.clone() else { return };
let t = Tokens::get(ctx);
let (mut ok, mut cancel, mut reset) = (false, false, false);
egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| {
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
});
let cat = d.str("__category");
let cat = PREF_CATEGORIES.iter().find(|c| **c == cat).copied().unwrap_or(PREF_CATEGORIES[0]);
egui::Window::new("Preferences")
.id(egui::Id::new("dialog-preferences"))
.order(egui::Order::Foreground)
.collapsible(false)
.resizable(false)
.title_bar(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, -20.0])
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(20)))
.show(ctx, |ui| {
ui.set_width(760.0);
ui.label(egui::RichText::new("Preferences").font(theme::semibold(16.0)).color(t.text));
ui.add_space(12.0);
ui.horizontal_top(|ui| {
// Category list.
egui::Frame::NONE.fill(t.panel_darker).corner_radius(egui::CornerRadius::same(4)).inner_margin(egui::Margin::same(6)).show(ui, |ui| {
ui.set_width(196.0);
ui.set_min_height(430.0);
ui.spacing_mut().item_spacing.y = 1.0;
for c in PREF_CATEGORIES {
let sel = *c == cat;
let text = egui::RichText::new(*c).size(12.5).color(if sel { t.text_strong } else { t.text });
let r = ui.add_sized([ui.available_width(), 24.0], egui::Button::selectable(sel, text).frame_when_inactive(false));
if r.clicked() {
d.fields.insert("__category".into(), json!(c));
}
}
});
ui.add_space(14.0);
// Fields.
ui.vertical(|ui| {
ui.set_width(530.0);
ui.label(egui::RichText::new(cat).font(theme::semibold(14.0)).color(t.text_strong));
ui.add_space(8.0);
egui::ScrollArea::vertical().id_salt(("prefs", cat)).max_height(400.0).auto_shrink([false, false]).show(ui, |ui| {
category_fields(ui, &mut d, cat);
});
});
});
ui.add_space(14.0);
ui.horizontal(|ui| {
if widgets::secondary_button(ui, "Reset Preferences").on_hover_text("Restore every preference to its default (applied on OK)").clicked() {
reset = true;
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::primary_button(ui, "OK").clicked() {
ok = true;
}
ui.add_space(8.0);
if widgets::secondary_button(ui, "Cancel").clicked() {
cancel = true;
}
ui.add_space(16.0);
let i = PREF_CATEGORIES.iter().position(|c| *c == cat).unwrap_or(0);
if ui.add_enabled(i + 1 < PREF_CATEGORIES.len(), egui::Button::new("Next")).clicked() {
d.fields.insert("__category".into(), json!(PREF_CATEGORIES[i + 1]));
}
if ui.add_enabled(i > 0, egui::Button::new("Previous")).clicked() {
d.fields.insert("__category".into(), json!(PREF_CATEGORIES[i - 1]));
}
});
});
});
if reset {
for (k, v) in Prefs::default().to_json().as_object().cloned().unwrap_or_default() {
d.fields.insert(k, v);
}
}
app.ui.dialog = Some(d);
if cancel {
app.ui.dialog = None;
} else if ok && let Err(e) = confirm(app) {
app.status(e);
}
}
fn category_fields(ui: &mut egui::Ui, d: &mut Dialog, cat: &str) {
let t = Tokens::get(ui.ctx());
let mut section = "";
let mut first = true;
let specs: Vec<_> = PREF_SPECS.iter().filter(|s| s.category == cat).collect();
for (key, c, label) in UI_FIELDS {
if *c == cat && cat != "Smart Guides" {
bool_row(ui, d, key, label);
}
}
if cat == "Smart Guides" {
bool_row(ui, d, "__smartGuides", UI_FIELDS[0].2);
ui.add_space(4.0);
}
for sp in specs {
if sp.section != section {
section = sp.section;
if !section.is_empty() {
if !first {
ui.add_space(8.0);
}
ui.label(egui::RichText::new(section).font(theme::semibold(12.5)).color(t.text_strong));
ui.add_space(2.0);
}
}
first = false;
let v = d.fields.get(sp.key).cloned().unwrap_or(Value::Null);
match sp.kind {
PrefKind::Bool => bool_row(ui, d, sp.key, sp.label),
PrefKind::Num { min, max, unit } => {
labeled(ui, sp.label, |ui| {
let mut x = v.as_f64().unwrap_or(min);
let r = if sp.key == "uiScaling" {
ui.add(egui::Slider::new(&mut x, min..=max).step_by(0.05).text("Smaller ↔ Larger"))
} else {
let speed = if max - min > 100.0 { 0.5 } else { 0.05 };
ui.add(egui::DragValue::new(&mut x).range(min..=max).speed(speed).max_decimals(3).suffix(format!(" {unit}")))
};
if r.changed() {
d.fields.insert(sp.key.into(), json!(x));
}
});
}
PrefKind::Int { min, max } => {
labeled(ui, sp.label, |ui| {
let mut x = v.as_i64().unwrap_or(min);
let r = if sp.key == "anchorSize" {
ui.add(egui::Slider::new(&mut x, min..=max).show_value(false).text("Size"))
} else {
ui.add(egui::DragValue::new(&mut x).range(min..=max).speed(0.2))
};
if r.changed() {
d.fields.insert(sp.key.into(), json!(x));
}
});
}
PrefKind::Choice(opts) => {
labeled(ui, sp.label, |ui| {
let cur = v.as_str().unwrap_or("");
let cur_label = opts.iter().find(|o| o.0 == cur).map(|o| o.1).unwrap_or(cur);
let labels: Vec<&str> = opts.iter().map(|o| o.1).collect();
if let Some(i) = widgets::dropdown(ui, sp.key, cur_label, &labels, 260.0) {
d.fields.insert(sp.key.into(), json!(opts[i].0));
}
});
}
PrefKind::Color => {
labeled(ui, sp.label, |ui| {
let hex = v.as_str().unwrap_or("#000000");
let c = drawcraft_color::Color::from_hex(hex).map(|c| c.to_rgba8(1.0)).unwrap_or([0, 0, 0, 255]);
let mut rgb = [c[0], c[1], c[2]];
if ui.color_edit_button_srgb(&mut rgb).changed() {
d.fields.insert(sp.key.into(), json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])));
}
ui.label(egui::RichText::new(hex).color(t.text_dim).size(11.0));
});
}
PrefKind::Text => {
labeled(ui, sp.label, |ui| {
let mut s = v.as_str().unwrap_or("").to_string();
if ui.add(egui::TextEdit::singleline(&mut s).desired_width(260.0)).changed() {
d.fields.insert(sp.key.into(), json!(s));
}
});
}
}
}
}
fn bool_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
let mut b = d.bool(key);
if ui.checkbox(&mut b, label).changed() {
d.fields.insert(key.into(), json!(b));
}
}
fn labeled(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui)) {
let t = Tokens::get(ui.ctx());
ui.horizontal(|ui| {
ui.add_sized([210.0, 22.0], egui::Label::new(egui::RichText::new(format!("{label}:")).color(t.text)).truncate());
add(ui);
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Services;
use drawcraft_engine::Session;
fn app() -> DrawcraftApp {
DrawcraftApp::new(Session::new(), Services::default())
}
#[test]
fn dialog_ok_applies_prefs_and_ui_fields() {
let mut a = app();
open(&mut a, Some("general"));
let d = a.ui.dialog.as_mut().unwrap();
assert_eq!(d.str("__category"), "General");
d.fields.insert("keyboardIncrement".into(), json!(4));
d.fields.insert("uiBrightness".into(), json!("light"));
d.fields.insert("__smartGuides".into(), json!(false));
confirm(&mut a).unwrap();
assert!(a.ui.dialog.is_none());
assert_eq!(a.session.prefs.keyboard_increment, 4.0);
assert_eq!(a.session.prefs.ui_brightness, "light");
assert!(!a.ui.view.smart_guides);
assert_eq!(a.ui.engine_prefs["keyboardIncrement"], json!(4.0));
}
#[test]
fn dialog_ok_with_invalid_value_keeps_dialog_and_prefs() {
let mut a = app();
open(&mut a, None);
a.ui.dialog.as_mut().unwrap().fields.insert("anchorSize".into(), json!(42));
assert!(confirm(&mut a).is_err());
assert!(a.ui.dialog.is_some());
assert_eq!(a.session.prefs, Prefs::default());
}
#[test]
fn prefs_persist_through_ui_state() {
let mut a = app();
a.run("prefs.set", json!({"key": "gridlineEvery", "value": 50})).unwrap();
snapshot(&mut a);
let saved = serde_json::to_vec(&a.ui).unwrap();
let mut b = app();
b.ui = serde_json::from_slice::<crate::UiState>(&saved).unwrap().sanitized();
restore(&mut b);
assert_eq!(b.session.prefs.gridline_every, 50.0);
}
#[test]
fn old_pref_files_keep_their_brightness() {
let mut a = app();
a.ui.brightness = Brightness::Light;
a.ui.engine_prefs = Value::Null;
restore(&mut a);
assert_eq!(a.session.prefs.ui_brightness, "light");
}
#[test]
fn every_category_renders_fields() {
for c in PREF_CATEGORIES {
assert!(PREF_SPECS.iter().any(|s| s.category == *c));
}
}
}

View File

@ -0,0 +1,783 @@
//! Edit → Keyboard Shortcuts: user overrides of tool and command shortcuts, conflict detection,
//! presets, import/export, and the dialog.
//!
//! Overrides live in [`UiState::shortcut_overrides`] (persisted) keyed by command id or
//! `tool:<id>`; an empty string removes a shortcut. A process-wide mirror (synced every frame by
//! [`sync`]) lets `menus::shortcut_of`, the egui dispatcher, the native menu and tooltips consult
//! them without threading the app state through.
use std::collections::{BTreeMap, HashSet};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Mutex, OnceLock, RwLock};
use egui::{Key, KeyboardShortcut, Modifiers};
use serde_json::{Value, json};
use crate::state::{Dialog, UiState};
use crate::theme::{self, Tokens};
use crate::{DrawcraftApp, menus, widgets};
pub const PRESETS: &[&str] = &["DrawCraft Defaults", "Illustrator Defaults"];
pub const CUSTOM: &str = "Custom";
/// Bumped whenever overrides or the workspace list change (the native menu rebuilds on it).
pub static GENERATION: AtomicU64 = AtomicU64::new(0);
fn store() -> &'static RwLock<BTreeMap<String, &'static str>> {
static S: OnceLock<RwLock<BTreeMap<String, &'static str>>> = OnceLock::new();
S.get_or_init(Default::default)
}
/// Leak-once interning for override strings (bounded by the chords a user ever assigns).
pub fn intern(s: &str) -> &'static str {
static SET: OnceLock<Mutex<HashSet<&'static str>>> = OnceLock::new();
let mut set = SET.get_or_init(Default::default).lock().unwrap_or_else(|e| e.into_inner());
if let Some(v) = set.get(s) {
return v;
}
let v: &'static str = Box::leak(s.to_string().into_boxed_str());
set.insert(v);
v
}
/// Mirror `ui.shortcut_overrides` into the global store (cheap when unchanged).
pub fn sync(ui: &UiState) {
let same = store().read().map(|m| m.len() == ui.shortcut_overrides.len() && m.iter().all(|(k, v)| ui.shortcut_overrides.get(k).map(String::as_str) == Some(*v))).unwrap_or(false);
if !same {
if let Ok(mut m) = store().write() {
*m = ui.shortcut_overrides.iter().map(|(k, v)| (k.clone(), intern(v))).collect();
}
GENERATION.fetch_add(1, Ordering::Relaxed);
}
crate::workspaces::sync(ui);
}
fn global_override(key: &str) -> Option<&'static str> {
store().read().ok().and_then(|m| m.get(key).copied())
}
/// Registry default shortcut of a command (engine or UI command).
pub fn default_command_shortcut(id: &str) -> Option<&'static str> {
if let Some(c) = drawcraft_engine::find_command(id) {
return c.shortcut;
}
menus::UI_COMMANDS.iter().find(|c| c.0 == id).map(|c| c.2).filter(|s| !s.is_empty())
}
/// Default shortcut of an entry key (`tool:<id>` or a command id).
pub fn default_of(key: &str) -> Option<&'static str> {
match key.strip_prefix("tool:") {
Some(t) => drawcraft_tools::tool_info(t).and_then(|t| t.shortcut),
None => default_command_shortcut(key),
}
}
/// Effective shortcut of `key` under an explicit override map: override > default; "" = none.
pub fn effective_in(overrides: &BTreeMap<String, String>, key: &str) -> Option<String> {
match overrides.get(key) {
Some(s) if s.is_empty() => None,
Some(s) => Some(s.clone()),
None => default_of(key).map(str::to_string),
}
}
/// Effective shortcut of a command, honouring the user's overrides.
pub fn command_shortcut(id: &str) -> Option<&'static str> {
match global_override(id) {
Some("") => None,
Some(s) => Some(s),
None => default_command_shortcut(id),
}
}
/// Effective shortcut of a tool.
pub fn tool_shortcut(tool: &str) -> Option<&'static str> {
match global_override(&format!("tool:{tool}")) {
Some("") => None,
Some(s) => Some(s),
None => drawcraft_tools::tool_info(tool).and_then(|t| t.shortcut),
}
}
/// Tool whose (effective) shortcut is `key` ("V", "Shift+M").
pub fn tool_for_key(key: &str) -> Option<&'static str> {
let n = normalize(key)?;
drawcraft_tools::catalog::all_tools().find(|t| tool_shortcut(t.id).and_then(normalize).as_deref() == Some(n.as_str())).map(|t| t.id)
}
/// Command whose effective shortcut is exactly `key` (for single-key command shortcuts: X, D, /).
pub fn command_for_key(key: &str) -> Option<&'static str> {
let n = normalize(key)?;
let hit = |id: &'static str| command_shortcut(id).and_then(normalize).as_deref() == Some(n.as_str());
drawcraft_engine::command_specs().iter().map(|c| c.id).chain(menus::UI_COMMANDS.iter().map(|c| c.0)).find(|id| hit(id))
}
// ---------- chords ----------
/// Canonical text of a chord: `Cmd+Ctrl+Alt+Shift+Key`.
pub fn format(sc: &KeyboardShortcut) -> String {
let m = sc.modifiers;
let mut s = String::new();
if m.command || m.mac_cmd {
s.push_str("Cmd+");
}
if m.ctrl && (cfg!(target_os = "macos") || !m.command) {
s.push_str("Ctrl+");
}
if m.alt {
s.push_str("Alt+");
}
if m.shift {
s.push_str("Shift+");
}
let k = sc.logical_key;
let name = match k {
Key::CloseBracket => "]",
Key::OpenBracket => "[",
Key::Semicolon => ";",
Key::Quote => "'",
Key::Slash => "/",
Key::Backslash => "\\",
Key::Equals => "=",
Key::Minus => "-",
Key::Plus => "+",
Key::Backtick => "~",
Key::Comma => "Comma",
Key::Period => "Period",
k => k.name(),
};
s.push_str(name);
s
}
/// Canonical form of a shortcut string (None if it doesn't parse).
pub fn normalize(s: &str) -> Option<String> {
crate::shortcuts::parse(s).map(|sc| format(&sc))
}
/// A recorded key press → shortcut text (None for keys that can't be shortcuts).
pub fn chord_from_event(key: Key, m: Modifiers) -> Option<String> {
if matches!(key, Key::Escape) {
return None;
}
let s = format(&KeyboardShortcut::new(m, key));
crate::shortcuts::parse(&s).map(|_| s)
}
// ---------- entries & conflicts ----------
#[derive(Clone, Debug)]
pub struct Entry {
/// `tool:<id>` or a command id.
pub key: String,
pub label: String,
/// Menu path ("Object › Arrange") or "Tools".
pub group: String,
pub is_tool: bool,
}
/// Every tool and menu command the editor lists.
pub fn entries() -> &'static [Entry] {
static E: OnceLock<Vec<Entry>> = OnceLock::new();
E.get_or_init(|| {
let mut v: Vec<Entry> = drawcraft_tools::catalog::all_tools()
.map(|t| Entry { key: format!("tool:{}", t.id), label: t.label.to_string(), group: "Tools".into(), is_tool: true })
.collect();
let mut seen = HashSet::new();
for c in drawcraft_engine::command_specs() {
if (c.menu.is_empty() && c.shortcut.is_none()) || !seen.insert(c.id) {
continue;
}
v.push(Entry { key: c.id.into(), label: c.label.into(), group: c.menu.join(" › "), is_tool: false });
}
for (id, label, sc, params) in menus::UI_COMMANDS {
if !seen.insert(id) || (sc.is_empty() && !params.starts_with("{}") && !params.starts_with("{path")) {
continue;
}
let group = id.split('.').next().map(|g| {
let mut c = g.chars();
c.next().map(|f| f.to_uppercase().collect::<String>() + c.as_str()).unwrap_or_default()
});
v.push(Entry { key: id.to_string(), label: label.to_string(), group: group.unwrap_or_default(), is_tool: false });
}
v
})
}
pub fn entry(key: &str) -> Option<&'static Entry> {
entries().iter().find(|e| e.key == key)
}
/// Entries (other than `except`) whose effective shortcut equals `chord`.
pub fn conflicts_with(overrides: &BTreeMap<String, String>, chord: &str, except: &str) -> Vec<String> {
let Some(n) = normalize(chord) else { return vec![] };
entries()
.iter()
.filter(|e| e.key != except)
.filter(|e| effective_in(overrides, &e.key).and_then(|s| normalize(&s)).as_deref() == Some(n.as_str()))
.map(|e| e.key.clone())
.collect()
}
/// All chords used by more than one entry: (chord, keys).
pub fn all_conflicts(overrides: &BTreeMap<String, String>) -> Vec<(String, Vec<String>)> {
let mut by: BTreeMap<String, Vec<String>> = BTreeMap::new();
for e in entries() {
if let Some(n) = effective_in(overrides, &e.key).and_then(|s| normalize(&s)) {
by.entry(n).or_default().push(e.key.clone());
}
}
by.into_iter().filter(|(_, v)| v.len() > 1).collect()
}
/// Assign `chord` (None = remove, "default" handled by callers via [`reset_one`]) to `key` in
/// `overrides`. Conflicting entries lose the chord when `force`, otherwise it's an error.
pub fn assign(overrides: &mut BTreeMap<String, String>, key: &str, chord: Option<&str>, force: bool) -> Result<Vec<String>, String> {
if entry(key).is_none() && default_of(key).is_none() {
return Err(format!("unknown tool or command `{key}`"));
}
let chord = match chord {
None | Some("") => {
set_or_clear(overrides, key, "");
return Ok(vec![]);
}
Some(c) => normalize(c).ok_or_else(|| format!("can't parse shortcut `{c}`"))?,
};
if key.starts_with("tool:") {
let sc = crate::shortcuts::parse(&chord).ok_or("bad shortcut")?;
if sc.modifiers.command || sc.modifiers.alt || sc.modifiers.ctrl {
return Err("tool shortcuts must be a single key (optionally with Shift)".into());
}
}
let clashes = conflicts_with(overrides, &chord, key);
if !clashes.is_empty() && !force {
let names: Vec<String> = clashes.iter().map(|k| entry(k).map(|e| e.label.clone()).unwrap_or(k.clone())).collect();
return Err(format!("{} is already used by {}", menus::pretty_shortcut(&chord), names.join(", ")));
}
for c in &clashes {
set_or_clear(overrides, c, "");
}
set_or_clear(overrides, key, &chord);
Ok(clashes)
}
/// Store `value` for `key`, dropping the override when it equals the default.
fn set_or_clear(overrides: &mut BTreeMap<String, String>, key: &str, value: &str) {
let default = default_of(key).and_then(normalize).unwrap_or_default();
let v = if value.is_empty() { String::new() } else { normalize(value).unwrap_or(value.to_string()) };
if v == default {
overrides.remove(key);
} else {
overrides.insert(key.to_string(), v);
}
}
pub fn reset_one(overrides: &mut BTreeMap<String, String>, key: &str) {
overrides.remove(key);
}
/// Overrides of a preset (both presets are the registry defaults for now).
pub fn preset(name: &str) -> Option<BTreeMap<String, String>> {
PRESETS.contains(&name).then(BTreeMap::new)
}
/// Export format: `{"format": "drawcraft-shortcuts", "set": name, "overrides": {...}}`.
pub fn export_json(set: &str, overrides: &BTreeMap<String, String>) -> Value {
json!({"format": "drawcraft-shortcuts", "version": 1, "set": set, "overrides": overrides})
}
pub fn import_json(v: &Value) -> Result<(String, BTreeMap<String, String>), String> {
let o = v.get("overrides").and_then(Value::as_object).ok_or("not a DrawCraft shortcut set (missing `overrides`)")?;
let mut out = BTreeMap::new();
for (k, v) in o {
let s = v.as_str().ok_or_else(|| format!("shortcut for `{k}` must be a string"))?;
if !s.is_empty() && normalize(s).is_none() {
return Err(format!("can't parse shortcut `{s}` for `{k}`"));
}
out.insert(k.clone(), normalize(s).unwrap_or_default());
}
Ok((v.get("set").and_then(Value::as_str).unwrap_or(CUSTOM).to_string(), out))
}
// ---------- UI commands ----------
pub fn open(app: &mut DrawcraftApp) {
let ov = serde_json::to_value(&app.ui.shortcut_overrides).unwrap_or(json!({}));
app.ui.dialog = Some(Dialog::new(
"shortcuts",
json!({"tab": "tools", "query": "", "set": app.ui.shortcut_set, "overrides": ov, "__recording": "", "__selected": "", "__message": "", "__conflict": ""}),
));
}
fn dialog_overrides(d: &Dialog) -> BTreeMap<String, String> {
d.fields.get("overrides").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default()
}
/// OK: commit the working set.
pub fn confirm(app: &mut DrawcraftApp) -> Result<Value, String> {
let Some(d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) };
app.ui.shortcut_overrides = dialog_overrides(&d);
app.ui.shortcut_set = d.str("set");
app.ui.dialog = None;
sync(&app.ui);
Ok(json!({"overrides": app.ui.shortcut_overrides.len()}))
}
/// `shortcuts.*` UI commands. `None` = not ours.
pub fn run_command(app: &mut DrawcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string);
let r = match id {
"edit.keyboardShortcuts" => {
open(app);
Ok(Value::Null)
}
"shortcuts.set" => {
let key = s("id").unwrap_or_default();
let mut ov = app.ui.shortcut_overrides.clone();
let r = if p.get("shortcut").is_some_and(Value::is_null) {
reset_one(&mut ov, &key);
Ok(vec![])
} else {
assign(&mut ov, &key, s("shortcut").as_deref(), p.get("force").and_then(Value::as_bool).unwrap_or(false))
};
r.map(|removed| {
app.ui.shortcut_overrides = ov;
app.ui.shortcut_set = CUSTOM.into();
sync(&app.ui);
json!({"id": key, "shortcut": effective_in(&app.ui.shortcut_overrides, &key), "removedFrom": removed})
})
}
"shortcuts.list" => {
let q = s("query").unwrap_or_default().to_lowercase();
let ov = &app.ui.shortcut_overrides;
Ok(Value::Array(
entries()
.iter()
.filter(|e| q.is_empty() || e.label.to_lowercase().contains(&q) || e.key.to_lowercase().contains(&q))
.map(|e| json!({"id": e.key, "label": e.label, "group": e.group, "shortcut": effective_in(ov, &e.key), "default": default_of(&e.key), "overridden": ov.contains_key(&e.key)}))
.collect(),
))
}
"shortcuts.conflicts" => Ok(json!(all_conflicts(&app.ui.shortcut_overrides).into_iter().map(|(c, k)| json!({"shortcut": c, "ids": k})).collect::<Vec<_>>())),
"shortcuts.reset" => {
app.ui.shortcut_overrides.clear();
app.ui.shortcut_set = PRESETS[0].into();
sync(&app.ui);
Ok(Value::Null)
}
"shortcuts.preset" => match s("name").as_deref().and_then(|n| preset(n).map(|p| (n.to_string(), p))) {
Some((n, p)) => {
app.ui.shortcut_overrides = p;
app.ui.shortcut_set = n;
sync(&app.ui);
Ok(Value::Null)
}
None => Err(format!("unknown preset (one of {})", PRESETS.join(", "))),
},
"shortcuts.export" => {
let v = export_json(&app.ui.shortcut_set, &app.ui.shortcut_overrides);
let bytes = serde_json::to_vec_pretty(&v).unwrap_or_default();
let path = s("path").or_else(|| app.services.pick_save.as_mut().and_then(|f| f("DrawCraft Shortcuts.json")));
match path {
Some(path) => match app.services.write.as_mut() {
Some(w) => w(&path, &bytes).map(|_| json!({"path": path})),
None => Err("no file writer".into()),
},
None => match app.services.download.as_mut() {
Some(dl) => {
dl("DrawCraft Shortcuts.json", &bytes);
Ok(Value::Null)
}
None => Ok(v),
},
}
}
"shortcuts.import" => {
let data = if let Some(v) = p.get("data") {
Ok(v.clone())
} else {
let path = s("path").or_else(|| app.services.pick_open.as_mut().and_then(|f| f()));
match (path, app.services.read.as_ref()) {
(Some(path), Some(r)) => r(&path).and_then(|b| serde_json::from_slice(&b).map_err(|e| e.to_string())),
(None, _) => Err("cancelled".into()),
(_, None) => Err("no file reader".into()),
}
};
data.and_then(|v| import_json(&v)).map(|(set, ov)| {
let n = ov.len();
app.ui.shortcut_overrides = ov;
app.ui.shortcut_set = set;
sync(&app.ui);
if let Some(d) = app.ui.dialog.as_mut().filter(|d| d.kind == "shortcuts") {
d.fields.insert("overrides".into(), serde_json::to_value(&app.ui.shortcut_overrides).unwrap_or(json!({})));
d.fields.insert("set".into(), json!(app.ui.shortcut_set));
}
json!({"overrides": n})
})
}
_ => return None,
};
Some(r)
}
/// While recording, Escape cancels the recording instead of closing the dialog.
pub fn is_recording(app: &DrawcraftApp) -> bool {
app.ui.dialog.as_ref().is_some_and(|d| d.kind == "shortcuts" && !d.str("__recording").is_empty())
}
// ---------- dialog ----------
pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) {
let Some(mut d) = app.ui.dialog.clone() else { return };
let t = Tokens::get(ctx);
let mut ov = dialog_overrides(&d);
let mut ok = false;
let mut cancel = false;
let recording = d.str("__recording");
// Record a chord (before widgets see the keys).
if !recording.is_empty() {
let ev = ctx.input(|i| {
i.events.iter().find_map(|e| match e {
egui::Event::Key { key, pressed: true, modifiers, .. } => Some((*key, *modifiers)),
_ => None,
})
});
if let Some((key, m)) = ev {
ctx.input_mut(|i| i.events.clear());
if key == Key::Escape {
d.fields.insert("__recording".into(), json!(""));
} else if let Some(chord) = chord_from_event(key, m) {
record(&mut d, &mut ov, &recording, &chord);
}
}
}
egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| {
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
});
egui::Window::new("Keyboard Shortcuts")
.id(egui::Id::new("dialog-shortcuts"))
.order(egui::Order::Foreground)
.collapsible(false)
.resizable(false)
.title_bar(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, -20.0])
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(20)))
.show(ctx, |ui| {
ui.set_width(640.0);
ui.label(egui::RichText::new("Keyboard Shortcuts").font(theme::semibold(16.0)).color(t.text));
ui.add_space(10.0);
// Set row.
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Set:").color(t.text_dim));
let cur = d.str("set");
let mut opts: Vec<&str> = PRESETS.to_vec();
if !PRESETS.contains(&cur.as_str()) {
opts.push(CUSTOM);
}
if let Some(i) = widgets::dropdown(ui, "kbset", &cur, &opts, 200.0)
&& let Some(p) = preset(opts[i])
{
ov = p;
d.fields.insert("set".into(), json!(opts[i]));
d.fields.insert("__message".into(), json!(""));
}
ui.add_space(12.0);
if ui.button("Import…").clicked() {
d.fields.insert("__import".into(), json!(true));
}
if ui.button("Export…").clicked() {
d.fields.insert("__export".into(), json!(true));
}
if ui.button("Reset to Defaults").clicked() {
ov.clear();
d.fields.insert("set".into(), json!(PRESETS[0]));
d.fields.insert("__message".into(), json!("All shortcuts reset to defaults."));
}
});
ui.add_space(8.0);
// Tabs + search.
ui.horizontal(|ui| {
let tab = d.str("tab");
for (id, label) in [("tools", "Tools"), ("menu", "Menu Commands")] {
if ui.selectable_label(tab == id, 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| {
let mut q = d.str("query");
if ui.add(egui::TextEdit::singleline(&mut q).hint_text("Search").desired_width(200.0)).changed() {
d.fields.insert("query".into(), json!(q));
}
});
});
ui.add_space(6.0);
let tools = d.str("tab") != "menu";
let q = d.str("query").to_lowercase();
let selected = d.str("__selected");
let rec = d.str("__recording");
let scroll_to = d.fields.remove("__scrollTo").and_then(|v| v.as_str().map(str::to_string));
egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| {
ui.horizontal(|ui| {
ui.add_sized([360.0, 16.0], egui::Label::new(egui::RichText::new(if tools { "Tool" } else { "Command" }).color(t.text_dim).size(11.0)));
ui.label(egui::RichText::new("Shortcut").color(t.text_dim).size(11.0));
});
egui::ScrollArea::vertical().max_height(360.0).auto_shrink([false, false]).show(ui, |ui| {
for e in entries().iter().filter(|e| e.is_tool == tools) {
let sc = effective_in(&ov, &e.key);
if !q.is_empty() && !(e.label.to_lowercase().contains(&q) || e.group.to_lowercase().contains(&q) || sc.as_deref().is_some_and(|s| s.to_lowercase().contains(&q))) {
continue;
}
let row = ui.horizontal(|ui| {
let name = if e.is_tool || e.group.is_empty() { e.label.clone() } else { format!("{} › {}", e.group, e.label) };
let r = ui.add_sized([360.0, 20.0], egui::Button::selectable(selected == e.key, egui::RichText::new(name).color(t.text)).truncate());
if r.clicked() {
d.fields.insert("__selected".into(), json!(e.key));
}
let label = if rec == e.key {
"Press keys…".to_string()
} else {
sc.as_deref().map(menus::pretty_shortcut).unwrap_or_else(|| "—".into())
};
let changed = ov.contains_key(&e.key);
let txt = egui::RichText::new(label).color(if changed { t.accent } else { t.text });
let b = ui.add_sized([150.0, 20.0], egui::Button::new(txt).selected(rec == e.key)).on_hover_text("Click, then press the new shortcut (Esc cancels)");
if b.clicked() {
d.fields.insert("__recording".into(), json!(e.key));
d.fields.insert("__selected".into(), json!(e.key));
}
});
if scroll_to.as_deref() == Some(e.key.as_str()) {
row.response.scroll_to_me(Some(egui::Align::Center));
}
}
});
});
ui.add_space(6.0);
// Selected-row actions + message.
ui.horizontal(|ui| {
let sel = d.str("__selected");
ui.add_enabled_ui(!sel.is_empty(), |ui| {
if ui.button("Clear").on_hover_text("Remove the shortcut").clicked() {
let _ = assign(&mut ov, &sel, None, true);
d.fields.insert("set".into(), json!(CUSTOM));
}
if ui.button("Use Default").clicked() {
reset_one(&mut ov, &sel);
}
});
let conflict = d.str("__conflict");
if !conflict.is_empty() && ui.button("Go to Conflict").clicked() {
let tab = if conflict.starts_with("tool:") { "tools" } else { "menu" };
d.fields.insert("tab".into(), json!(tab));
d.fields.insert("query".into(), json!(""));
d.fields.insert("__selected".into(), json!(conflict));
d.fields.insert("__scrollTo".into(), json!(conflict));
d.fields.insert("__conflict".into(), json!(""));
}
});
let msg = d.str("__message");
if !msg.is_empty() {
ui.label(egui::RichText::new(msg).color(t.accent_strong).size(11.5));
}
let n = all_conflicts(&ov).len();
if n > 0 {
ui.label(egui::RichText::new(format!("{n} shortcut(s) are assigned more than once")).color(t.text_dim).size(11.0));
}
ui.add_space(12.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::primary_button(ui, "OK").clicked() {
ok = true;
}
ui.add_space(8.0);
if widgets::secondary_button(ui, "Cancel").clicked() {
cancel = true;
}
});
});
d.fields.insert("overrides".into(), serde_json::to_value(&ov).unwrap_or(json!({})));
let import = d.fields.remove("__import").is_some();
let export = d.fields.remove("__export").is_some();
app.ui.dialog = Some(d);
if import {
// Import replaces the working set (committed on OK like other edits).
let saved = (app.ui.shortcut_overrides.clone(), app.ui.shortcut_set.clone());
match run_command(app, "shortcuts.import", &json!({})) {
Some(Ok(_)) => {
let (o, s) = (app.ui.shortcut_overrides.clone(), app.ui.shortcut_set.clone());
(app.ui.shortcut_overrides, app.ui.shortcut_set) = saved;
sync(&app.ui);
if let Some(d) = app.ui.dialog.as_mut() {
d.fields.insert("overrides".into(), serde_json::to_value(&o).unwrap_or(json!({})));
d.fields.insert("set".into(), json!(s));
d.fields.insert("__message".into(), json!("Imported. Press OK to keep the imported set."));
}
}
Some(Err(e)) if e != "cancelled" => app.status(e),
_ => {}
}
}
if export {
let (ov, set) = app.ui.dialog.as_ref().map(|d| (dialog_overrides(d), d.str("set"))).unwrap_or_default();
let saved = std::mem::replace(&mut app.ui.shortcut_overrides, ov);
let saved_set = std::mem::replace(&mut app.ui.shortcut_set, set);
if let Some(Err(e)) = run_command(app, "shortcuts.export", &json!({})) {
app.status(e);
}
app.ui.shortcut_overrides = saved;
app.ui.shortcut_set = saved_set;
}
if cancel {
app.ui.dialog = None;
} else if ok && let Err(e) = confirm(app) {
app.status(e);
}
}
/// Apply a recorded chord to `key` in the working set, warning about (and resolving) conflicts.
fn record(d: &mut Dialog, ov: &mut BTreeMap<String, String>, key: &str, chord: &str) {
d.fields.insert("__recording".into(), json!(""));
match assign(ov, key, Some(chord), true) {
Ok(removed) => {
d.fields.insert("set".into(), json!(CUSTOM));
if let Some(first) = removed.first() {
let names: Vec<String> = removed.iter().map(|k| entry(k).map(|e| e.label.clone()).unwrap_or(k.clone())).collect();
d.fields.insert(
"__message".into(),
json!(format!("⚠ {} was already used by {} — it has been removed there.", menus::pretty_shortcut(chord), names.join(", "))),
);
d.fields.insert("__conflict".into(), json!(first));
} else {
d.fields.insert("__message".into(), json!(""));
d.fields.insert("__conflict".into(), json!(""));
}
}
Err(e) => {
d.fields.insert("__message".into(), json!(e));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_parse_round_trip_for_every_default() {
for e in entries() {
if let Some(s) = default_of(&e.key)
&& let Some(sc) = crate::shortcuts::parse(s)
{
let f = format(&sc);
assert_eq!(crate::shortcuts::parse(&f), Some(sc), "{} `{s}` → `{f}`", e.key);
assert_eq!(normalize(&f).as_deref(), Some(f.as_str()));
}
}
}
#[test]
fn normalize_is_order_insensitive() {
assert_eq!(normalize("Shift+Cmd+Alt+K"), normalize("Cmd+Alt+Shift+K"));
assert_eq!(normalize("Cmd+Shift+]").as_deref(), Some("Cmd+Shift+]"));
assert_eq!(normalize("Cmd+="), Some("Cmd+=".into()));
assert!(normalize("Hyper+K").is_none());
}
#[test]
fn chord_from_recorded_event() {
assert_eq!(chord_from_event(Key::K, Modifiers::COMMAND | Modifiers::SHIFT).as_deref(), Some("Cmd+Shift+K"));
assert_eq!(chord_from_event(Key::P, Modifiers::NONE).as_deref(), Some("P"));
assert_eq!(chord_from_event(Key::Escape, Modifiers::NONE), None);
}
#[test]
fn override_beats_default_and_empty_removes() {
let mut ov = BTreeMap::new();
assert_eq!(effective_in(&ov, "edit.preferences").as_deref(), Some("Cmd+K"));
ov.insert("edit.preferences".to_string(), "Cmd+Alt+Shift+9".to_string());
assert_eq!(effective_in(&ov, "edit.preferences").as_deref(), Some("Cmd+Alt+Shift+9"));
ov.insert("edit.preferences".to_string(), String::new());
assert_eq!(effective_in(&ov, "edit.preferences"), None);
assert_eq!(effective_in(&BTreeMap::new(), "tool:pen").as_deref(), Some("P"));
}
#[test]
fn global_override_reaches_shortcut_of_and_tools() {
let mut ui = UiState::default();
ui.shortcut_overrides.insert("test.nonexistent".into(), "Cmd+Alt+Shift+F9".into());
ui.shortcut_overrides.insert("tool:measure".into(), "Shift+9".into());
let g = GENERATION.load(Ordering::Relaxed);
sync(&ui);
assert!(GENERATION.load(Ordering::Relaxed) > g);
assert_eq!(menus::shortcut_of("test.nonexistent"), Some("Cmd+Alt+Shift+F9"));
assert_eq!(tool_shortcut("measure"), Some("Shift+9"));
assert_eq!(tool_for_key("Shift+9"), Some("measure"));
sync(&UiState::default());
assert_eq!(menus::shortcut_of("test.nonexistent"), None);
}
#[test]
fn assign_detects_conflicts() {
let mut ov = BTreeMap::new();
// ⌘K is Preferences.
let err = assign(&mut ov, "file.revert", Some("Cmd+K"), false).unwrap_err();
assert!(err.contains("Preferences"), "{err}");
assert!(ov.is_empty());
assert_eq!(conflicts_with(&ov, "Cmd+K", "file.revert"), vec!["edit.preferences".to_string()]);
}
#[test]
fn forced_assign_moves_the_chord() {
let mut ov = BTreeMap::new();
let removed = assign(&mut ov, "file.revert", Some("Cmd+K"), true).unwrap();
assert_eq!(removed, vec!["edit.preferences".to_string()]);
assert_eq!(effective_in(&ov, "file.revert").as_deref(), Some("Cmd+K"));
assert_eq!(effective_in(&ov, "edit.preferences"), None);
assert!(conflicts_with(&ov, "Cmd+K", "file.revert").is_empty());
}
#[test]
fn assigning_the_default_drops_the_override() {
let mut ov = BTreeMap::new();
assign(&mut ov, "tool:pen", Some("Shift+9"), false).unwrap();
assert_eq!(ov.len(), 1);
assign(&mut ov, "tool:pen", Some("P"), false).unwrap();
assert!(ov.is_empty());
}
#[test]
fn tool_shortcuts_must_be_single_keys() {
let mut ov = BTreeMap::new();
assert!(assign(&mut ov, "tool:pen", Some("Cmd+Alt+Shift+F9"), true).is_err());
assert!(assign(&mut ov, "tool:bogus", Some("Q"), true).is_err());
}
#[test]
fn export_import_round_trip() {
let mut ov = BTreeMap::new();
ov.insert("tool:pen".to_string(), "Shift+9".to_string());
ov.insert("edit.preferences".to_string(), String::new());
let v = export_json("Mine", &ov);
let (set, back) = import_json(&v).unwrap();
assert_eq!(set, "Mine");
assert_eq!(back, ov);
assert!(import_json(&json!({"overrides": {"x": "Cmd+Nope+Q"}})).is_err());
assert!(import_json(&json!({})).is_err());
}
#[test]
fn presets_are_the_defaults() {
for p in PRESETS {
assert_eq!(preset(p), Some(BTreeMap::new()));
}
assert!(preset("Nope").is_none());
}
#[test]
fn entries_cover_tools_and_menu_commands() {
assert!(entries().iter().any(|e| e.key == "tool:selection"));
assert!(entries().iter().any(|e| e.key == "edit.preferences"));
assert!(entries().iter().any(|e| e.key == "edit.undo"));
let mut seen = HashSet::new();
assert!(entries().iter().all(|e| seen.insert(e.key.clone())), "duplicate entries");
}
}

View File

@ -43,15 +43,16 @@ pub fn parse(s: &str) -> Option<KeyboardShortcut> {
Some(KeyboardShortcut::new(m, key))
}
/// Every command shortcut in effect (user overrides from Edit → Keyboard Shortcuts win).
fn all_shortcuts() -> Vec<(KeyboardShortcut, &'static str)> {
let mut v: Vec<(KeyboardShortcut, &'static str)> = vec![];
for c in drawcraft_engine::command_specs() {
if let Some(sc) = c.shortcut.and_then(parse) {
if let Some(sc) = crate::menus::shortcut_of(c.id).and_then(parse) {
v.push((sc, c.id));
}
}
for c in crate::menus::UI_COMMANDS {
if let Some(sc) = parse(c.2) {
if let Some(sc) = crate::menus::shortcut_of(c.0).and_then(parse) {
v.push((sc, c.0));
}
}
@ -64,6 +65,9 @@ fn all_shortcuts() -> Vec<(KeyboardShortcut, &'static str)> {
pub fn handle(app: &mut DrawcraftApp, ctx: &egui::Context) {
if app.ui.dialog.is_some() || app.ui.palette_open {
if crate::shortcut_editor::is_recording(app) {
return;
}
if ctx.input(|i| i.key_pressed(Key::Escape)) {
app.ui.dialog = None;
app.ui.palette_open = false;
@ -165,39 +169,13 @@ pub fn handle(app: &mut DrawcraftApp, ctx: &egui::Context) {
}
let upper = text.to_uppercase();
let key = if m.shift && text.chars().all(|c| c.is_alphabetic()) { format!("Shift+{upper}") } else { upper.clone() };
match key.as_str() {
"X" => {
let _ = app.run("paint.toggleActive", json!({}));
continue;
}
"Shift+X" => {
let _ = app.run("paint.swap", json!({}));
continue;
}
"D" => {
let _ = app.run("paint.default", json!({}));
continue;
}
"/" => {
let _ = app.run("paint.none", json!({}));
continue;
}
"Shift+D" => {
let _ = app.run("view.drawMode", json!({}));
continue;
}
"F" => {
let _ = app.run("view.screenMode", json!({}));
continue;
}
"Shift+F" => {
let _ = app.run("view.presentation", json!({}));
continue;
}
_ => {}
// Single-key command shortcuts (X, Shift+X, D, /, Shift+D, F, Shift+F by default).
if let Some(id) = crate::shortcut_editor::command_for_key(&key).or_else(|| crate::shortcut_editor::command_for_key(&text)) {
let _ = app.run(id, json!({}));
continue;
}
if let Some(t) = drawcraft_tools::catalog::tool_for_shortcut(&key).or_else(|| drawcraft_tools::catalog::tool_for_shortcut(&text)) {
app.select_tool(t.id);
if let Some(t) = crate::shortcut_editor::tool_for_key(&key).or_else(|| crate::shortcut_editor::tool_for_key(&text)) {
app.select_tool(t);
}
}
}

View File

@ -198,6 +198,21 @@ pub struct UiState {
/// Recording in progress: (set index, action name, journal length when recording started).
#[serde(skip)]
pub recording: Option<(usize, String, usize)>,
/// Keyboard shortcut overrides: command id or `tool:<id>` → chord ("" = none).
#[serde(default)]
pub shortcut_overrides: std::collections::BTreeMap<String, String>,
/// Name of the shortcut set (preset or "Custom").
#[serde(default = "default_shortcut_set")]
pub shortcut_set: String,
/// Current workspace (Window → Workspace).
#[serde(default = "default_workspace")]
pub workspace: String,
/// User-saved workspaces (New Workspace…).
#[serde(default)]
pub custom_workspaces: Vec<crate::workspaces::Workspace>,
/// Engine preferences (Edit → Preferences), persisted alongside the UI state.
#[serde(default)]
pub engine_prefs: Value,
}
impl UiState {
@ -242,6 +257,11 @@ impl Default for UiState {
about: false,
action_sets: crate::panels::actions::default_sets(),
recording: None,
shortcut_overrides: Default::default(),
shortcut_set: default_shortcut_set(),
workspace: default_workspace(),
custom_workspaces: vec![],
engine_prefs: Value::Null,
}
}
}
@ -250,6 +270,14 @@ fn yes() -> bool {
true
}
fn default_shortcut_set() -> String {
crate::shortcut_editor::PRESETS[0].to_string()
}
fn default_workspace() -> String {
crate::workspaces::ESSENTIALS.to_string()
}
#[cfg(test)]
mod tests {
use super::*;

View File

@ -48,7 +48,7 @@ pub const BASIC: &[(&str, &[&[&str]])] = &[
];
fn tip(t: &ToolInfo) -> String {
match t.shortcut {
match crate::shortcut_editor::tool_shortcut(t.id) {
Some(s) => format!("{} ({})", t.label, s),
None => t.label.to_string(),
}
@ -285,7 +285,7 @@ fn flyout(app: &mut DrawcraftApp, ctx: &egui::Context) {
egui::FontId::proportional(13.0),
color,
);
if let Some(sc) = tool.shortcut {
if let Some(sc) = crate::shortcut_editor::tool_shortcut(tool.id) {
ui.painter().text(
r.right_center() - vec2(18.0, 0.0),
egui::Align2::RIGHT_CENTER,

View File

@ -0,0 +1,463 @@
//! Window → Workspace: saved panel/bar layouts (Essentials, Essentials Classic, Painting, …),
//! Reset, New Workspace…, Manage Workspaces…. A workspace is a snapshot of `UiState` layout
//! flags; UI brightness and everything else is untouched when switching.
use std::sync::atomic::Ordering;
use std::sync::{OnceLock, RwLock};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::state::{Dialog, DockTab, UiState};
use crate::theme::{self, Tokens};
use crate::{DrawcraftApp, widgets};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Workspace {
pub name: String,
pub control_bar: bool,
pub rulers: bool,
pub toolbar: bool,
pub toolbar_double: bool,
pub toolbar_advanced: bool,
pub task_bar: bool,
pub dock: bool,
pub dock_tab: DockTab,
pub open_panel: Option<String>,
pub status_bar: bool,
}
impl Default for Workspace {
fn default() -> Self {
let ui = UiState::default();
capture(&ui, "Essentials")
}
}
pub const ESSENTIALS: &str = "Essentials";
/// Built-in workspaces, in Illustrator's menu order (Essentials first).
pub fn builtins() -> Vec<Workspace> {
let base = Workspace::default();
let w = |name: &str, f: &dyn Fn(&mut Workspace)| {
let mut x = Workspace { name: name.into(), ..base.clone() };
f(&mut x);
x
};
vec![
w(ESSENTIALS, &|_| {}),
w("Essentials Classic", &|x| {
x.control_bar = true;
x.rulers = true;
x.toolbar_advanced = true;
x.task_bar = false;
x.open_panel = Some("color".into());
}),
w("Automation", &|x| {
x.control_bar = true;
x.toolbar_advanced = true;
x.dock_tab = DockTab::Layers;
x.open_panel = Some("actions".into());
}),
w("Layout", &|x| {
x.control_bar = true;
x.rulers = true;
x.toolbar_advanced = true;
x.dock_tab = DockTab::Layers;
x.open_panel = Some("align".into());
}),
w("Painting", &|x| {
x.control_bar = true;
x.toolbar_advanced = true;
x.toolbar_double = true;
x.open_panel = Some("brushes".into());
}),
w("Printing and Proofing", &|x| {
x.control_bar = true;
x.rulers = true;
x.toolbar_advanced = true;
x.open_panel = Some("info".into());
}),
w("Tracing", &|x| {
x.control_bar = true;
x.toolbar_advanced = true;
x.dock_tab = DockTab::Layers;
x.task_bar = false;
}),
w("Typography", &|x| {
x.control_bar = true;
x.rulers = true;
x.toolbar_advanced = true;
x.open_panel = Some("character".into());
}),
w("Web", &|x| {
x.control_bar = true;
x.rulers = true;
x.toolbar_advanced = true;
x.open_panel = Some("symbols".into());
}),
]
}
pub fn is_builtin(name: &str) -> bool {
builtins().iter().any(|w| w.name == name)
}
/// Snapshot the layout flags of `ui` as a workspace called `name`.
pub fn capture(ui: &UiState, name: &str) -> Workspace {
Workspace {
name: name.into(),
control_bar: ui.control_bar,
rulers: ui.view.rulers,
toolbar: ui.toolbar,
toolbar_double: ui.toolbar_double,
toolbar_advanced: ui.toolbar_advanced,
task_bar: ui.task_bar,
dock: ui.dock,
dock_tab: ui.dock_tab,
open_panel: ui.open_panel.clone(),
status_bar: ui.status_bar,
}
}
/// Apply a workspace's layout flags (brightness, prefs, shortcuts etc. untouched).
pub fn apply(ui: &mut UiState, w: &Workspace) {
ui.control_bar = w.control_bar;
ui.view.rulers = w.rulers;
ui.toolbar = w.toolbar;
ui.toolbar_double = w.toolbar_double;
ui.toolbar_advanced = w.toolbar_advanced;
ui.task_bar = w.task_bar;
ui.dock = w.dock;
ui.dock_tab = w.dock_tab;
ui.open_panel = w.open_panel.clone();
ui.status_bar = w.status_bar;
ui.flyout = None;
ui.workspace = w.name.clone();
}
/// A workspace by name (user workspaces shadow nothing: names are unique across both lists).
pub fn find(ui: &UiState, name: &str) -> Option<Workspace> {
ui.custom_workspaces.iter().find(|w| w.name.eq_ignore_ascii_case(name)).cloned().or_else(|| builtins().into_iter().find(|w| w.name.eq_ignore_ascii_case(name)))
}
/// Every workspace name: built-ins then user workspaces.
pub fn names(ui: &UiState) -> Vec<String> {
builtins().into_iter().map(|w| w.name).chain(ui.custom_workspaces.iter().map(|w| w.name.clone())).collect()
}
/// Save the current layout as `name` (replacing a user workspace of that name).
pub fn save_as(ui: &mut UiState, name: &str) -> Result<(), String> {
let name = name.trim();
if name.is_empty() {
return Err("workspace name can't be empty".into());
}
if is_builtin(name) {
return Err(format!("`{name}` is a built-in workspace; choose another name"));
}
let w = capture(ui, name);
match ui.custom_workspaces.iter_mut().find(|x| x.name == name) {
Some(x) => *x = w,
None => ui.custom_workspaces.push(w),
}
ui.workspace = name.into();
Ok(())
}
pub fn delete(ui: &mut UiState, name: &str) -> Result<(), String> {
let before = ui.custom_workspaces.len();
ui.custom_workspaces.retain(|w| w.name != name);
if ui.custom_workspaces.len() == before {
return Err(if is_builtin(name) { format!("`{name}` is built in and can't be deleted") } else { format!("no workspace `{name}`") });
}
if ui.workspace == name {
ui.workspace = ESSENTIALS.into();
}
Ok(())
}
pub fn rename(ui: &mut UiState, from: &str, to: &str) -> Result<(), String> {
let to = to.trim();
if to.is_empty() || is_builtin(to) || ui.custom_workspaces.iter().any(|w| w.name == to) {
return Err(format!("`{to}` is not an available name"));
}
let w = ui.custom_workspaces.iter_mut().find(|w| w.name == from).ok_or_else(|| format!("no user workspace `{from}`"))?;
w.name = to.into();
if ui.workspace == from {
ui.workspace = to.into();
}
Ok(())
}
// ---------- menu support (menu_tree has no app access) ----------
fn names_store() -> &'static RwLock<Vec<&'static str>> {
static S: OnceLock<RwLock<Vec<&'static str>>> = OnceLock::new();
S.get_or_init(Default::default)
}
/// Mirror the user workspace names for the (static) menu tree.
pub fn sync(ui: &UiState) {
let same = names_store().read().map(|v| v.len() == ui.custom_workspaces.len() && v.iter().zip(&ui.custom_workspaces).all(|(a, b)| *a == b.name)).unwrap_or(true);
if !same {
if let Ok(mut v) = names_store().write() {
*v = ui.custom_workspaces.iter().map(|w| crate::shortcut_editor::intern(&w.name)).collect();
}
crate::shortcut_editor::GENERATION.fetch_add(1, Ordering::Relaxed);
}
}
/// Window → Workspace submenu items.
pub fn menu_items() -> Vec<crate::menus::Item> {
use crate::menus::Item;
let mut v: Vec<Item> = builtins().into_iter().map(|w| Item::Cmd(crate::shortcut_editor::intern(&w.name), "window.workspace", json!({"name": w.name}))).collect();
let custom = names_store().read().map(|v| v.clone()).unwrap_or_default();
if !custom.is_empty() {
v.push(Item::Sep);
v.extend(custom.into_iter().map(|n| Item::Cmd(n, "window.workspace", json!({"name": n}))));
}
v.push(Item::Sep);
v.push(Item::Cmd("Reset Essentials", "window.workspace.reset", Value::Null));
v.push(Item::Cmd("New Workspace…", "window.workspace.new", Value::Null));
v.push(Item::Cmd("Manage Workspaces…", "window.workspace.manage", Value::Null));
v
}
/// The app-bar workspace switcher's menu.
pub fn popup(app: &mut DrawcraftApp, ui: &mut egui::Ui) {
ui.set_min_width(210.0);
let mut clicked: Option<(&'static str, Value)> = None;
for it in menu_items() {
match it {
crate::menus::Item::Sep => {
ui.separator();
}
crate::menus::Item::Cmd(label, id, p) => {
let label = crate::menus::dynamic_label(app, id, label);
let text = match crate::menus::checked(app, id, &p) {
Some(true) => format!("✓ {label}"),
Some(false) => format!(" {label}"),
None => format!(" {label}"),
};
if ui.add(egui::Button::new(text).min_size(egui::vec2(ui.available_width(), 0.0))).clicked() {
clicked = Some((id, if p.is_null() { json!({}) } else { p }));
ui.close();
}
}
_ => {}
}
}
if let Some((id, p)) = clicked {
crate::menus::invoke(app, id, p);
}
}
// ---------- commands ----------
pub fn run_command(app: &mut DrawcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string);
let r = match id {
"window.workspace" => match s("name").and_then(|n| find(&app.ui, &n)) {
Some(w) => {
apply(&mut app.ui, &w);
Ok(json!({"workspace": w.name}))
}
None => Err(format!("unknown workspace (one of {})", names(&app.ui).join(", "))),
},
"window.workspace.reset" => {
let w = find(&app.ui, &app.ui.workspace.clone()).unwrap_or_default();
apply(&mut app.ui, &w);
Ok(json!({"workspace": w.name}))
}
"window.workspace.new" => match s("name") {
Some(n) => save_as(&mut app.ui, &n).map(|_| json!({"workspace": n})),
None => {
let n = (1..).map(|i| format!("Workspace {i}")).find(|n| find(&app.ui, n).is_none()).unwrap_or_default();
app.ui.dialog = Some(Dialog::new("newWorkspace", json!({"name": n})));
Ok(Value::Null)
}
},
"window.workspace.manage" => {
app.ui.dialog = Some(Dialog::new("manageWorkspaces", json!({"selected": "", "name": ""})));
Ok(Value::Null)
}
"window.workspace.delete" => delete(&mut app.ui, &s("name").unwrap_or_default()).map(|_| Value::Null),
"window.workspace.rename" => rename(&mut app.ui, &s("name").unwrap_or_default(), &s("to").unwrap_or_default()).map(|_| Value::Null),
"window.workspace.list" => Ok(json!({"current": app.ui.workspace, "workspaces": names(&app.ui), "custom": app.ui.custom_workspaces})),
_ => return None,
};
Some(r)
}
/// OK in New Workspace / Manage Workspaces.
pub fn confirm(app: &mut DrawcraftApp) -> Result<Value, String> {
let Some(d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) };
let r = match d.kind.as_str() {
"newWorkspace" => save_as(&mut app.ui, &d.str("name")).map(|_| Value::Null),
_ => Ok(Value::Null),
};
if r.is_ok() {
app.ui.dialog = None;
}
r
}
pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) {
let Some(mut d) = app.ui.dialog.clone() else { return };
let t = Tokens::get(ctx);
let (mut ok, mut cancel) = (false, false);
let mut action: Option<(&str, Value)> = None;
egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| {
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
});
let manage = d.kind == "manageWorkspaces";
egui::Window::new("Workspace")
.id(egui::Id::new("dialog-workspace"))
.order(egui::Order::Foreground)
.collapsible(false)
.resizable(false)
.title_bar(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, -40.0])
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(22)))
.show(ctx, |ui| {
ui.set_width(340.0);
ui.label(egui::RichText::new(if manage { "Manage Workspaces" } else { "New Workspace" }).font(theme::semibold(16.0)).color(t.text));
ui.add_space(12.0);
if manage {
egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| {
ui.set_min_height(140.0);
ui.set_width(ui.available_width());
for n in names(&app.ui) {
let builtin = is_builtin(&n);
let text = if builtin { egui::RichText::new(&n).color(t.text_dim) } else { egui::RichText::new(&n).color(t.text) };
if ui.selectable_label(d.str("selected") == n, text).clicked() && !builtin {
d.fields.insert("selected".into(), json!(n));
d.fields.insert("name".into(), json!(n));
}
}
});
ui.add_space(8.0);
let sel = d.str("selected");
ui.horizontal(|ui| {
let mut name = d.str("name");
if ui.add(egui::TextEdit::singleline(&mut name).desired_width(180.0)).changed() {
d.fields.insert("name".into(), json!(name));
}
if ui.button("New").on_hover_text("Save the current layout under this name").clicked() {
action = Some(("window.workspace.new", json!({"name": d.str("name")})));
}
ui.add_enabled_ui(!sel.is_empty(), |ui| {
if ui.button("Rename").clicked() {
action = Some(("window.workspace.rename", json!({"name": sel, "to": d.str("name")})));
}
if ui.button("Delete").clicked() {
action = Some(("window.workspace.delete", json!({"name": sel})));
}
});
});
} else {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Name:").color(t.text_dim));
let mut name = d.str("name");
let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(240.0));
if r.changed() {
d.fields.insert("name".into(), json!(name));
}
});
ui.label(egui::RichText::new("Saves the current bars, toolbar and panel layout.").color(t.text_dim).size(11.0));
}
ui.add_space(16.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::primary_button(ui, "OK").clicked() {
ok = true;
}
ui.add_space(8.0);
if !manage && widgets::secondary_button(ui, "Cancel").clicked() {
cancel = true;
}
});
});
if !manage && ctx.input(|i| i.key_pressed(egui::Key::Enter)) {
ok = true;
}
app.ui.dialog = Some(d);
if let Some((id, p)) = action {
match run_command(app, id, &p) {
Some(Err(e)) => app.status(e),
_ => {
if let Some(d) = app.ui.dialog.as_mut() {
d.fields.insert("selected".into(), json!(""));
}
}
}
}
if cancel {
app.ui.dialog = None;
} else if ok && let Err(e) = confirm(app) {
app.status(e);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn essentials_is_the_default_layout() {
let ui = UiState::default();
assert_eq!(capture(&ui, ESSENTIALS), builtins()[0]);
assert_eq!(ui.workspace, ESSENTIALS);
}
#[test]
fn apply_classic_then_reset_to_essentials_keeps_brightness() {
let mut ui = UiState { brightness: crate::theme::Brightness::Light, ..Default::default() };
apply(&mut ui, &find(&ui, "Essentials Classic").unwrap());
assert!(ui.control_bar && ui.view.rulers && ui.toolbar_advanced);
assert_eq!(ui.open_panel.as_deref(), Some("color"));
assert_eq!(ui.workspace, "Essentials Classic");
apply(&mut ui, &find(&ui, "essentials").unwrap());
assert!(!ui.control_bar && !ui.view.rulers);
assert_eq!(ui.brightness, crate::theme::Brightness::Light);
}
#[test]
fn save_apply_round_trip() {
let mut ui = UiState::default();
ui.control_bar = true;
ui.dock_tab = DockTab::Layers;
ui.open_panel = Some("stroke".into());
save_as(&mut ui, "Mine").unwrap();
apply(&mut ui, &find(&ui, ESSENTIALS).unwrap());
assert!(!ui.control_bar);
apply(&mut ui, &find(&ui, "Mine").unwrap());
assert!(ui.control_bar && ui.dock_tab == DockTab::Layers && ui.open_panel.as_deref() == Some("stroke"));
// Survives the UiState JSON round trip.
let back: UiState = serde_json::from_value(serde_json::to_value(&ui).unwrap()).unwrap();
assert_eq!(back.custom_workspaces, ui.custom_workspaces);
assert_eq!(back.workspace, "Mine");
}
#[test]
fn builtin_names_are_protected() {
let mut ui = UiState::default();
assert!(save_as(&mut ui, "Painting").is_err());
assert!(save_as(&mut ui, " ").is_err());
assert!(delete(&mut ui, "Essentials").is_err());
save_as(&mut ui, "A").unwrap();
assert!(rename(&mut ui, "A", "Web").is_err());
rename(&mut ui, "A", "B").unwrap();
assert_eq!(ui.workspace, "B");
delete(&mut ui, "B").unwrap();
assert_eq!(ui.workspace, ESSENTIALS);
}
#[test]
fn all_requested_workspaces_exist() {
let ui = UiState::default();
for n in ["Essentials", "Essentials Classic", "Painting", "Typography", "Layout", "Tracing", "Web", "Automation"] {
assert!(find(&ui, n).is_some(), "{n}");
}
}
}