diff --git a/Cargo.lock b/Cargo.lock index fc6499a5..69e187b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -863,6 +863,7 @@ name = "drawcraft-color" version = "0.1.0" dependencies = [ "kurbo 0.13.1", + "moxcms", "proptest", "serde", "serde_json", diff --git a/apps/drawcraft/src/main.rs b/apps/drawcraft/src/main.rs index c40f2413..f3c24fb3 100644 --- a/apps/drawcraft/src/main.rs +++ b/apps/drawcraft/src/main.rs @@ -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); } } diff --git a/apps/drawcraft/src/native_menu.rs b/apps/drawcraft/src/native_menu.rs index 09c40244..d93c8346 100644 --- a/apps/drawcraft/src/native_menu.rs +++ b/apps/drawcraft/src/native_menu.rs @@ -20,6 +20,8 @@ pub struct NativeMenu { _menu: Menu, items: HashMap, 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 { 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, "")); } diff --git a/crates/color/Cargo.toml b/crates/color/Cargo.toml index 07c93694..8f41d544 100644 --- a/crates/color/Cargo.toml +++ b/crates/color/Cargo.toml @@ -12,6 +12,7 @@ workspace = true kurbo = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } +moxcms = "0.8" [dev-dependencies] proptest = { workspace = true } diff --git a/crates/color/src/cms/generic.rs b/crates/color/src/cms/generic.rs new file mode 100644 index 00000000..630c15f3 --- /dev/null +++ b/crates/color/src/cms/generic.rs @@ -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, + /// Lightness of maximum chroma per hue bin. + cusp_l: Vec, +} + +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, +} + +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::(); + 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::(); + } + 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::(); + 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::() + 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::() + k; + if sum > self.params.tac { + let s = ((self.params.tac - k) / cmy.iter().sum::().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 = (0..GBD_L).map(|li| smooth[li * GBD_H + hi]).collect(); + let known: Vec = (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) +} diff --git a/crates/color/src/cms/icc.rs b/crates/color/src/cms/icc.rs new file mode 100644 index 00000000..050fa56b --- /dev/null +++ b/crates/color/src/cms/icc.rs @@ -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>; + +/// 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; 4], + from_srgb: [OnceLock; 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) -> Option { + 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, profile: ColorProfile) -> Result { + 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, bytes: &[u8]) -> Result { + 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> { + 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 { + 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() +} diff --git a/crates/color/src/cms/lab.rs b/crates/color/src/cms/lab.rs new file mode 100644 index 00000000..eacc768d --- /dev/null +++ b/crates/color/src/cms/lab.rs @@ -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}"); + } +} diff --git a/crates/color/src/cms/mod.rs b/crates/color/src/cms/mod.rs new file mode 100644 index 00000000..d134d8e5 --- /dev/null +++ b/crates/color/src/cms/mod.rs @@ -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 { + 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), + Device, + Icc(Arc), +} + +#[derive(Clone)] +enum RgbSpace { + Srgb, + Icc(Arc), +} + +fn generic() -> Arc { + static G: OnceLock> = OnceLock::new(); + G.get_or_init(|| Arc::new(GenericCmyk::new(generic::swop_like()))).clone() +} + +static USER: RwLock>> = RwLock::new(Vec::new()); + +fn builtin_rgb_cache(name: &str) -> Option> { + static CACHE: Mutex>> = 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> { + USER.read().unwrap_or_else(|e| e.into_inner()).iter().find(|p| p.name == name).cloned() +} + +fn cmyk_space(name: &str) -> Result { + 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 { + 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 { + let mut v: Vec = [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) -> Result { + 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 { + 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 { + 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 { + 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)>> = 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 { + 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>, +} + +/// 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>> = 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 { + 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; diff --git a/crates/color/src/cms/tests.rs b/crates/color/src/cms/tests.rs new file mode 100644 index 00000000..4e4f1d8b --- /dev/null +++ b/crates/color/src/cms/tests.rs @@ -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::() <= 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::(); + 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::() > abs.iter().sum::() + 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]); +} diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 2af17293..bb82dac0 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -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), 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]); } diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index ece98c75..568d8c5a 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -63,6 +63,10 @@ pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option { 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 { 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 { } } +/// Hit anywhere in an evaluated subtree (groups recurse; leaves use [`hit_leaf`]). +fn hit_any(n: &Node, p: Point, opt: HitOptions) -> Option { + 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, leaves: bool) -> Vec { let mut out = Vec::new(); let tops: Vec<&std::sync::Arc> = 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 { diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 87420aae..ceec9f21 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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, + /// Pattern swatch definitions (Object → Pattern). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub patterns: Vec, + /// Pattern editing mode, while active. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pattern_edit: Option, 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) } diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index ef0c8b45..c34e2e70 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -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], kind: &EnvelopeKind) -> Option 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 { 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()], } } diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 8c28bc79..f19f70a9 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -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 ``. @@ -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). diff --git a/crates/doc/src/pattern.rs b/crates/doc/src/pattern.rs new file mode 100644 index 00000000..7f960cf7 --- /dev/null +++ b/crates/doc/src/pattern.rs @@ -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 `` 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) -> Option { + 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>, + #[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>) -> 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 { + 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 { + 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 = if self.overlap.bottom_in_front { (j0..=j1).collect() } else { (j0..=j1).rev().collect() }; + for &j in &rows { + let cols: Vec = 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 { + 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 { + 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, +} + +/// 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>, +} + +/// 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) -> Vec { + 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 = (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 { + crate::live::nodes_bounds(&self.source) + } + pub fn transforms(&self) -> Vec { + repeat_transforms(&self.kind, self.source_bounds()) + } + /// Evaluated instances: one group of the (transformed) source per instance (ids 0). + pub fn expand(&self) -> Vec { + 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 { + 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>, instances: u32, radius: Option) -> 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>, 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>, 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 { + 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 = (-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 = 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 = 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::(&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::(&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")); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index f321124b..89cb530f 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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 }) } diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs new file mode 100644 index 00000000..a598b61f --- /dev/null +++ b/crates/engine/src/cmd/prefscmds.rs @@ -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 { + 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::>().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) -> 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 { + 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 { + 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 { + 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 { + 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 { + 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::>()); + } + PrefKind::Color => o["kind"] = json!("color"), + PrefKind::Text => o["kind"] = json!("text"), + } + o + }) + .collect(), + )) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7a57cab7..004c508a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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) -> 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; diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests_prefs.rs new file mode 100644 index 00000000..2de1a23a --- /dev/null +++ b/crates/engine/src/tests_prefs.rs @@ -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); +} diff --git a/crates/engine/src/tests_textedit.rs b/crates/engine/src/tests_textedit.rs index 5db973c6..11ef0216 100644 --- a/crates/engine/src/tests_textedit.rs +++ b/crates/engine/src/tests_textedit.rs @@ -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()); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 4ee97bb1..e5c6c7f8 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -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(&mut self, view: ViewInfo, f: impl FnOnce(&mut dyn drawcraft_tools::Tool, &ToolContext) -> R) -> R + pub(crate) fn with_tool_cx(&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> { 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> { - 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> { - 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 { - 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)); } } } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index f5b44716..9a16d71b 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -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 { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 4c633b38..726ac433 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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, /// Draw template layers dimmed (50%). pub dim_templates: bool, + /// Soft proof / separations preview (see [`proof`]). + pub proof: Option, + /// 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) { + 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); } diff --git a/crates/render/src/live.rs b/crates/render/src/live.rs index dfbb5c96..41010a07 100644 --- a/crates/render/src/live.rs +++ b/crates/render/src/live.rs @@ -129,7 +129,7 @@ impl Renderer { pub(crate) fn draw_live_body(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc, 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); diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index 6c6eda20..6d8454fa 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -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), } } diff --git a/crates/render/src/pattern.rs b/crates/render/src/pattern.rs new file mode 100644 index 00000000..caf3a8b8 --- /dev/null +++ b/crates/render/src/pattern.rs @@ -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>, + params: (Rect, TileType, Overlap), + scale: f64, + sx: f64, + sy: f64, + pixmap: Arc, +} + +#[derive(Default)] +struct PatternCache { + tiles: HashMap, + renderer: Option>, + depth: u32, +} + +thread_local! { + static CACHE: RefCell = RefCell::new(PatternCache::default()); +} + +fn same_art(a: &[Arc], b: &[Arc]) -> 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(f: impl FnOnce(&mut Renderer) -> T) -> Option { + 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 { + 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> = 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); + } +} diff --git a/crates/render/src/proof.rs b/crates/render/src/proof.rs new file mode 100644 index 00000000..3942ec19 --- /dev/null +++ b/crates/render/src/proof.rs @@ -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 { + let c = cms::active(); + let mut v: Vec = 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 { + 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>, +} + +static VIEW: RwLock> = 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 { + 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() +} diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 084f92c6..b2af533e 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -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, names: HashMap, depth: usize, + /// `` def ids by pattern name + placement. + patterns: HashMap, + 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) -> 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 `` 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 { + 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!( + "", + self.num(pw), + self.num(ph), + self.matrix(m) + ); + self.def(1, &head); + self.defs.push_str(&content); + self.def(1, ""); + 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); } diff --git a/crates/tools/src/distort.rs b/crates/tools/src/distort.rs new file mode 100644 index 00000000..d268c8f1 --- /dev/null +++ b/crates/tools/src/distort.rs @@ -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> { + 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) { + 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); + } + } +} diff --git a/crates/tools/src/distort/arap.rs b/crates/tools/src/distort/arap.rs new file mode 100644 index 00000000..cd4100b2 --- /dev/null +++ b/crates/tools/src/distort/arap.rs @@ -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, +} + +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, + 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>, +} + +/// 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 { + 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 = (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 { + let centres: Vec = 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 = (0..n).map(|i| i as f64 * 0.5 - 2.0).collect(); + let mut b: Vec = (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 = [(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 = [(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); + } +} diff --git a/crates/tools/src/distort/liquify.rs b/crates/tools/src/distort/liquify.rs new file mode 100644 index 00000000..20ae6986 --- /dev/null +++ b/crates/tools/src/distort/liquify.rs @@ -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 { + 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 { + 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 { + let mut out: Vec = 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, 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 = 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 { + 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, + active: bool, + /// Alt-drag resizes the brush from this centre. + sizing: Option, + hover: Option, +} + +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 { + 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 { + 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 { + 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)); + } +} diff --git a/crates/tools/src/distort/pathutil.rs b/crates/tools/src/distort/pathutil.rs new file mode 100644 index 00000000..6c723673 --- /dev/null +++ b/crates/tools/src/distort/pathutil.rs @@ -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 { + (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)); + } +} diff --git a/crates/tools/src/distort/perspective.rs b/crates/tools/src/distort/perspective.rs new file mode 100644 index 00000000..f8e15450 --- /dev/null +++ b/crates/tools/src/distort/perspective.rs @@ -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 { + 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 { + 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 { + 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, +} + +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 { + 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::().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 { + 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 { + 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 { + self.homography(plane)?.inverse()?.apply(p) + } + /// Plane → page coordinates. + pub fn to_page(&self, plane: Plane, q: Point) -> Option { + 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 Option> { + 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 Option> { + 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 { + 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 { + 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 { + 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 = [(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, +} + +impl Tool for PerspectiveGridTool { + fn id(&self) -> &'static str { + "perspectiveGrid" + } + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + 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 { + 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 { + if self.drag.take().is_some() { vec![Action::Commit] } else { vec![] } + } +} + +// ---------- Perspective Selection tool ---------- + +#[derive(Default)] +pub struct PerspectiveSelectionTool { + drag: Option<(Vec, Point)>, +} + +impl Tool for PerspectiveSelectionTool { + fn id(&self) -> &'static str { + "perspectiveSelection" + } + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + 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 { + // 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 { + 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()); + } +} diff --git a/crates/tools/src/distort/puppet.rs b/crates/tools/src/distort/puppet.rs new file mode 100644 index 00000000..18eecb6f --- /dev/null +++ b/crates/tools/src/distort/puppet.rs @@ -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, 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 { + let (o, b) = mesh_input(doc, ids)?; + Some(Mesh::build(&o, b, MeshOptions { expand, ..Default::default() })) +} + +#[derive(Default)] +pub struct PuppetWarpTool { + ids: Vec, + pins: Vec, + selected: Option, + 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 { + 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 { + 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 { + 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 { + 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::>()}) + } + 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 { + 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); + } +} diff --git a/crates/tools/src/distort/width.rs b/crates/tools/src/distort/width.rs new file mode 100644 index 00000000..b1089067 --- /dev/null +++ b/crates/tools/src/distort/width.rs @@ -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), + /// Slide along the path. + Move, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct Drag { + spot: Spot, + index: Option, + mode: Mode, + left: f64, + right: f64, + t: f64, +} + +#[derive(Default)] +pub struct WidthTool { + hover: Option, + drag: Option, + /// 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 { + 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| 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 { + 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 { + 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 { + 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| { + 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 { + 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")); + } +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index f4433abe..d469d2e0 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -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 { .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")))), } } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 7d0f22da..6a9919e2 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -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()); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e7f56407..ab95f835 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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 { 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 { "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); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 97723b6c..7e7dce9c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index dcb63815..6808acac 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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:, 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 Option { - 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 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 { _ => 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)> { 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)> { 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"), diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index aae6f323..378ee61e 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -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() diff --git a/crates/ui-egui/src/prefs_dialog.rs b/crates/ui-egui/src/prefs_dialog.rs new file mode 100644 index 00000000..990448d1 --- /dev/null +++ b/crates/ui-egui/src/prefs_dialog.rs @@ -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 = 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 { + let Some(d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) }; + let values: Map = 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 = ctx.data(|d| d.get_temp::>(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::(&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)); + } + } +} diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs new file mode 100644 index 00000000..23693f8f --- /dev/null +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -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:`; 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> { + static S: OnceLock>> = 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>> = 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:` 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, key: &str) -> Option { + 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 { + 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 { + 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:` 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> = OnceLock::new(); + E.get_or_init(|| { + let mut v: Vec = 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::() + 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, chord: &str, except: &str) -> Vec { + 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) -> Vec<(String, Vec)> { + let mut by: BTreeMap> = 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, key: &str, chord: Option<&str>, force: bool) -> Result, 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 = 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, 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, key: &str) { + overrides.remove(key); +} + +/// Overrides of a preset (both presets are the registry defaults for now). +pub fn preset(name: &str) -> Option> { + PRESETS.contains(&name).then(BTreeMap::new) +} + +/// Export format: `{"format": "drawcraft-shortcuts", "set": name, "overrides": {...}}`. +pub fn export_json(set: &str, overrides: &BTreeMap) -> Value { + json!({"format": "drawcraft-shortcuts", "version": 1, "set": set, "overrides": overrides}) +} + +pub fn import_json(v: &Value) -> Result<(String, BTreeMap), 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 { + 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 { + 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> { + 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::>())), + "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, 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 = 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"); + } +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index ec926ab9..fc5b03f2 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -43,15 +43,16 @@ pub fn parse(s: &str) -> Option { 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); } } } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 439246ef..f369629d 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -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:` → chord ("" = none). + #[serde(default)] + pub shortcut_overrides: std::collections::BTreeMap, + /// 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, + /// 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::*; diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 050d5be5..b645c6da 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -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, diff --git a/crates/ui-egui/src/workspaces.rs b/crates/ui-egui/src/workspaces.rs new file mode 100644 index 00000000..62226e7c --- /dev/null +++ b/crates/ui-egui/src/workspaces.rs @@ -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, + 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 { + 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 { + 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 { + 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> { + static S: OnceLock>> = 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 { + use crate::menus::Item; + let mut v: Vec = 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> { + 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 { + 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}"); + } + } +}