continued work; wip
This commit is contained in:
parent
94e72f490e
commit
6dd0d2dcff
|
|
@ -863,6 +863,7 @@ name = "drawcraft-color"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"moxcms",
|
||||
"proptest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ pub struct NativeMenu {
|
|||
_menu: Menu,
|
||||
items: HashMap<String, (String, Value, Handle, String)>,
|
||||
last_refresh: f64,
|
||||
/// Shortcut-override / workspace-list generation the menu was built for.
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
/// Accelerator for a shortcut like "Cmd+Shift+]" (modifier-less shortcuts stay in the app so they
|
||||
|
|
@ -33,6 +35,9 @@ fn accel(sc: &str) -> Option<Accelerator> {
|
|||
|
||||
impl NativeMenu {
|
||||
pub fn install(app: &mut DrawcraftApp) -> Self {
|
||||
// Accelerators come from `shortcut_of`, which honours the user's overrides.
|
||||
drawcraft_ui_egui::shortcut_editor::sync(&app.ui);
|
||||
let generation = drawcraft_ui_egui::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let menu = Menu::new();
|
||||
let mut items = HashMap::new();
|
||||
let mut counter = 0usize;
|
||||
|
|
@ -48,7 +53,7 @@ impl NativeMenu {
|
|||
.filter(|(_, p, _, c)| (p.is_null() || p.as_object().is_some_and(|o| o.is_empty())) && menus::shortcut_of(c).and_then(accel).is_some())
|
||||
.map(|(_, _, _, c)| c.clone())
|
||||
.collect();
|
||||
Self { _menu: menu, items, last_refresh: 0.0 }
|
||||
Self { _menu: menu, items, last_refresh: 0.0, generation }
|
||||
}
|
||||
|
||||
/// Dispatch clicked items and refresh state.
|
||||
|
|
@ -64,6 +69,11 @@ impl NativeMenu {
|
|||
return;
|
||||
}
|
||||
self.last_refresh = now;
|
||||
// Shortcuts edited or workspaces added: rebuild so accelerators and lists are current.
|
||||
if drawcraft_ui_egui::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed) != self.generation {
|
||||
*self = NativeMenu::install(app);
|
||||
return;
|
||||
}
|
||||
for (run, rp, h, cmd) in self.items.values() {
|
||||
let null = Value::Null;
|
||||
let params = if run == cmd { rp } else { &null };
|
||||
|
|
@ -83,6 +93,7 @@ impl NativeMenu {
|
|||
| "view.artboards"
|
||||
| "view.boundingBox"
|
||||
| "view.transparencyGrid"
|
||||
| "window.workspace.reset"
|
||||
) {
|
||||
i.set_text(menus::dynamic_label(app, cmd, ""));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ workspace = true
|
|||
kurbo = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
moxcms = "0.8"
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,453 @@
|
|||
//! "DrawCraft Generic CMYK (SWOP-like)": our own parametric press model.
|
||||
//!
|
||||
//! We can't redistribute Adobe's or ECI's characterisation profiles, so the built-in CMYK space is
|
||||
//! a small physical model instead of an ICC LUT. It is *ours* and only SWOP-*like*: the numbers
|
||||
//! are rounded, typical values for coated web offset, not a measured dataset.
|
||||
//!
|
||||
//! * **Forward (CMYK → XYZ):** each ink's nominal dot area is widened by a parabolic dot-gain
|
||||
//! curve (`a + 4·g·a·(1−a)`, i.e. `g` gain at 50%); the effective areas mix the eight CMY
|
||||
//! Neugebauer primaries with Demichel weights; black is mixed in the same way against
|
||||
//! "primary + K" colours whose optical densities add but saturate towards a maximum density
|
||||
//! (normalised so K on paper is exact), so rich blacks don't multiply to an unrealistic depth.
|
||||
//! * **Inverse (Lab → CMYK):** damped, bounded Gauss–Newton on the forward model solves CMY for a
|
||||
//! fixed K. Black generation is GCR: solve CMY with K = 0, take the grey component
|
||||
//! `min(C, M, Y)`, map it through the black-generation curve to K, re-solve CMY. Under-colour
|
||||
//! removal enforces the total area coverage (TAC) limit by adding K and re-solving, then scaling
|
||||
//! CMY as a last resort.
|
||||
//! * **Rendering intents:** colorimetric intents map the source white to paper (relative) or not
|
||||
//! (absolute) and clip; perceptual applies black-point compensation plus soft chroma compression
|
||||
//! against a gamut boundary descriptor (segment maxima of the forward model); saturation keeps
|
||||
//! chroma by pulling out-of-gamut colours towards the hue's cusp lightness.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use super::Intent;
|
||||
use super::lab::{D50, Lab, delta_e2000, lab_to_xyz, srgb_to_lab, xyz_to_lab, xyz_to_srgb};
|
||||
|
||||
/// Black generation strength.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct BlackGeneration {
|
||||
/// Grey component below which no black is generated.
|
||||
pub start: f32,
|
||||
/// Maximum black.
|
||||
pub max_k: f32,
|
||||
/// Curve exponent (>1 = lighter black in the midtones).
|
||||
pub gamma: f32,
|
||||
}
|
||||
|
||||
/// Parameters of the parametric press model.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GenericCmykParams {
|
||||
pub name: &'static str,
|
||||
pub paper: Lab,
|
||||
/// Solid overprints, Demichel order: C, M, CM, Y, CY, MY, CMY.
|
||||
pub solids: [Lab; 7],
|
||||
pub black: Lab,
|
||||
/// Dot gain at 50% per ink (C, M, Y, K).
|
||||
pub dot_gain: [f32; 4],
|
||||
/// Total area coverage limit (sum of inks, 1.0 = 100%).
|
||||
pub tac: f32,
|
||||
pub bg: BlackGeneration,
|
||||
}
|
||||
|
||||
/// Rounded, typical SWOP-like values (not measured data).
|
||||
pub fn swop_like() -> GenericCmykParams {
|
||||
GenericCmykParams {
|
||||
name: super::GENERIC_CMYK,
|
||||
paper: Lab::new(93.0, -0.5, 2.5),
|
||||
solids: [
|
||||
Lab::new(55.0, -37.0, -50.0), // C
|
||||
Lab::new(48.0, 74.0, -3.0), // M
|
||||
Lab::new(24.0, 22.0, -46.0), // CM (blue)
|
||||
Lab::new(89.0, -5.0, 93.0), // Y
|
||||
Lab::new(50.0, -65.0, 27.0), // CY (green)
|
||||
Lab::new(47.0, 68.0, 48.0), // MY (red)
|
||||
Lab::new(24.0, 1.0, 0.0), // CMY
|
||||
],
|
||||
black: Lab::new(19.0, 0.5, 0.5),
|
||||
dot_gain: [0.20, 0.20, 0.20, 0.22],
|
||||
tac: 3.0,
|
||||
bg: BlackGeneration { start: 0.1, max_k: 0.95, gamma: 1.25 },
|
||||
}
|
||||
}
|
||||
|
||||
/// Gamut boundary descriptor: max chroma per (L, hue) cell in media-relative Lab.
|
||||
struct Gbd {
|
||||
cmax: Vec<f32>,
|
||||
/// Lightness of maximum chroma per hue bin.
|
||||
cusp_l: Vec<f32>,
|
||||
}
|
||||
|
||||
const GBD_L: usize = 51; // L 0..100, step 2
|
||||
const GBD_H: usize = 72; // 5° bins
|
||||
|
||||
pub struct GenericCmyk {
|
||||
pub params: GenericCmykParams,
|
||||
/// XYZ of the 8 CMY primaries (index = c | m<<1 | y<<2), without and with K.
|
||||
prim: [[f32; 3]; 8],
|
||||
prim_k: [[f32; 3]; 8],
|
||||
paper_xyz: [f32; 3],
|
||||
/// Media-relative XYZ of the darkest printable black (for black-point compensation).
|
||||
black_rel: [f32; 3],
|
||||
gbd: OnceLock<Gbd>,
|
||||
}
|
||||
|
||||
impl GenericCmyk {
|
||||
pub fn new(params: GenericCmykParams) -> Self {
|
||||
let paper_xyz = lab_to_xyz(params.paper);
|
||||
let mut prim = [[0.0f32; 3]; 8];
|
||||
prim[0] = paper_xyz;
|
||||
for i in 1..8 {
|
||||
prim[i] = lab_to_xyz(params.solids[i - 1]);
|
||||
}
|
||||
let k_xyz = lab_to_xyz(params.black);
|
||||
// Black over other inks: optical densities add, but saturate towards a maximum density
|
||||
// (ink trapping / surface reflection), normalised so that K on paper is exact.
|
||||
const D_MAX: f32 = 2.0;
|
||||
let sat = |d: f32| D_MAX * (1.0 - (-d / D_MAX).exp());
|
||||
let mut prim_k = [[0.0f32; 3]; 8];
|
||||
for i in 0..8 {
|
||||
for ch in 0..3 {
|
||||
let di = -(prim[i][ch] / paper_xyz[ch]).max(1e-6).log10();
|
||||
let dk = -(k_xyz[ch] / paper_xyz[ch]).max(1e-6).log10();
|
||||
let d = sat(di + dk) * dk / sat(dk).max(1e-6);
|
||||
prim_k[i][ch] = paper_xyz[ch] * 10f32.powf(-d.max(di));
|
||||
}
|
||||
}
|
||||
let mut g = Self { params, prim, prim_k, paper_xyz, black_rel: [0.0; 3], gbd: OnceLock::new() };
|
||||
// Darkest black within TAC: search a few rich blacks.
|
||||
let mut best = [1.0f32; 3];
|
||||
for k in [0.8f32, 0.9, 1.0] {
|
||||
let rest = (g.params.tac - k).max(0.0) / 3.0;
|
||||
let cmy = rest.min(1.0);
|
||||
let x = g.to_xyz_rel([cmy, cmy, cmy, k]);
|
||||
if x[1] < best[1] {
|
||||
best = x;
|
||||
}
|
||||
}
|
||||
g.black_rel = best;
|
||||
g
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
self.params.name
|
||||
}
|
||||
|
||||
fn gain(a: f32, g: f32) -> f32 {
|
||||
let a = a.clamp(0.0, 1.0);
|
||||
(a + 4.0 * g * a * (1.0 - a)).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// CMYK (0..1) → absolute XYZ (D50).
|
||||
pub fn to_xyz(&self, cmyk: [f32; 4]) -> [f32; 3] {
|
||||
let dg = self.params.dot_gain;
|
||||
let c = Self::gain(cmyk[0], dg[0]);
|
||||
let m = Self::gain(cmyk[1], dg[1]);
|
||||
let y = Self::gain(cmyk[2], dg[2]);
|
||||
let k = Self::gain(cmyk[3], dg[3]);
|
||||
let mut out = [0.0f32; 3];
|
||||
for i in 0..8 {
|
||||
let w = (if i & 1 != 0 { c } else { 1.0 - c }) * (if i & 2 != 0 { m } else { 1.0 - m }) * (if i & 4 != 0 { y } else { 1.0 - y });
|
||||
if w == 0.0 {
|
||||
continue;
|
||||
}
|
||||
for ch in 0..3 {
|
||||
out[ch] += w * ((1.0 - k) * self.prim[i][ch] + k * self.prim_k[i][ch]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Media-relative XYZ (paper = D50 white).
|
||||
pub fn to_xyz_rel(&self, cmyk: [f32; 4]) -> [f32; 3] {
|
||||
let x = self.to_xyz(cmyk);
|
||||
[x[0] * D50[0] / self.paper_xyz[0], x[1] * D50[1] / self.paper_xyz[1], x[2] * D50[2] / self.paper_xyz[2]]
|
||||
}
|
||||
|
||||
pub fn to_lab_rel(&self, cmyk: [f32; 4]) -> Lab {
|
||||
xyz_to_lab(self.to_xyz_rel(cmyk))
|
||||
}
|
||||
pub fn to_lab_abs(&self, cmyk: [f32; 4]) -> Lab {
|
||||
xyz_to_lab(self.to_xyz(cmyk))
|
||||
}
|
||||
|
||||
/// Display sRGB: media-relative (paper shows as white) or absolute (simulate paper colour).
|
||||
pub fn to_srgb(&self, cmyk: [f32; 4], absolute: bool) -> [f32; 3] {
|
||||
xyz_to_srgb(if absolute { self.to_xyz(cmyk) } else { self.to_xyz_rel(cmyk) })
|
||||
}
|
||||
|
||||
/// Lab of the darkest printable black (media-relative).
|
||||
pub fn black_point(&self) -> Lab {
|
||||
xyz_to_lab(self.black_rel)
|
||||
}
|
||||
|
||||
/// Solve CMY for a fixed K so that the media-relative Lab matches `target` (bounded).
|
||||
fn solve_cmy(&self, target: Lab, k: f32, init: [f32; 3]) -> [f32; 3] {
|
||||
let mut x = init.map(|v| v.clamp(0.0, 1.0));
|
||||
let f = |x: [f32; 3]| {
|
||||
let l = self.to_lab_rel([x[0], x[1], x[2], k]);
|
||||
[l.l - target.l, l.a - target.a, l.b - target.b]
|
||||
};
|
||||
let mut r = f(x);
|
||||
let mut err = r.iter().map(|v| v * v).sum::<f32>();
|
||||
let mut lambda = 1e-3f32;
|
||||
for _ in 0..40 {
|
||||
if err < 1e-4 {
|
||||
break;
|
||||
}
|
||||
// Numeric Jacobian (one-sided, pointing into the box).
|
||||
let mut j = [[0.0f32; 3]; 3];
|
||||
for col in 0..3 {
|
||||
let h = if x[col] > 0.5 { -1e-3 } else { 1e-3 };
|
||||
let mut xp = x;
|
||||
xp[col] += h;
|
||||
let rp = f(xp);
|
||||
for row in 0..3 {
|
||||
j[row][col] = (rp[row] - r[row]) / h;
|
||||
}
|
||||
}
|
||||
// Levenberg–Marquardt step: (JᵀJ + λ·diag) δ = −Jᵀr.
|
||||
let mut a = [[0.0f32; 3]; 3];
|
||||
let mut g = [0.0f32; 3];
|
||||
for p in 0..3 {
|
||||
for q in 0..3 {
|
||||
a[p][q] = (0..3).map(|i| j[i][p] * j[i][q]).sum();
|
||||
}
|
||||
g[p] = -(0..3).map(|i| j[i][p] * r[i]).sum::<f32>();
|
||||
}
|
||||
let mut improved = false;
|
||||
for _ in 0..8 {
|
||||
let mut al = a;
|
||||
for (p, row) in al.iter_mut().enumerate() {
|
||||
row[p] += lambda * (a[p][p] + 1e-3);
|
||||
}
|
||||
let Some(d) = solve3(al, g) else {
|
||||
lambda *= 10.0;
|
||||
continue;
|
||||
};
|
||||
let xn = [(x[0] + d[0]).clamp(0.0, 1.0), (x[1] + d[1]).clamp(0.0, 1.0), (x[2] + d[2]).clamp(0.0, 1.0)];
|
||||
let rn = f(xn);
|
||||
let en = rn.iter().map(|v| v * v).sum::<f32>();
|
||||
if en < err {
|
||||
let step = (0..3).map(|i| (xn[i] - x[i]).abs()).fold(0.0, f32::max);
|
||||
x = xn;
|
||||
r = rn;
|
||||
err = en;
|
||||
lambda = (lambda * 0.3).max(1e-7);
|
||||
improved = step > 1e-6;
|
||||
break;
|
||||
}
|
||||
lambda *= 10.0;
|
||||
}
|
||||
if !improved {
|
||||
break;
|
||||
}
|
||||
}
|
||||
x
|
||||
}
|
||||
|
||||
fn initial_cmy(target: Lab, k: f32) -> [f32; 3] {
|
||||
let rgb = super::lab::lab_to_srgb(target);
|
||||
let d = (1.0 - k).max(0.05);
|
||||
rgb.map(|v| ((1.0 - v - k) / d).clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
fn black_for_grey(&self, grey: f32) -> f32 {
|
||||
let bg = self.params.bg;
|
||||
if grey <= bg.start {
|
||||
return 0.0;
|
||||
}
|
||||
(((grey - bg.start) / (1.0 - bg.start)).clamp(0.0, 1.0).powf(bg.gamma) * bg.max_k).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Separate a media-relative Lab (already gamut-mapped) into CMYK with GCR/UCR and the TAC limit.
|
||||
pub fn separate(&self, target: Lab) -> [f32; 4] {
|
||||
if target.l >= 99.99 && target.a.abs() < 0.01 && target.b.abs() < 0.01 {
|
||||
return [0.0; 4];
|
||||
}
|
||||
let cmy0 = self.solve_cmy(target, 0.0, Self::initial_cmy(target, 0.0));
|
||||
let grey = cmy0[0].min(cmy0[1]).min(cmy0[2]);
|
||||
let mut k = self.black_for_grey(grey);
|
||||
// If CMY alone can't get dark enough, black must carry the rest.
|
||||
let l0 = self.to_lab_rel([cmy0[0], cmy0[1], cmy0[2], 0.0]).l;
|
||||
if l0 > target.l + 1.0 {
|
||||
k = k.max(((l0 - target.l) / l0.max(1.0)).clamp(0.0, 1.0));
|
||||
}
|
||||
let mut cmy = if k > 0.0 { self.solve_cmy(target, k, Self::initial_cmy(target, k)) } else { cmy0 };
|
||||
// UCR: trade CMY for K until within the TAC limit.
|
||||
for _ in 0..6 {
|
||||
let sum = cmy.iter().sum::<f32>() + k;
|
||||
if sum <= self.params.tac + 1e-3 || k >= 1.0 {
|
||||
break;
|
||||
}
|
||||
k = (k + (sum - self.params.tac) * 0.5).min(1.0);
|
||||
cmy = self.solve_cmy(target, k, cmy);
|
||||
}
|
||||
let sum = cmy.iter().sum::<f32>() + k;
|
||||
if sum > self.params.tac {
|
||||
let s = ((self.params.tac - k) / cmy.iter().sum::<f32>().max(1e-6)).clamp(0.0, 1.0);
|
||||
cmy = cmy.map(|v| v * s);
|
||||
}
|
||||
[cmy[0], cmy[1], cmy[2], k].map(|v| if v < 1e-4 { 0.0 } else { v.min(1.0) })
|
||||
}
|
||||
|
||||
fn gbd(&self) -> &Gbd {
|
||||
self.gbd.get_or_init(|| {
|
||||
let mut cmax = vec![0.0f32; GBD_L * GBD_H];
|
||||
let mut cusp = vec![(0.0f32, 50.0f32); GBD_H];
|
||||
let n = 16;
|
||||
for ki in 0..=5 {
|
||||
let k = ki as f32 / 5.0;
|
||||
for ci in 0..=n {
|
||||
for mi in 0..=n {
|
||||
for yi in 0..=n {
|
||||
let (c, m, y) = (ci as f32 / n as f32, mi as f32 / n as f32, yi as f32 / n as f32);
|
||||
if c + m + y + k > self.params.tac + 1e-3 {
|
||||
continue;
|
||||
}
|
||||
let lab = self.to_lab_rel([c, m, y, k]);
|
||||
let li = ((lab.l / 2.0).round() as usize).min(GBD_L - 1);
|
||||
let hi = ((lab.hue() / 5.0) as usize).min(GBD_H - 1);
|
||||
let ch = lab.chroma();
|
||||
let cell = &mut cmax[li * GBD_H + hi];
|
||||
*cell = cell.max(ch);
|
||||
if ch > cusp[hi].0 {
|
||||
cusp[hi] = (ch, lab.l);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fill holes: take the max over neighbouring hue bins, then interpolate along L.
|
||||
let mut smooth = cmax.clone();
|
||||
for li in 0..GBD_L {
|
||||
for hi in 0..GBD_H {
|
||||
let get = |h: usize| cmax[li * GBD_H + h % GBD_H];
|
||||
smooth[li * GBD_H + hi] = get(hi).max(get(hi + 1)).max(get(hi + GBD_H - 1));
|
||||
}
|
||||
}
|
||||
for hi in 0..GBD_H {
|
||||
let col: Vec<f32> = (0..GBD_L).map(|li| smooth[li * GBD_H + hi]).collect();
|
||||
let known: Vec<usize> = (0..GBD_L).filter(|&li| col[li] > 0.0).collect();
|
||||
for li in 0..GBD_L {
|
||||
if col[li] > 0.0 {
|
||||
continue;
|
||||
}
|
||||
let below = known.iter().rev().find(|&&k| k < li);
|
||||
let above = known.iter().find(|&&k| k > li);
|
||||
smooth[li * GBD_H + hi] = match (below, above) {
|
||||
(Some(&b), Some(&a)) => col[b] + (col[a] - col[b]) * (li - b) as f32 / (a - b) as f32,
|
||||
_ => 0.0,
|
||||
};
|
||||
}
|
||||
}
|
||||
Gbd { cmax: smooth, cusp_l: cusp.into_iter().map(|c| c.1).collect() }
|
||||
})
|
||||
}
|
||||
|
||||
/// Maximum printable chroma at (L, hue), media-relative.
|
||||
pub fn max_chroma(&self, l: f32, hue: f32) -> f32 {
|
||||
let g = self.gbd();
|
||||
let lf = (l.clamp(0.0, 100.0) / 2.0).min((GBD_L - 1) as f32);
|
||||
let hf = hue.rem_euclid(360.0) / 5.0;
|
||||
let (l0, h0) = (lf.floor() as usize, hf.floor() as usize);
|
||||
let (l1, h1) = ((l0 + 1).min(GBD_L - 1), (h0 + 1) % GBD_H);
|
||||
let (tl, th) = (lf - l0 as f32, hf - h0 as f32);
|
||||
let at = |li: usize, hi: usize| g.cmax[li * GBD_H + hi % GBD_H];
|
||||
let a = at(l0, h0) * (1.0 - th) + at(l0, h1) * th;
|
||||
let b = at(l1, h0) * (1.0 - th) + at(l1, h1) * th;
|
||||
a * (1.0 - tl) + b * tl
|
||||
}
|
||||
|
||||
fn cusp_l(&self, hue: f32) -> f32 {
|
||||
self.gbd().cusp_l[((hue.rem_euclid(360.0) / 5.0) as usize).min(GBD_H - 1)]
|
||||
}
|
||||
|
||||
/// Map an absolute source Lab (D50; sRGB white = L 100) to the media-relative target for `intent`.
|
||||
pub fn gamut_map(&self, src: Lab, intent: Intent, bpc: bool) -> Lab {
|
||||
if intent == Intent::AbsoluteColorimetric {
|
||||
// Keep absolute colorimetry: express in media-relative terms without white mapping.
|
||||
let x = lab_to_xyz(src);
|
||||
return xyz_to_lab([x[0] * D50[0] / self.paper_xyz[0], x[1] * D50[1] / self.paper_xyz[1], x[2] * D50[2] / self.paper_xyz[2]]);
|
||||
}
|
||||
let mut lab = src;
|
||||
if bpc || matches!(intent, Intent::Perceptual | Intent::Saturation) {
|
||||
// Black-point compensation in XYZ: source black (0) → printable black.
|
||||
let x = lab_to_xyz(lab);
|
||||
let bp = self.black_rel;
|
||||
let y = [0, 1, 2].map(|i| bp[i] + x[i] * (1.0 - bp[i] / D50[i]));
|
||||
lab = xyz_to_lab(y);
|
||||
}
|
||||
match intent {
|
||||
Intent::Perceptual => {
|
||||
let c = lab.chroma();
|
||||
if c > 1e-3 {
|
||||
// Cusp-directed: very out-of-gamut colours also move towards the lightness
|
||||
// where the press reaches its maximum chroma for this hue.
|
||||
let h = lab.hue();
|
||||
let cmax0 = self.max_chroma(lab.l, h).max(1.0);
|
||||
if c > cmax0 {
|
||||
let w = ((c - cmax0) / c).clamp(0.0, 1.0) * 0.9;
|
||||
lab.l += (self.cusp_l(h) - lab.l) * w;
|
||||
}
|
||||
let cmax = self.max_chroma(lab.l, h).max(1.0);
|
||||
let r = c / cmax;
|
||||
const KNEE: f32 = 0.75;
|
||||
let r2 = if r > KNEE { KNEE + (1.0 - KNEE) * (1.0 - (-(r - KNEE) / (1.0 - KNEE)).exp()) } else { r };
|
||||
lab = Lab::from_lch(lab.l, r2 * cmax * 0.995, lab.hue());
|
||||
}
|
||||
lab
|
||||
}
|
||||
Intent::Saturation => {
|
||||
let (c, h) = (lab.chroma(), lab.hue());
|
||||
if c > 1e-3 {
|
||||
let mut l = lab.l;
|
||||
let cmax = self.max_chroma(l, h);
|
||||
if c > cmax {
|
||||
let cl = self.cusp_l(h);
|
||||
l += (cl - l) * ((c - cmax) / c.max(1.0)).clamp(0.0, 1.0);
|
||||
}
|
||||
let cmax = self.max_chroma(l, h);
|
||||
lab = Lab::from_lch(l, (c * 1.1).min(cmax * 0.995), h);
|
||||
}
|
||||
lab
|
||||
}
|
||||
_ => lab,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert an absolute Lab to CMYK with `intent`.
|
||||
pub fn from_lab(&self, src: Lab, intent: Intent, bpc: bool) -> [f32; 4] {
|
||||
self.separate(self.gamut_map(src, intent, bpc))
|
||||
}
|
||||
|
||||
pub fn from_srgb(&self, rgb: [f32; 3], intent: Intent, bpc: bool) -> [f32; 4] {
|
||||
self.from_lab(srgb_to_lab(rgb), intent, bpc)
|
||||
}
|
||||
|
||||
/// ΔE2000 between an sRGB colour and its best (relative colorimetric) reproduction.
|
||||
pub fn gamut_error(&self, src: Lab) -> f32 {
|
||||
let target = self.gamut_map(src, Intent::RelativeColorimetric, false);
|
||||
let cmyk = self.separate(target);
|
||||
delta_e2000(self.to_lab_rel(cmyk), target)
|
||||
}
|
||||
}
|
||||
|
||||
fn solve3(a: [[f32; 3]; 3], b: [f32; 3]) -> Option<[f32; 3]> {
|
||||
let det = a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1]) - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
|
||||
+ a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
|
||||
if det.abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let mut out = [0.0f32; 3];
|
||||
for (i, o) in out.iter_mut().enumerate() {
|
||||
let mut m = a;
|
||||
for r in 0..3 {
|
||||
m[r][i] = b[r];
|
||||
}
|
||||
*o = (m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
|
||||
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]))
|
||||
/ det;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
//! ICC profiles through `moxcms` (pure Rust): built-in RGB spaces and user-supplied `.icc` files.
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use moxcms::{ColorProfile, DataColorSpace, Layout, ProfileText, RenderingIntent, TransformF32Executor, TransformOptions};
|
||||
|
||||
use super::{CmsError, Intent, ProfileKind};
|
||||
|
||||
fn moxcms_intent(i: Intent) -> RenderingIntent {
|
||||
match i {
|
||||
Intent::Perceptual => RenderingIntent::Perceptual,
|
||||
Intent::RelativeColorimetric => RenderingIntent::RelativeColorimetric,
|
||||
Intent::Saturation => RenderingIntent::Saturation,
|
||||
Intent::AbsoluteColorimetric => RenderingIntent::AbsoluteColorimetric,
|
||||
}
|
||||
}
|
||||
|
||||
const INTENTS: [Intent; 4] = [Intent::Perceptual, Intent::RelativeColorimetric, Intent::Saturation, Intent::AbsoluteColorimetric];
|
||||
|
||||
fn intent_index(i: Intent) -> usize {
|
||||
INTENTS.iter().position(|x| *x == i).unwrap_or(1)
|
||||
}
|
||||
|
||||
type Xf = Option<Arc<TransformF32Executor>>;
|
||||
|
||||
/// A parsed ICC profile plus lazily-built transforms to/from sRGB.
|
||||
pub struct IccProfile {
|
||||
pub name: String,
|
||||
pub kind: ProfileKind,
|
||||
profile: ColorProfile,
|
||||
srgb: ColorProfile,
|
||||
to_srgb: [OnceLock<Xf>; 4],
|
||||
from_srgb: [OnceLock<Xf>; 4],
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for IccProfile {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("IccProfile").field("name", &self.name).field("kind", &self.kind).finish()
|
||||
}
|
||||
}
|
||||
|
||||
fn text(t: &Option<ProfileText>) -> Option<String> {
|
||||
match t.as_ref()? {
|
||||
ProfileText::PlainString(s) => Some(s.clone()),
|
||||
ProfileText::Localizable(v) => v.iter().find(|l| l.language == "en").or(v.first()).map(|l| l.value.clone()),
|
||||
ProfileText::Description(d) => Some(if d.ascii_string.is_empty() { d.unicode_string.clone() } else { d.ascii_string.clone() }),
|
||||
}
|
||||
.map(|s| s.trim_matches(char::from(0)).trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
impl IccProfile {
|
||||
pub fn from_profile(name: Option<String>, profile: ColorProfile) -> Result<Self, CmsError> {
|
||||
let kind = match profile.color_space {
|
||||
DataColorSpace::Rgb => ProfileKind::Rgb,
|
||||
DataColorSpace::Cmyk => ProfileKind::Cmyk,
|
||||
DataColorSpace::Gray => ProfileKind::Gray,
|
||||
other => return Err(CmsError::Unsupported(format!("profile colour space {other:?} is not RGB, CMYK or Gray"))),
|
||||
};
|
||||
let name = name.or_else(|| text(&profile.description)).unwrap_or_else(|| format!("Untitled {kind:?} profile"));
|
||||
Ok(Self { name, kind, profile, srgb: ColorProfile::new_srgb(), to_srgb: Default::default(), from_srgb: Default::default() })
|
||||
}
|
||||
|
||||
pub fn from_bytes(name: Option<String>, bytes: &[u8]) -> Result<Self, CmsError> {
|
||||
let p = ColorProfile::new_from_slice(bytes).map_err(|e| CmsError::BadProfile(format!("{e:?}")))?;
|
||||
Self::from_profile(name, p)
|
||||
}
|
||||
|
||||
fn layout(&self) -> Layout {
|
||||
match self.kind {
|
||||
ProfileKind::Cmyk => Layout::Rgba, // 4 channels
|
||||
ProfileKind::Gray => Layout::Gray,
|
||||
ProfileKind::Rgb => Layout::Rgb,
|
||||
}
|
||||
}
|
||||
|
||||
fn channels(&self) -> usize {
|
||||
match self.kind {
|
||||
ProfileKind::Cmyk => 4,
|
||||
ProfileKind::Gray => 1,
|
||||
ProfileKind::Rgb => 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn opts(intent: Intent) -> TransformOptions {
|
||||
TransformOptions { rendering_intent: moxcms_intent(intent), ..Default::default() }
|
||||
}
|
||||
|
||||
fn to_xf(&self, intent: Intent) -> Xf {
|
||||
self.to_srgb[intent_index(intent)]
|
||||
.get_or_init(|| self.profile.create_transform_f32(self.layout(), &self.srgb, Layout::Rgb, Self::opts(intent)).ok())
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn from_xf(&self, intent: Intent) -> Xf {
|
||||
self.from_srgb[intent_index(intent)]
|
||||
.get_or_init(|| self.srgb.create_transform_f32(Layout::Rgb, &self.profile, self.layout(), Self::opts(intent)).ok())
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Device values (RGB / CMYK / Gray, 0..1) → sRGB.
|
||||
pub fn to_srgb(&self, v: &[f32], intent: Intent) -> Option<[f32; 3]> {
|
||||
let xf = self.to_xf(intent)?;
|
||||
let mut src = v.to_vec();
|
||||
src.resize(self.channels(), 0.0);
|
||||
let mut dst = [0.0f32; 3];
|
||||
xf.transform(&src, &mut dst).ok()?;
|
||||
Some(dst.map(|x| x.clamp(0.0, 1.0)))
|
||||
}
|
||||
|
||||
/// sRGB → device values.
|
||||
pub fn from_srgb(&self, rgb: [f32; 3], intent: Intent) -> Option<Vec<f32>> {
|
||||
let xf = self.from_xf(intent)?;
|
||||
let mut dst = vec![0.0f32; self.channels()];
|
||||
xf.transform(&rgb, &mut dst).ok()?;
|
||||
Some(dst.into_iter().map(|x| x.clamp(0.0, 1.0)).collect())
|
||||
}
|
||||
|
||||
/// Whether transforms can be built for this profile (checked at registration).
|
||||
pub fn usable(&self) -> bool {
|
||||
self.to_xf(Intent::RelativeColorimetric).is_some() && self.from_xf(Intent::RelativeColorimetric).is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// Built-in RGB working spaces from `moxcms`'s standard primaries.
|
||||
pub fn builtin_rgb(name: &str) -> Option<IccProfile> {
|
||||
let p = match name {
|
||||
super::ADOBE_RGB => ColorProfile::new_adobe_rgb(),
|
||||
super::DISPLAY_P3 => ColorProfile::new_display_p3(),
|
||||
super::PROPHOTO_RGB => ColorProfile::new_pro_photo_rgb(),
|
||||
super::SRGB => ColorProfile::new_srgb(),
|
||||
_ => return None,
|
||||
};
|
||||
IccProfile::from_profile(Some(name.to_string()), p).ok()
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,638 @@
|
|||
//! Colour management: working spaces, RGB ↔ CMYK ↔ Lab conversions with rendering intents,
|
||||
//! gamut checks and soft-proof transforms.
|
||||
//!
|
||||
//! * **RGB working spaces** are ICC matrix/shaper profiles via [`moxcms`] (sRGB, Adobe RGB (1998)
|
||||
//! compatible, Display P3, ProPhoto RGB) or user `.icc` files. Display output is sRGB.
|
||||
//! * **CMYK spaces:** [`GENERIC_CMYK`] is our own documented parametric press model
|
||||
//! ([`generic`]); [`DEVICE_CMYK`] is the old profile-free formula (kept for exact legacy numbers);
|
||||
//! any CMYK `.icc` the user loads goes through `moxcms` (LUT-based profiles).
|
||||
//! * The **active settings** (Edit → Color Settings) are process-wide; [`crate::Color::to_rgb`]
|
||||
//! uses them. Code that needs deterministic numbers builds its own [`Cms`] with [`Cms::new`].
|
||||
|
||||
pub mod generic;
|
||||
pub mod icc;
|
||||
pub mod lab;
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock, RwLock};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use generic::GenericCmyk;
|
||||
pub use icc::IccProfile;
|
||||
pub use lab::{Lab, delta_e76, delta_e2000};
|
||||
|
||||
use crate::Color;
|
||||
|
||||
pub const SRGB: &str = "sRGB IEC61966-2.1";
|
||||
pub const ADOBE_RGB: &str = "Adobe RGB (1998) compatible";
|
||||
pub const DISPLAY_P3: &str = "Display P3";
|
||||
pub const PROPHOTO_RGB: &str = "ProPhoto RGB";
|
||||
/// Our parametric press model (see [`generic`]). Not an Adobe/ECI profile.
|
||||
pub const GENERIC_CMYK: &str = "DrawCraft Generic CMYK (SWOP-like)";
|
||||
/// Profile-free CMYK (`rgb = (1−c)(1−k)` …): uncalibrated, kept for legacy numbers.
|
||||
pub const DEVICE_CMYK: &str = "Device CMYK (uncalibrated)";
|
||||
|
||||
/// ΔE2000 above which a colour counts as out of the CMYK gamut (the gamut warning).
|
||||
pub const GAMUT_THRESHOLD: f32 = 2.0;
|
||||
|
||||
/// ICC rendering intent.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Intent {
|
||||
Perceptual,
|
||||
#[default]
|
||||
RelativeColorimetric,
|
||||
Saturation,
|
||||
AbsoluteColorimetric,
|
||||
}
|
||||
|
||||
impl Intent {
|
||||
pub const ALL: [Intent; 4] = [Intent::Perceptual, Intent::RelativeColorimetric, Intent::Saturation, Intent::AbsoluteColorimetric];
|
||||
/// Parse `perceptual`, `relative`/`relativeColorimetric`, `saturation`, `absolute`/`absoluteColorimetric`.
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
Some(match s.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() {
|
||||
"perceptual" => Intent::Perceptual,
|
||||
"relative" | "relativecolorimetric" => Intent::RelativeColorimetric,
|
||||
"saturation" => Intent::Saturation,
|
||||
"absolute" | "absolutecolorimetric" => Intent::AbsoluteColorimetric,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
pub fn id(&self) -> &'static str {
|
||||
match self {
|
||||
Intent::Perceptual => "perceptual",
|
||||
Intent::RelativeColorimetric => "relative",
|
||||
Intent::Saturation => "saturation",
|
||||
Intent::AbsoluteColorimetric => "absolute",
|
||||
}
|
||||
}
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Intent::Perceptual => "Perceptual",
|
||||
Intent::RelativeColorimetric => "Relative Colorimetric",
|
||||
Intent::Saturation => "Saturation",
|
||||
Intent::AbsoluteColorimetric => "Absolute Colorimetric",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ProfileKind {
|
||||
Rgb,
|
||||
Cmyk,
|
||||
Gray,
|
||||
}
|
||||
|
||||
/// A profile the user can pick.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ProfileInfo {
|
||||
pub name: String,
|
||||
pub kind: ProfileKind,
|
||||
pub builtin: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum CmsError {
|
||||
#[error("unknown colour profile: {0}")]
|
||||
UnknownProfile(String),
|
||||
#[error("invalid ICC profile: {0}")]
|
||||
BadProfile(String),
|
||||
#[error("unsupported: {0}")]
|
||||
Unsupported(String),
|
||||
#[error("cannot read profile: {0}")]
|
||||
Io(String),
|
||||
}
|
||||
|
||||
/// Edit → Color Settings.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ColorSettings {
|
||||
/// RGB working space.
|
||||
pub rgb: String,
|
||||
/// CMYK working space.
|
||||
pub cmyk: String,
|
||||
/// Conversion intent (Convert to CMYK, document colour mode).
|
||||
pub intent: Intent,
|
||||
/// Black-point compensation.
|
||||
pub bpc: bool,
|
||||
}
|
||||
|
||||
impl Default for ColorSettings {
|
||||
fn default() -> Self {
|
||||
Self { rgb: SRGB.into(), cmyk: GENERIC_CMYK.into(), intent: Intent::RelativeColorimetric, bpc: true }
|
||||
}
|
||||
}
|
||||
|
||||
/// Target colour model for [`Cms::convert`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Model {
|
||||
Rgb,
|
||||
Cmyk,
|
||||
Gray,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum CmykSpace {
|
||||
Generic(Arc<GenericCmyk>),
|
||||
Device,
|
||||
Icc(Arc<IccProfile>),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum RgbSpace {
|
||||
Srgb,
|
||||
Icc(Arc<IccProfile>),
|
||||
}
|
||||
|
||||
fn generic() -> Arc<GenericCmyk> {
|
||||
static G: OnceLock<Arc<GenericCmyk>> = OnceLock::new();
|
||||
G.get_or_init(|| Arc::new(GenericCmyk::new(generic::swop_like()))).clone()
|
||||
}
|
||||
|
||||
static USER: RwLock<Vec<Arc<IccProfile>>> = RwLock::new(Vec::new());
|
||||
|
||||
fn builtin_rgb_cache(name: &str) -> Option<Arc<IccProfile>> {
|
||||
static CACHE: Mutex<Vec<Arc<IccProfile>>> = Mutex::new(Vec::new());
|
||||
let mut c = CACHE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(p) = c.iter().find(|p| p.name == name) {
|
||||
return Some(p.clone());
|
||||
}
|
||||
let p = Arc::new(icc::builtin_rgb(name)?);
|
||||
c.push(p.clone());
|
||||
Some(p)
|
||||
}
|
||||
|
||||
fn user_profile(name: &str) -> Option<Arc<IccProfile>> {
|
||||
USER.read().unwrap_or_else(|e| e.into_inner()).iter().find(|p| p.name == name).cloned()
|
||||
}
|
||||
|
||||
fn cmyk_space(name: &str) -> Result<CmykSpace, CmsError> {
|
||||
match name {
|
||||
GENERIC_CMYK => Ok(CmykSpace::Generic(generic())),
|
||||
DEVICE_CMYK => Ok(CmykSpace::Device),
|
||||
_ => match user_profile(name) {
|
||||
Some(p) if p.kind == ProfileKind::Cmyk => Ok(CmykSpace::Icc(p)),
|
||||
Some(_) => Err(CmsError::Unsupported(format!("{name} is not a CMYK profile"))),
|
||||
None => Err(CmsError::UnknownProfile(name.into())),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn rgb_space(name: &str) -> Result<RgbSpace, CmsError> {
|
||||
if name == SRGB {
|
||||
return Ok(RgbSpace::Srgb);
|
||||
}
|
||||
if let Some(p) = builtin_rgb_cache(name) {
|
||||
return Ok(RgbSpace::Icc(p));
|
||||
}
|
||||
match user_profile(name) {
|
||||
Some(p) if p.kind == ProfileKind::Rgb => Ok(RgbSpace::Icc(p)),
|
||||
Some(_) => Err(CmsError::Unsupported(format!("{name} is not an RGB profile"))),
|
||||
None => Err(CmsError::UnknownProfile(name.into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every profile available (built-in first, then user-loaded).
|
||||
pub fn profiles() -> Vec<ProfileInfo> {
|
||||
let mut v: Vec<ProfileInfo> = [SRGB, ADOBE_RGB, DISPLAY_P3, PROPHOTO_RGB]
|
||||
.iter()
|
||||
.map(|n| ProfileInfo { name: (*n).into(), kind: ProfileKind::Rgb, builtin: true })
|
||||
.chain([GENERIC_CMYK, DEVICE_CMYK].iter().map(|n| ProfileInfo { name: (*n).into(), kind: ProfileKind::Cmyk, builtin: true }))
|
||||
.collect();
|
||||
for p in USER.read().unwrap_or_else(|e| e.into_inner()).iter() {
|
||||
v.push(ProfileInfo { name: p.name.clone(), kind: p.kind, builtin: false });
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Register an ICC profile from bytes (named by its description unless `name` is given).
|
||||
/// Re-registering a name replaces the old profile.
|
||||
pub fn register_icc(bytes: &[u8], name: Option<String>) -> Result<ProfileInfo, CmsError> {
|
||||
let p = IccProfile::from_bytes(name, bytes)?;
|
||||
if p.kind == ProfileKind::Gray {
|
||||
return Err(CmsError::Unsupported("grayscale profiles can't be working spaces yet".into()));
|
||||
}
|
||||
if [SRGB, ADOBE_RGB, DISPLAY_P3, PROPHOTO_RGB, GENERIC_CMYK, DEVICE_CMYK].contains(&p.name.as_str()) {
|
||||
return Err(CmsError::Unsupported(format!("{} clashes with a built-in profile name", p.name)));
|
||||
}
|
||||
if !p.usable() {
|
||||
return Err(CmsError::Unsupported(format!("{}: no usable transform to/from sRGB", p.name)));
|
||||
}
|
||||
let info = ProfileInfo { name: p.name.clone(), kind: p.kind, builtin: false };
|
||||
let mut u = USER.write().unwrap_or_else(|e| e.into_inner());
|
||||
u.retain(|x| x.name != info.name);
|
||||
u.push(Arc::new(p));
|
||||
drop(u);
|
||||
clear_proof_cache();
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// Load a `.icc`/`.icm` file from disk (native only).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn load_icc_file(path: &std::path::Path) -> Result<ProfileInfo, CmsError> {
|
||||
let bytes = std::fs::read(path).map_err(|e| CmsError::Io(format!("{}: {e}", path.display())))?;
|
||||
register_icc(&bytes, None)
|
||||
}
|
||||
|
||||
/// A colour-management context: resolved working spaces plus conversion settings.
|
||||
#[derive(Clone)]
|
||||
pub struct Cms {
|
||||
settings: ColorSettings,
|
||||
rgb: RgbSpace,
|
||||
cmyk: CmykSpace,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Cms {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Cms").field("settings", &self.settings).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Cms {
|
||||
fn default() -> Self {
|
||||
Self { settings: ColorSettings::default(), rgb: RgbSpace::Srgb, cmyk: CmykSpace::Generic(generic()) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Cms {
|
||||
pub fn new(settings: &ColorSettings) -> Result<Self, CmsError> {
|
||||
Ok(Self { settings: settings.clone(), rgb: rgb_space(&settings.rgb)?, cmyk: cmyk_space(&settings.cmyk)? })
|
||||
}
|
||||
pub fn settings(&self) -> &ColorSettings {
|
||||
&self.settings
|
||||
}
|
||||
pub fn rgb_is_srgb(&self) -> bool {
|
||||
matches!(self.rgb, RgbSpace::Srgb)
|
||||
}
|
||||
|
||||
/// Working RGB → display sRGB.
|
||||
pub fn rgb_to_srgb(&self, rgb: [f32; 3]) -> [f32; 3] {
|
||||
match &self.rgb {
|
||||
RgbSpace::Srgb => rgb,
|
||||
RgbSpace::Icc(p) => p.to_srgb(&rgb, Intent::RelativeColorimetric).unwrap_or(rgb),
|
||||
}
|
||||
}
|
||||
/// Display sRGB → working RGB.
|
||||
pub fn srgb_to_rgb(&self, srgb: [f32; 3]) -> [f32; 3] {
|
||||
match &self.rgb {
|
||||
RgbSpace::Srgb => srgb,
|
||||
RgbSpace::Icc(p) => p.from_srgb(srgb, Intent::RelativeColorimetric).map(|v| [v[0], v[1], v[2]]).unwrap_or(srgb),
|
||||
}
|
||||
}
|
||||
|
||||
fn cmyk_to_srgb_in(space: &CmykSpace, cmyk: [f32; 4], absolute: bool) -> [f32; 3] {
|
||||
let cmyk = cmyk.map(|v| v.clamp(0.0, 1.0));
|
||||
match space {
|
||||
CmykSpace::Generic(g) => g.to_srgb(cmyk, absolute),
|
||||
CmykSpace::Device => naive_cmyk_to_rgb(cmyk),
|
||||
CmykSpace::Icc(p) => {
|
||||
let i = if absolute { Intent::AbsoluteColorimetric } else { Intent::RelativeColorimetric };
|
||||
p.to_srgb(&cmyk, i).unwrap_or_else(|| naive_cmyk_to_rgb(cmyk))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn srgb_to_cmyk_in(space: &CmykSpace, srgb: [f32; 3], intent: Intent, bpc: bool) -> [f32; 4] {
|
||||
match space {
|
||||
CmykSpace::Generic(g) => g.from_srgb(srgb, intent, bpc),
|
||||
CmykSpace::Device => naive_rgb_to_cmyk(srgb),
|
||||
CmykSpace::Icc(p) => p.from_srgb(srgb, intent).map(|v| [v[0], v[1], v[2], v[3]]).unwrap_or_else(|| naive_rgb_to_cmyk(srgb)),
|
||||
}
|
||||
}
|
||||
|
||||
/// CMYK in the working space → display sRGB (`absolute` simulates the paper colour).
|
||||
pub fn cmyk_to_srgb(&self, cmyk: [f32; 4], absolute: bool) -> [f32; 3] {
|
||||
Self::cmyk_to_srgb_in(&self.cmyk, cmyk, absolute)
|
||||
}
|
||||
/// Display sRGB → working CMYK with `intent`.
|
||||
pub fn srgb_to_cmyk(&self, srgb: [f32; 3], intent: Intent) -> [f32; 4] {
|
||||
Self::srgb_to_cmyk_in(&self.cmyk, srgb, intent, self.settings.bpc)
|
||||
}
|
||||
/// Media-relative Lab of a working-space CMYK colour.
|
||||
pub fn cmyk_to_lab(&self, cmyk: [f32; 4]) -> Lab {
|
||||
match &self.cmyk {
|
||||
CmykSpace::Generic(g) => g.to_lab_rel(cmyk.map(|v| v.clamp(0.0, 1.0))),
|
||||
_ => lab::srgb_to_lab(self.cmyk_to_srgb(cmyk, false)),
|
||||
}
|
||||
}
|
||||
/// Lab → working CMYK with `intent`.
|
||||
pub fn lab_to_cmyk(&self, l: Lab, intent: Intent) -> [f32; 4] {
|
||||
match &self.cmyk {
|
||||
CmykSpace::Generic(g) => g.from_lab(l, intent, self.settings.bpc),
|
||||
_ => self.srgb_to_cmyk(lab::lab_to_srgb(l), intent),
|
||||
}
|
||||
}
|
||||
|
||||
/// Display sRGB of any colour.
|
||||
pub fn display_rgb(&self, c: &Color) -> [f32; 3] {
|
||||
match *c {
|
||||
Color::Rgb { r, g, b } => self.rgb_to_srgb([r, g, b]),
|
||||
Color::Cmyk { c, m, y, k } => self.cmyk_to_srgb([c, m, y, k], false),
|
||||
Color::Gray { k } => [1.0 - k.clamp(0.0, 1.0); 3],
|
||||
}
|
||||
}
|
||||
|
||||
/// CIE Lab (D50) of any colour.
|
||||
pub fn lab(&self, c: &Color) -> Lab {
|
||||
match *c {
|
||||
Color::Cmyk { c, m, y, k } => self.cmyk_to_lab([c, m, y, k]),
|
||||
_ => lab::srgb_to_lab(self.display_rgb(c)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ink values of any colour in the working CMYK space (CMYK passes through; grey is K only).
|
||||
pub fn to_cmyk(&self, c: &Color, intent: Intent) -> [f32; 4] {
|
||||
match *c {
|
||||
Color::Cmyk { c, m, y, k } => [c, m, y, k],
|
||||
Color::Gray { k } => [0.0, 0.0, 0.0, k],
|
||||
Color::Rgb { .. } => self.srgb_to_cmyk(self.display_rgb(c), intent),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert `c` into `model` (a colour already in `model` is returned unchanged).
|
||||
pub fn convert(&self, c: &Color, model: Model, intent: Intent) -> Color {
|
||||
match (c, model) {
|
||||
(Color::Rgb { .. }, Model::Rgb) | (Color::Cmyk { .. }, Model::Cmyk) | (Color::Gray { .. }, Model::Gray) => *c,
|
||||
(_, Model::Cmyk) => {
|
||||
let [c, m, y, k] = self.to_cmyk(c, intent);
|
||||
Color::Cmyk { c, m, y, k }
|
||||
}
|
||||
(_, Model::Rgb) => {
|
||||
let [r, g, b] = self.srgb_to_rgb(self.display_rgb(c));
|
||||
Color::Rgb { r, g, b }
|
||||
}
|
||||
(_, Model::Gray) => {
|
||||
// Neutral with the same L* (grey ink percentage is 1 − lightness on screen).
|
||||
let l = self.lab(c).l;
|
||||
let v = lab::xyz_to_srgb(lab::lab_to_xyz(Lab::new(l, 0.0, 0.0)))[1];
|
||||
Color::Gray { k: (1.0 - v).clamp(0.0, 1.0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Colour from Lab (in the working RGB space).
|
||||
pub fn from_lab(&self, l: Lab) -> Color {
|
||||
let [r, g, b] = self.srgb_to_rgb(lab::lab_to_srgb(l));
|
||||
Color::Rgb { r, g, b }
|
||||
}
|
||||
|
||||
/// How far (ΔE2000) the best CMYK reproduction of `c` is from `c` (0 for CMYK/grey colours).
|
||||
pub fn gamut_error(&self, c: &Color) -> f32 {
|
||||
match c {
|
||||
Color::Rgb { .. } => {
|
||||
let srgb = self.display_rgb(c);
|
||||
let src = lab::srgb_to_lab(srgb);
|
||||
match &self.cmyk {
|
||||
CmykSpace::Generic(g) => g.gamut_error(src),
|
||||
CmykSpace::Device => 0.0,
|
||||
CmykSpace::Icc(_) => {
|
||||
let back = self.cmyk_to_srgb(self.srgb_to_cmyk(srgb, Intent::RelativeColorimetric), false);
|
||||
delta_e2000(src, lab::srgb_to_lab(back))
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Gamut warning: can this colour be printed in the working CMYK space?
|
||||
pub fn out_of_gamut(&self, c: &Color) -> bool {
|
||||
self.gamut_error(c) > GAMUT_THRESHOLD
|
||||
}
|
||||
|
||||
/// Soft-proof one display sRGB colour.
|
||||
pub fn proof_srgb(&self, srgb: [f32; 3], proof: &ProofSetup) -> [f32; 3] {
|
||||
let srgb = srgb.map(|v| v.clamp(0.0, 1.0));
|
||||
match &proof.target {
|
||||
ProofTarget::Srgb | ProofTarget::MonitorRgb => srgb,
|
||||
ProofTarget::LegacyMacRgb => srgb.map(|v| lab::linear_to_srgb(v.powf(1.8))),
|
||||
ProofTarget::Protanopia => simulate_cvd(srgb, &PROTANOPIA),
|
||||
ProofTarget::Deuteranopia => simulate_cvd(srgb, &DEUTERANOPIA),
|
||||
ProofTarget::WorkingCmyk => {
|
||||
let cmyk = self.srgb_to_cmyk(srgb, proof.intent);
|
||||
self.cmyk_to_srgb(cmyk, proof.simulate_paper)
|
||||
}
|
||||
ProofTarget::Cmyk(name) => match cmyk_space(name) {
|
||||
Ok(sp) => {
|
||||
let cmyk = Self::srgb_to_cmyk_in(&sp, srgb, proof.intent, self.settings.bpc);
|
||||
Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper)
|
||||
}
|
||||
Err(_) => srgb,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Display sRGB of CMYK inks in the proof's CMYK space (the working space for RGB targets).
|
||||
pub fn proof_cmyk_to_srgb(&self, cmyk: [f32; 4], proof: &ProofSetup) -> [f32; 3] {
|
||||
match &proof.target {
|
||||
ProofTarget::Cmyk(name) => match cmyk_space(name) {
|
||||
Ok(sp) => Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper),
|
||||
Err(_) => self.cmyk_to_srgb(cmyk, proof.simulate_paper),
|
||||
},
|
||||
_ => self.cmyk_to_srgb(cmyk, proof.simulate_paper),
|
||||
}
|
||||
}
|
||||
|
||||
/// A cached 3-D lookup table implementing [`Cms::proof_srgb`] for 8-bit pixels.
|
||||
pub fn proof_lut(&self, proof: &ProofSetup) -> Arc<ProofLut> {
|
||||
let key = format!("{:?}|{:?}|{:?}|{}", self.settings, proof.target, proof.intent, proof.simulate_paper);
|
||||
let mut cache = PROOF_CACHE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some((_, l)) = cache.iter().find(|(k, _)| *k == key) {
|
||||
return l.clone();
|
||||
}
|
||||
let lut = Arc::new(ProofLut::build(|rgb| self.proof_srgb(rgb, proof)));
|
||||
if cache.len() >= 8 {
|
||||
cache.remove(0);
|
||||
}
|
||||
cache.push((key, lut.clone()));
|
||||
lut
|
||||
}
|
||||
}
|
||||
|
||||
static PROOF_CACHE: Mutex<Vec<(String, Arc<ProofLut>)>> = Mutex::new(Vec::new());
|
||||
|
||||
fn clear_proof_cache() {
|
||||
PROOF_CACHE.lock().unwrap_or_else(|e| e.into_inner()).clear();
|
||||
}
|
||||
|
||||
pub(crate) fn naive_cmyk_to_rgb([c, m, y, k]: [f32; 4]) -> [f32; 3] {
|
||||
[(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)]
|
||||
}
|
||||
|
||||
pub(crate) fn naive_rgb_to_cmyk([r, g, b]: [f32; 3]) -> [f32; 4] {
|
||||
let k = 1.0 - r.max(g).max(b);
|
||||
if k >= 1.0 {
|
||||
return [0.0, 0.0, 0.0, 1.0];
|
||||
}
|
||||
[(1.0 - r - k) / (1.0 - k), (1.0 - g - k) / (1.0 - k), (1.0 - b - k) / (1.0 - k), k]
|
||||
}
|
||||
|
||||
// Machado, Oliveira & Fernandes (2009) dichromacy simulation matrices (severity 1.0, linear RGB).
|
||||
const PROTANOPIA: [[f32; 3]; 3] = [[0.152_286, 1.052_583, -0.204_868], [0.114_503, 0.786_281, 0.099_216], [-0.003_882, -0.048_116, 1.051_998]];
|
||||
const DEUTERANOPIA: [[f32; 3]; 3] = [[0.367_322, 0.860_646, -0.227_968], [0.280_085, 0.672_501, 0.047_413], [-0.011_820, 0.042_940, 0.968_881]];
|
||||
|
||||
fn simulate_cvd(srgb: [f32; 3], m: &[[f32; 3]; 3]) -> [f32; 3] {
|
||||
lab::mat_mul(m, lab::srgb_linear(srgb)).map(|v| lab::linear_to_srgb(v.clamp(0.0, 1.0)))
|
||||
}
|
||||
|
||||
/// View → Proof Setup target.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", content = "profile", rename_all = "camelCase")]
|
||||
pub enum ProofTarget {
|
||||
/// Working CMYK.
|
||||
#[default]
|
||||
WorkingCmyk,
|
||||
/// A specific CMYK profile (Customize…).
|
||||
Cmyk(String),
|
||||
/// Legacy Macintosh RGB (Gamma 1.8).
|
||||
LegacyMacRgb,
|
||||
/// Internet Standard RGB (sRGB).
|
||||
Srgb,
|
||||
/// Monitor RGB (no simulation).
|
||||
MonitorRgb,
|
||||
/// Colour blindness — protanopia type.
|
||||
Protanopia,
|
||||
/// Colour blindness — deuteranopia type.
|
||||
Deuteranopia,
|
||||
}
|
||||
|
||||
impl ProofTarget {
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
if let Some(p) = s.strip_prefix("cmyk:") {
|
||||
return Some(ProofTarget::Cmyk(p.to_string()));
|
||||
}
|
||||
Some(match s {
|
||||
"workingCmyk" | "cmyk" => ProofTarget::WorkingCmyk,
|
||||
"legacyMacRgb" | "macRgb" => ProofTarget::LegacyMacRgb,
|
||||
"srgb" | "sRGB" => ProofTarget::Srgb,
|
||||
"monitorRgb" | "monitor" => ProofTarget::MonitorRgb,
|
||||
"protanopia" => ProofTarget::Protanopia,
|
||||
"deuteranopia" => ProofTarget::Deuteranopia,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
pub fn id(&self) -> String {
|
||||
match self {
|
||||
ProofTarget::WorkingCmyk => "workingCmyk".into(),
|
||||
ProofTarget::Cmyk(p) => format!("cmyk:{p}"),
|
||||
ProofTarget::LegacyMacRgb => "legacyMacRgb".into(),
|
||||
ProofTarget::Srgb => "srgb".into(),
|
||||
ProofTarget::MonitorRgb => "monitorRgb".into(),
|
||||
ProofTarget::Protanopia => "protanopia".into(),
|
||||
ProofTarget::Deuteranopia => "deuteranopia".into(),
|
||||
}
|
||||
}
|
||||
/// Whether the target simulates a CMYK press (separations make sense).
|
||||
pub fn is_cmyk(&self) -> bool {
|
||||
matches!(self, ProofTarget::WorkingCmyk | ProofTarget::Cmyk(_))
|
||||
}
|
||||
}
|
||||
|
||||
/// View → Proof Setup / Proof Colors / Separations Preview state for rendering.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProofSetup {
|
||||
pub target: ProofTarget,
|
||||
pub intent: Intent,
|
||||
/// Simulate Paper Color (absolute colorimetric display of the press).
|
||||
#[serde(default)]
|
||||
pub simulate_paper: bool,
|
||||
/// Separations Preview: names of the visible plates ("Cyan", "Magenta", "Yellow", "Black",
|
||||
/// spot swatch names). `None` = normal composite proof.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub separations: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Process plate names in order.
|
||||
pub const PROCESS_PLATES: [&str; 4] = ["Cyan", "Magenta", "Yellow", "Black"];
|
||||
|
||||
const LUT_N: usize = 17;
|
||||
|
||||
/// A 17³ RGB → RGB lookup table with trilinear interpolation.
|
||||
pub struct ProofLut {
|
||||
data: Vec<[f32; 3]>,
|
||||
}
|
||||
|
||||
impl ProofLut {
|
||||
pub fn build(f: impl Fn([f32; 3]) -> [f32; 3]) -> Self {
|
||||
let n = LUT_N;
|
||||
let mut data = Vec::with_capacity(n * n * n);
|
||||
for r in 0..n {
|
||||
for g in 0..n {
|
||||
for b in 0..n {
|
||||
let s = (n - 1) as f32;
|
||||
data.push(f([r as f32 / s, g as f32 / s, b as f32 / s]));
|
||||
}
|
||||
}
|
||||
}
|
||||
Self { data }
|
||||
}
|
||||
pub fn apply(&self, rgb: [f32; 3]) -> [f32; 3] {
|
||||
let n = LUT_N;
|
||||
let s = (n - 1) as f32;
|
||||
let p = rgb.map(|v| v.clamp(0.0, 1.0) * s);
|
||||
let i = p.map(|v| (v.floor() as usize).min(n - 2));
|
||||
let t = [p[0] - i[0] as f32, p[1] - i[1] as f32, p[2] - i[2] as f32];
|
||||
let at = |r: usize, g: usize, b: usize| self.data[(r * n + g) * n + b];
|
||||
let mut out = [0.0f32; 3];
|
||||
for (dr, wr) in [(0, 1.0 - t[0]), (1, t[0])] {
|
||||
for (dg, wg) in [(0, 1.0 - t[1]), (1, t[1])] {
|
||||
for (db, wb) in [(0, 1.0 - t[2]), (1, t[2])] {
|
||||
let w = wr * wg * wb;
|
||||
if w == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let v = at(i[0] + dr, i[1] + dg, i[2] + db);
|
||||
for c in 0..3 {
|
||||
out[c] += w * v[c];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn apply8(&self, rgb: [u8; 3]) -> [u8; 3] {
|
||||
self.apply(rgb.map(|v| v as f32 / 255.0)).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- the active (process-wide) settings ----------
|
||||
|
||||
static ACTIVE: RwLock<Option<Arc<Cms>>> = RwLock::new(None);
|
||||
static RGB_IS_SRGB: AtomicBool = AtomicBool::new(true);
|
||||
static CMYK_IS_DEVICE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// The active colour-management context (Edit → Color Settings).
|
||||
pub fn active() -> Arc<Cms> {
|
||||
if let Some(c) = ACTIVE.read().unwrap_or_else(|e| e.into_inner()).as_ref() {
|
||||
return c.clone();
|
||||
}
|
||||
let mut w = ACTIVE.write().unwrap_or_else(|e| e.into_inner());
|
||||
w.get_or_insert_with(|| Arc::new(Cms::default())).clone()
|
||||
}
|
||||
|
||||
pub fn active_settings() -> ColorSettings {
|
||||
active().settings.clone()
|
||||
}
|
||||
|
||||
/// Replace the active settings (fails, leaving them unchanged, if a profile is unknown).
|
||||
pub fn set_active(settings: &ColorSettings) -> Result<(), CmsError> {
|
||||
let cms = Cms::new(settings)?;
|
||||
RGB_IS_SRGB.store(cms.rgb_is_srgb(), Ordering::Relaxed);
|
||||
CMYK_IS_DEVICE.store(matches!(cms.cmyk, CmykSpace::Device), Ordering::Relaxed);
|
||||
*ACTIVE.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(cms));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fast path for [`Color::to_rgb`]: RGB colours need no conversion while the working space is sRGB.
|
||||
pub(crate) fn rgb_is_srgb() -> bool {
|
||||
RGB_IS_SRGB.load(Ordering::Relaxed)
|
||||
}
|
||||
pub(crate) fn cmyk_is_device() -> bool {
|
||||
CMYK_IS_DEVICE.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
use super::*;
|
||||
|
||||
fn cms() -> Cms {
|
||||
Cms::new(&ColorSettings::default()).unwrap()
|
||||
}
|
||||
|
||||
/// Without black-point compensation, relative colorimetric reproduces in-gamut colours exactly.
|
||||
fn cms_no_bpc() -> Cms {
|
||||
Cms::new(&ColorSettings { bpc: false, ..Default::default() }).unwrap()
|
||||
}
|
||||
|
||||
fn de(a: [f32; 3], b: [f32; 3]) -> f32 {
|
||||
delta_e2000(lab::srgb_to_lab(a), lab::srgb_to_lab(b))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intents_parse_and_name() {
|
||||
for i in Intent::ALL {
|
||||
assert_eq!(Intent::parse(i.id()), Some(i));
|
||||
assert_eq!(Intent::parse(i.label()), Some(i));
|
||||
}
|
||||
assert_eq!(Intent::parse("bogus"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn srgb_via_moxcms_matches_analytic() {
|
||||
// The built-in sRGB ICC profile through moxcms agrees with our analytic sRGB maths.
|
||||
let p = icc::builtin_rgb(SRGB).unwrap();
|
||||
for rgb in [[0.2, 0.5, 0.8], [1.0, 0.0, 0.0], [0.5, 0.5, 0.5]] {
|
||||
let out = p.to_srgb(&rgb, Intent::RelativeColorimetric).unwrap();
|
||||
assert!(de(out, rgb) < 0.5, "{rgb:?} → {out:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paper_white_and_rich_black() {
|
||||
let c = cms();
|
||||
assert_eq!(c.srgb_to_cmyk([1.0, 1.0, 1.0], Intent::RelativeColorimetric), [0.0; 4]);
|
||||
let k = c.srgb_to_cmyk([0.0, 0.0, 0.0], Intent::RelativeColorimetric);
|
||||
assert!(k[3] > 0.8, "black generation puts most of the black in K: {k:?}");
|
||||
assert!(k.iter().sum::<f32>() <= 3.0 + 1e-3, "TAC limit: {k:?}");
|
||||
assert!(k[0] > 0.3 && k[1] > 0.3 && k[2] > 0.3, "rich black: {k:?}");
|
||||
assert_eq!(c.cmyk_to_srgb([0.0; 4], false), [1.0, 1.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_cmyk_rgb_roundtrip_in_gamut() {
|
||||
let c = cms_no_bpc();
|
||||
for rgb in [[0.8, 0.6, 0.5], [0.5, 0.5, 0.5], [0.3, 0.45, 0.35], [0.9, 0.85, 0.3], [0.55, 0.4, 0.6], [0.35, 0.55, 0.7], [0.75, 0.75, 0.75]] {
|
||||
let cmyk = c.srgb_to_cmyk(rgb, Intent::RelativeColorimetric);
|
||||
let back = c.cmyk_to_srgb(cmyk, false);
|
||||
assert!(de(rgb, back) < 1.5, "{rgb:?} → {cmyk:?} → {back:?} ΔE {}", de(rgb, back));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmyk_lab_cmyk_roundtrip() {
|
||||
let c = cms_no_bpc();
|
||||
for cmyk in [[0.2, 0.4, 0.6, 0.1], [0.0, 0.0, 0.0, 0.5], [0.5, 0.1, 0.0, 0.0], [0.1, 0.7, 0.3, 0.2]] {
|
||||
let l = c.cmyk_to_lab(cmyk);
|
||||
// Lab here is media-relative, i.e. what relative colorimetric reproduces exactly.
|
||||
let back = c.lab_to_cmyk(l, Intent::RelativeColorimetric);
|
||||
let l2 = c.cmyk_to_lab(back);
|
||||
assert!(delta_e2000(l, l2) < 1.0, "{cmyk:?} → {l:?} → {back:?} → {l2:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gcr_puts_grey_into_black() {
|
||||
let k = cms().srgb_to_cmyk([0.4, 0.4, 0.4], Intent::RelativeColorimetric);
|
||||
assert!(k[3] > 0.2, "{k:?}");
|
||||
let spread = k[0].max(k[1]).max(k[2]) - k[0].min(k[1]).min(k[2]);
|
||||
assert!(spread < 0.2, "neutral stays balanced: {k:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamut_warning() {
|
||||
let c = cms();
|
||||
assert!(c.out_of_gamut(&Color::rgb(0.0, 0.0, 1.0)), "sRGB blue is outside SWOP-like CMYK");
|
||||
assert!(c.out_of_gamut(&Color::rgb(0.0, 1.0, 0.0)), "sRGB green is outside");
|
||||
assert!(!c.out_of_gamut(&Color::rgb(0.8, 0.6, 0.5)), "skin tone is printable");
|
||||
assert!(!c.out_of_gamut(&Color::rgb(0.5, 0.5, 0.5)));
|
||||
assert!(!c.out_of_gamut(&Color::cmyk(1.0, 0.0, 0.0, 0.0)), "CMYK colours are in gamut by definition");
|
||||
// The uncalibrated device space has no gamut limits.
|
||||
let dev = Cms::new(&ColorSettings { cmyk: DEVICE_CMYK.into(), ..Default::default() }).unwrap();
|
||||
assert!(!dev.out_of_gamut(&Color::rgb(0.0, 0.0, 1.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intents_differ() {
|
||||
let c = cms();
|
||||
let blue = [0.0, 0.0, 1.0];
|
||||
let res: Vec<[f32; 4]> = Intent::ALL.iter().map(|i| c.srgb_to_cmyk(blue, *i)).collect();
|
||||
let dist = |a: [f32; 4], b: [f32; 4]| (0..4).map(|i| (a[i] - b[i]).abs()).sum::<f32>();
|
||||
assert!(dist(res[0], res[1]) > 0.02, "perceptual vs relative: {res:?}");
|
||||
assert!(dist(res[2], res[1]) > 0.02, "saturation vs relative: {res:?}");
|
||||
// Absolute colorimetry keeps the source white brighter than paper → less ink on light greys.
|
||||
let g = [0.88, 0.88, 0.88];
|
||||
let rel = c.srgb_to_cmyk(g, Intent::RelativeColorimetric);
|
||||
let abs = c.srgb_to_cmyk(g, Intent::AbsoluteColorimetric);
|
||||
assert!(rel.iter().sum::<f32>() > abs.iter().sum::<f32>() + 0.02, "{rel:?} vs {abs:?}");
|
||||
// Perceptual compresses in-gamut saturated colours too (relative leaves them alone).
|
||||
let red = lab::srgb_to_lab([0.8, 0.25, 0.25]);
|
||||
let pr = c.cmyk_to_lab(c.srgb_to_cmyk([0.8, 0.25, 0.25], Intent::Perceptual));
|
||||
let rr = c.cmyk_to_lab(c.srgb_to_cmyk([0.8, 0.25, 0.25], Intent::RelativeColorimetric));
|
||||
assert!(delta_e2000(rr, red) < delta_e2000(pr, red), "relative is closer for in-gamut colours");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simulate_paper_tints_white() {
|
||||
let c = cms();
|
||||
let rel = c.cmyk_to_srgb([0.0; 4], false);
|
||||
let abs = c.cmyk_to_srgb([0.0; 4], true);
|
||||
assert_eq!(rel, [1.0; 3]);
|
||||
assert!(abs[0] < 0.98 && abs[2] < abs[0], "paper is darker and slightly warm: {abs:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_cmyk_keeps_legacy_numbers() {
|
||||
let dev = Cms::new(&ColorSettings { cmyk: DEVICE_CMYK.into(), ..Default::default() }).unwrap();
|
||||
assert_eq!(dev.cmyk_to_srgb([1.0, 0.0, 0.0, 0.0], false), [0.0, 1.0, 1.0]);
|
||||
assert_eq!(dev.srgb_to_cmyk([1.0, 0.0, 0.0], Intent::Perceptual), [0.0, 1.0, 1.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_between_models() {
|
||||
let c = cms();
|
||||
let red = Color::rgb(0.9, 0.2, 0.2);
|
||||
let cmyk = c.convert(&red, Model::Cmyk, Intent::RelativeColorimetric);
|
||||
assert!(matches!(cmyk, Color::Cmyk { .. }));
|
||||
assert_eq!(c.convert(&cmyk, Model::Cmyk, Intent::Perceptual), cmyk, "idempotent");
|
||||
let back = c.convert(&cmyk, Model::Rgb, Intent::RelativeColorimetric);
|
||||
assert!(de(back.to_rgb_uncalibrated(), [0.9, 0.2, 0.2]) < 6.0, "{back:?}");
|
||||
let g = c.convert(&Color::rgb(0.5, 0.5, 0.5), Model::Gray, Intent::RelativeColorimetric);
|
||||
let Color::Gray { k } = g else { panic!() };
|
||||
assert!((k - 0.5).abs() < 0.01, "{k}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_gamut_working_space() {
|
||||
let c = Cms::new(&ColorSettings { rgb: ADOBE_RGB.into(), ..Default::default() }).unwrap();
|
||||
assert!(!c.rgb_is_srgb());
|
||||
let grey = c.rgb_to_srgb([0.5, 0.5, 0.5]);
|
||||
assert!(de(grey, [0.5, 0.5, 0.5]) < 1.5, "{grey:?}");
|
||||
// Adobe RGB green is outside sRGB: it clips to (nearly) full sRGB green.
|
||||
let g = c.rgb_to_srgb([0.0, 1.0, 0.0]);
|
||||
assert!(g[1] > 0.95 && g[0] < 0.1, "{g:?}");
|
||||
let back = c.srgb_to_rgb(c.rgb_to_srgb([0.4, 0.5, 0.3]));
|
||||
assert!(de(back, [0.4, 0.5, 0.3]) < 1.0);
|
||||
assert!(matches!(Cms::new(&ColorSettings { rgb: "Nope".into(), ..Default::default() }), Err(CmsError::UnknownProfile(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proof_lut_matches_direct_proof() {
|
||||
let c = cms();
|
||||
let p = ProofSetup::default();
|
||||
let lut = c.proof_lut(&p);
|
||||
for rgb in [[0.8, 0.6, 0.5], [0.0, 0.0, 1.0], [0.3, 0.9, 0.2]] {
|
||||
let direct = c.proof_srgb(rgb, &p);
|
||||
let via_lut = lut.apply(rgb);
|
||||
assert!(de(direct, via_lut) < 2.5, "{rgb:?}: {direct:?} vs {via_lut:?}");
|
||||
}
|
||||
// Out-of-gamut blue gets visibly duller in the proof.
|
||||
let b = c.proof_srgb([0.0, 0.0, 1.0], &p);
|
||||
assert!(de(b, [0.0, 0.0, 1.0]) > 5.0, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_blindness_proofs() {
|
||||
let c = cms();
|
||||
let p = ProofSetup { target: ProofTarget::Protanopia, ..Default::default() };
|
||||
let red = c.proof_srgb([0.9, 0.1, 0.1], &p);
|
||||
let green = c.proof_srgb([0.1, 0.6, 0.1], &p);
|
||||
assert!(de(red, green) < de([0.9, 0.1, 0.1], [0.1, 0.6, 0.1]) * 0.5, "red/green confusion");
|
||||
let d = ProofSetup { target: ProofTarget::Deuteranopia, ..Default::default() };
|
||||
assert_ne!(c.proof_srgb([0.9, 0.1, 0.1], &d), [0.9, 0.1, 0.1]);
|
||||
let m = ProofSetup { target: ProofTarget::MonitorRgb, ..Default::default() };
|
||||
assert_eq!(c.proof_srgb([0.2, 0.3, 0.4], &m), [0.2, 0.3, 0.4]);
|
||||
assert_eq!(ProofTarget::parse(&ProofTarget::Cmyk("X".into()).id()), Some(ProofTarget::Cmyk("X".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_icc_rejected() {
|
||||
assert!(matches!(register_icc(b"not a profile", None), Err(CmsError::BadProfile(_))));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn loads_system_cmyk_icc() {
|
||||
let path = std::path::Path::new("/System/Library/ColorSync/Profiles/Generic CMYK Profile.icc");
|
||||
if !path.exists() {
|
||||
return;
|
||||
}
|
||||
let info = load_icc_file(path).unwrap();
|
||||
assert_eq!(info.kind, ProfileKind::Cmyk);
|
||||
assert!(profiles().iter().any(|p| p.name == info.name && !p.builtin));
|
||||
let c = Cms::new(&ColorSettings { cmyk: info.name.clone(), ..Default::default() }).unwrap();
|
||||
let rgb = [0.7, 0.5, 0.4];
|
||||
let back = c.cmyk_to_srgb(c.srgb_to_cmyk(rgb, Intent::RelativeColorimetric), false);
|
||||
assert!(de(rgb, back) < 5.0, "{back:?}");
|
||||
assert_eq!(c.cmyk_to_srgb([0.0; 4], false).map(|v| (v * 255.0).round() as u8), [255, 255, 255]);
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod blend;
|
||||
pub mod cms;
|
||||
pub mod gradient;
|
||||
pub mod harmony;
|
||||
pub mod swatch;
|
||||
|
|
@ -47,20 +48,46 @@ impl Color {
|
|||
Color::Gray { k }
|
||||
}
|
||||
|
||||
/// Display RGB (naive, profile-free conversion; colour management comes later).
|
||||
/// Display (sRGB) colour through the active colour settings ([`cms::active`]): RGB is
|
||||
/// converted from the working RGB space, CMYK through the working CMYK profile (relative
|
||||
/// colorimetric). Grey is ink percentage (0 = white).
|
||||
pub fn to_rgb(&self) -> [f32; 3] {
|
||||
match *self {
|
||||
Color::Rgb { r, g, b } => [r, g, b],
|
||||
Color::Cmyk { c, m, y, k } => [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)],
|
||||
Color::Rgb { r, g, b } if cms::rgb_is_srgb() => [r, g, b],
|
||||
Color::Cmyk { c, m, y, k } if cms::cmyk_is_device() => cms::naive_cmyk_to_rgb([c, m, y, k]),
|
||||
// Illustrator's Gray is ink percentage: 0 = white, 1 = black.
|
||||
Color::Gray { k } => [1.0 - k; 3],
|
||||
_ => cms::active().display_rgb(self),
|
||||
}
|
||||
}
|
||||
/// Profile-free display RGB (`(1−c)(1−k)` …), the pre-colour-management formula.
|
||||
pub fn to_rgb_uncalibrated(&self) -> [f32; 3] {
|
||||
match *self {
|
||||
Color::Rgb { r, g, b } => [r, g, b],
|
||||
Color::Cmyk { c, m, y, k } => cms::naive_cmyk_to_rgb([c, m, y, k]),
|
||||
Color::Gray { k } => [1.0 - k; 3],
|
||||
}
|
||||
}
|
||||
/// CIE Lab (D50) through the active colour settings.
|
||||
pub fn to_lab(&self) -> cms::Lab {
|
||||
cms::active().lab(self)
|
||||
}
|
||||
/// Colour-managed CMYK in the working CMYK space with `intent` (see [`Color::to_cmyk`] for the
|
||||
/// profile-free formula).
|
||||
pub fn to_cmyk_managed(&self, intent: cms::Intent) -> [f32; 4] {
|
||||
cms::active().to_cmyk(self, intent)
|
||||
}
|
||||
/// Gamut warning: true when this colour can't be reproduced in the working CMYK space.
|
||||
pub fn out_of_gamut(&self) -> bool {
|
||||
cms::active().out_of_gamut(self)
|
||||
}
|
||||
pub fn to_rgba8(&self, alpha: f32) -> [u8; 4] {
|
||||
let [r, g, b] = self.to_rgb();
|
||||
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
[q(r), q(g), q(b), q(alpha)]
|
||||
}
|
||||
/// Profile-free CMYK (`k = 1 − max(r, g, b)` …), kept for exact legacy numbers; colour-managed
|
||||
/// separations use [`Color::to_cmyk_managed`].
|
||||
pub fn to_cmyk(&self) -> [f32; 4] {
|
||||
match *self {
|
||||
Color::Cmyk { c, m, y, k } => [c, m, y, k],
|
||||
|
|
@ -170,9 +197,17 @@ pub enum Paint {
|
|||
Gradient(Box<GradientPaint>),
|
||||
Pattern {
|
||||
pattern: String,
|
||||
/// Pattern space → document placement (Transform Patterns edits it; identity = tiles
|
||||
/// anchored at the document origin).
|
||||
#[serde(default, skip_serializing_if = "is_identity")]
|
||||
xf: kurbo::Affine,
|
||||
},
|
||||
}
|
||||
|
||||
fn is_identity(a: &kurbo::Affine) -> bool {
|
||||
*a == kurbo::Affine::IDENTITY
|
||||
}
|
||||
|
||||
impl Paint {
|
||||
pub fn solid(c: Color) -> Self {
|
||||
Paint::Solid { color: c, swatch: None }
|
||||
|
|
@ -192,7 +227,7 @@ impl Paint {
|
|||
Paint::Solid { color, swatch: Some(n) } => format!("{n} ({})", color.to_hex()),
|
||||
Paint::Solid { color, .. } => color.to_hex(),
|
||||
Paint::Gradient(g) => format!("{} gradient", g.gradient.kind.label()),
|
||||
Paint::Pattern { pattern } => format!("pattern {pattern}"),
|
||||
Paint::Pattern { pattern, .. } => format!("pattern {pattern}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -220,8 +255,12 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn cmyk_conversions() {
|
||||
assert_eq!(Color::cmyk(0.0, 0.0, 0.0, 1.0).to_hex(), "#000000");
|
||||
assert_eq!(Color::cmyk(1.0, 0.0, 0.0, 0.0).to_hex(), "#00ffff");
|
||||
assert_eq!(Color::cmyk(0.0, 0.0, 0.0, 1.0).to_rgb_uncalibrated(), [0.0, 0.0, 0.0]);
|
||||
assert_eq!(Color::cmyk(1.0, 0.0, 0.0, 0.0).to_rgb_uncalibrated(), [0.0, 1.0, 1.0]);
|
||||
// Managed display: paper is white, 100% cyan is a press cyan (not #00ffff).
|
||||
assert_eq!(Color::cmyk(0.0, 0.0, 0.0, 0.0).to_hex(), "#ffffff");
|
||||
let [r, g, b] = Color::cmyk(1.0, 0.0, 0.0, 0.0).to_rgb();
|
||||
assert!(r < 0.2 && g > 0.5 && b > 0.8, "{r} {g} {b}");
|
||||
let k = Color::rgb(1.0, 0.0, 0.0).to_cmyk();
|
||||
assert_eq!(k, [0.0, 1.0, 1.0, 0.0]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,10 @@ pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
|
|||
if let NodeKind::Layer { template: true, .. } = layer.kind {
|
||||
continue;
|
||||
}
|
||||
// Pattern editing mode: only the tile art is editable.
|
||||
if doc.pattern_edit.as_ref().is_some_and(|e| e.layer != layer.id) {
|
||||
continue;
|
||||
}
|
||||
chain.push(layer.id);
|
||||
if let Some(h) = hit_children(layer, p, opt, &mut chain) {
|
||||
return Some(h);
|
||||
|
|
@ -133,6 +137,7 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
|
|||
NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::SymbolInstance { .. } | NodeKind::Blend { .. } | NodeKind::Envelope { .. } => {
|
||||
n.geometric_bounds().filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds)
|
||||
}
|
||||
NodeKind::Repeat(r) => r.expand().iter().find_map(|g| hit_any(g, p, opt)),
|
||||
NodeKind::Mesh(m) => {
|
||||
let bp = m.outline().to_bezpath();
|
||||
if stroke_contains(&bp, 0.0, opt.tol, p) {
|
||||
|
|
@ -144,12 +149,24 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Hit anywhere in an evaluated subtree (groups recurse; leaves use [`hit_leaf`]).
|
||||
fn hit_any(n: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
|
||||
match &n.kind {
|
||||
NodeKind::Group { children, .. } => children.iter().rev().find_map(|c| hit_any(c, p, opt)),
|
||||
_ => hit_leaf(n, p, opt),
|
||||
}
|
||||
}
|
||||
|
||||
/// Objects (children of layers, or of `scope` in isolation mode) touched by a marquee rect.
|
||||
pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) -> Vec<NodeId> {
|
||||
let mut out = Vec::new();
|
||||
let tops: Vec<&std::sync::Arc<Node>> = match scope.and_then(|s| doc.node(s)) {
|
||||
Some(s) => s.children().map(|c| c.iter().collect()).unwrap_or_default(),
|
||||
None => doc.layers.iter().filter(|l| l.visible && !l.locked).flat_map(|l| l.children().into_iter().flatten()).collect(),
|
||||
None => doc
|
||||
.layers
|
||||
.iter()
|
||||
.filter(|l| l.visible && !l.locked && doc.pattern_edit.as_ref().is_none_or(|e| e.layer == l.id))
|
||||
.flat_map(|l| l.children().into_iter().flatten()).collect(),
|
||||
};
|
||||
fn touches(n: &Node, r: Rect) -> bool {
|
||||
match &n.kind {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ pub mod appearance;
|
|||
pub mod hit;
|
||||
pub mod live;
|
||||
pub mod node;
|
||||
pub mod pattern;
|
||||
pub mod selection;
|
||||
pub mod text;
|
||||
|
||||
|
|
@ -20,6 +21,7 @@ pub use drawcraft_color as color;
|
|||
pub use drawcraft_geom as geom;
|
||||
pub use hit::{Hit, HitKind};
|
||||
pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
|
||||
pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType};
|
||||
pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind};
|
||||
pub use selection::{AnchorRef, Selection};
|
||||
pub use text::{CharStyle, Justify, ParaStyle, TextKind, TextObject, TextRun};
|
||||
|
|
@ -263,6 +265,12 @@ pub struct Document {
|
|||
pub raster_effects_ppi: f64,
|
||||
#[serde(default)]
|
||||
pub images: BTreeMap<String, ImageBlob>,
|
||||
/// Pattern swatch definitions (Object → Pattern).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub patterns: Vec<PatternDef>,
|
||||
/// Pattern editing mode, while active.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pattern_edit: Option<PatternEdit>,
|
||||
next_id: u64,
|
||||
/// Foreign data preserved on round-trip.
|
||||
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
|
||||
|
|
@ -300,6 +308,8 @@ impl Document {
|
|||
grid: GridPrefs::default(),
|
||||
raster_effects_ppi: 72.0,
|
||||
images: BTreeMap::new(),
|
||||
patterns: vec![],
|
||||
pattern_edit: None,
|
||||
next_id: 1,
|
||||
unknown: Default::default(),
|
||||
};
|
||||
|
|
@ -519,6 +529,12 @@ impl Document {
|
|||
pub fn next_artboard_id(&self) -> u32 {
|
||||
self.artboards.iter().map(|a| a.id).max().unwrap_or(0) + 1
|
||||
}
|
||||
pub fn pattern(&self, name: &str) -> Option<&PatternDef> {
|
||||
self.patterns.iter().find(|p| p.name == name)
|
||||
}
|
||||
pub fn pattern_mut(&mut self, name: &str) -> Option<&mut PatternDef> {
|
||||
self.patterns.iter_mut().find(|p| p.name == name)
|
||||
}
|
||||
pub fn swatch(&self, name: &str) -> Option<&Swatch> {
|
||||
self.swatches.iter().chain(self.swatch_groups.iter().flat_map(|g| g.swatches.iter())).find(|s| s.name == name)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1320,7 +1320,7 @@ fn map_node(n: &Node, f: &dyn Fn(Point) -> Point, piece: f64, outline: Outliner)
|
|||
}
|
||||
}
|
||||
NodeKind::Mesh(m) => m.map(f),
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } => {
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) => {
|
||||
let g = expanded_group(n, outline);
|
||||
return map_node(&g, f, piece, outline);
|
||||
}
|
||||
|
|
@ -1373,7 +1373,7 @@ pub fn envelope_bounds(content: &[Arc<Node>], kind: &EnvelopeKind) -> Option<Rec
|
|||
|
||||
/// Is this one of the live kinds?
|
||||
pub fn is_live(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_))
|
||||
matches!(n.kind, NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_))
|
||||
}
|
||||
|
||||
/// Mesh tessellation as filled quad paths (no stroke), `n`×`n` per patch.
|
||||
|
|
@ -1401,6 +1401,7 @@ pub fn expand_live_with(n: &Node, outline: Outliner) -> Vec<Node> {
|
|||
NodeKind::Blend { children, spec } => blend_expand(children, spec),
|
||||
NodeKind::Envelope { content, kind, fidelity, .. } => envelope_expand(content, kind, *fidelity, outline),
|
||||
NodeKind::Mesh(m) => mesh_quad_nodes(m, 8),
|
||||
NodeKind::Repeat(r) => r.expand(),
|
||||
_ => vec![n.clone()],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize};
|
|||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::live::{BlendSpec, EnvelopeKind, GradientMesh};
|
||||
use crate::pattern::RepeatSpec;
|
||||
use crate::text::TextObject;
|
||||
|
||||
/// Stable per-document object id. Never reused.
|
||||
|
|
@ -225,6 +226,8 @@ pub enum NodeKind {
|
|||
},
|
||||
/// Gradient mesh.
|
||||
Mesh(GradientMesh),
|
||||
/// Live Repeat (radial / grid / mirror) of source art.
|
||||
Repeat(RepeatSpec),
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
|
|
@ -291,7 +294,8 @@ impl Node {
|
|||
| NodeKind::Group { children, .. }
|
||||
| NodeKind::Compound { children, .. }
|
||||
| NodeKind::Blend { children, .. }
|
||||
| NodeKind::Envelope { content: children, .. } => Some(children),
|
||||
| NodeKind::Envelope { content: children, .. }
|
||||
| NodeKind::Repeat(RepeatSpec { source: children, .. }) => Some(children),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
@ -301,7 +305,8 @@ impl Node {
|
|||
| NodeKind::Group { children, .. }
|
||||
| NodeKind::Compound { children, .. }
|
||||
| NodeKind::Blend { children, .. }
|
||||
| NodeKind::Envelope { content: children, .. } => Some(children),
|
||||
| NodeKind::Envelope { content: children, .. }
|
||||
| NodeKind::Repeat(RepeatSpec { source: children, .. }) => Some(children),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
@ -327,6 +332,7 @@ impl Node {
|
|||
NodeKind::Blend { .. } => "Blend",
|
||||
NodeKind::Envelope { .. } => "Envelope",
|
||||
NodeKind::Mesh(_) => "Mesh",
|
||||
NodeKind::Repeat(r) => r.kind.label(),
|
||||
}
|
||||
}
|
||||
/// Name shown in the Layers panel: explicit name or `<Kind>`.
|
||||
|
|
@ -373,6 +379,7 @@ impl Node {
|
|||
}
|
||||
NodeKind::Envelope { content, kind, .. } => crate::live::envelope_bounds(content, kind),
|
||||
NodeKind::Mesh(m) => m.bounds(),
|
||||
NodeKind::Repeat(r) => r.bounds(),
|
||||
}
|
||||
}
|
||||
/// Visual bounds (includes stroke outset).
|
||||
|
|
@ -387,7 +394,7 @@ impl Node {
|
|||
let o = crate::live::max_outset(children);
|
||||
drawcraft_geom::union_opt(b, spec.spine.as_ref().and_then(|s| s.bounds()).map(|r| r.inflate(o, o)))
|
||||
}
|
||||
NodeKind::Envelope { content, .. } => {
|
||||
NodeKind::Envelope { content, .. } | NodeKind::Repeat(RepeatSpec { source: content, .. }) => {
|
||||
let o = crate::live::max_outset(content);
|
||||
self.geometric_bounds().map(|b| b.inflate(o, o))
|
||||
}
|
||||
|
|
@ -445,6 +452,7 @@ impl Node {
|
|||
}
|
||||
}
|
||||
NodeKind::Mesh(m) => m.transform(a),
|
||||
NodeKind::Repeat(r) => r.transform(a, scale_strokes),
|
||||
}
|
||||
}
|
||||
/// Visit this node and all descendants depth first (paint order).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,740 @@
|
|||
//! Pattern swatches (Object → Pattern) and live Repeats (Object → Repeat): data and pure math.
|
||||
//!
|
||||
//! **Patterns.** A [`PatternDef`] is a named tile: art plus a tile rectangle and a tile type
|
||||
//! (grid, brick by row/column, hex by column/row). Pattern space has the tile's top-left at the
|
||||
//! origin; instance `(i, j)` is the art translated by `-tile.origin + tile_offset(i, j)`. A
|
||||
//! [`Paint::Pattern`] maps pattern space into the document with its own `xf` (identity = tiles
|
||||
//! anchored at the document origin, like Illustrator's ruler origin). Every tiling is periodic
|
||||
//! on a rectangular *super-tile* ([`PatternDef::period`]), which is what renderers rasterize and
|
||||
//! what SVG `<pattern>` elements repeat.
|
||||
//!
|
||||
//! **Repeats.** A [`RepeatSpec`] holds source art and a radial / grid / mirror arrangement; the
|
||||
//! instances are the source under [`repeat_transforms`] (evaluated on demand like blends).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use drawcraft_color::Paint;
|
||||
use drawcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::appearance::AppearanceItem;
|
||||
use crate::node::{Node, NodeId, NodeKind};
|
||||
|
||||
fn half() -> f64 {
|
||||
0.5
|
||||
}
|
||||
fn five() -> u32 {
|
||||
5
|
||||
}
|
||||
fn seventy() -> f32 {
|
||||
70.0
|
||||
}
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
fn three() -> u32 {
|
||||
3
|
||||
}
|
||||
fn full_turn() -> f64 {
|
||||
360.0
|
||||
}
|
||||
|
||||
// =====================================================================================
|
||||
// Patterns
|
||||
// =====================================================================================
|
||||
|
||||
/// Pattern Options → Tile Type.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum TileType {
|
||||
#[default]
|
||||
Grid,
|
||||
/// Rows shifted horizontally by `offset` × tile width (0.5 = half brick).
|
||||
BrickByRow {
|
||||
#[serde(default = "half")]
|
||||
offset: f64,
|
||||
},
|
||||
/// Columns shifted vertically by `offset` × tile height.
|
||||
BrickByColumn {
|
||||
#[serde(default = "half")]
|
||||
offset: f64,
|
||||
},
|
||||
/// Hexagonal tiles in columns: columns ¾ tile width apart, odd columns shifted half a tile down.
|
||||
HexByColumn,
|
||||
/// Hexagonal tiles in rows: rows ¾ tile height apart, odd rows shifted half a tile right.
|
||||
HexByRow,
|
||||
}
|
||||
|
||||
impl TileType {
|
||||
pub fn id(&self) -> &'static str {
|
||||
match self {
|
||||
TileType::Grid => "grid",
|
||||
TileType::BrickByRow { .. } => "brickByRow",
|
||||
TileType::BrickByColumn { .. } => "brickByColumn",
|
||||
TileType::HexByColumn => "hexByColumn",
|
||||
TileType::HexByRow => "hexByRow",
|
||||
}
|
||||
}
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
TileType::Grid => "Grid",
|
||||
TileType::BrickByRow { .. } => "Brick by Row",
|
||||
TileType::BrickByColumn { .. } => "Brick by Column",
|
||||
TileType::HexByColumn => "Hex by Column",
|
||||
TileType::HexByRow => "Hex by Row",
|
||||
}
|
||||
}
|
||||
/// Parse an id (`grid`, `brickByRow`, `brick-row`, …) with an optional brick offset.
|
||||
pub fn parse(s: &str, offset: Option<f64>) -> Option<Self> {
|
||||
let o = offset.unwrap_or(0.5);
|
||||
Some(match s.to_ascii_lowercase().replace(['-', '_', ' '], "").as_str() {
|
||||
"grid" => TileType::Grid,
|
||||
"brickbyrow" | "brickrow" | "brick" => TileType::BrickByRow { offset: o },
|
||||
"brickbycolumn" | "brickcolumn" | "brickcol" => TileType::BrickByColumn { offset: o },
|
||||
"hexbycolumn" | "hexcolumn" | "hexcol" | "hex" => TileType::HexByColumn,
|
||||
"hexbyrow" | "hexrow" => TileType::HexByRow,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
pub const IDS: [&'static str; 5] = ["grid", "brickByRow", "brickByColumn", "hexByColumn", "hexByRow"];
|
||||
|
||||
/// Column / row steps for a `w`×`h` tile (the hex types pack columns/rows at ¾).
|
||||
pub fn steps(&self, w: f64, h: f64) -> (f64, f64) {
|
||||
match self {
|
||||
TileType::HexByColumn => (w * 0.75, h),
|
||||
TileType::HexByRow => (w, h * 0.75),
|
||||
_ => (w, h),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Position of instance (column `i`, row `j`) for a `w`×`h` tile. `(0, 0)` is at the origin.
|
||||
pub fn tile_offset(tt: TileType, w: f64, h: f64, i: i64, j: i64) -> Vec2 {
|
||||
let (i_f, j_f) = (i as f64, j as f64);
|
||||
let frac = |v: f64| v - v.floor();
|
||||
match tt {
|
||||
TileType::Grid => Vec2::new(i_f * w, j_f * h),
|
||||
TileType::BrickByRow { offset } => Vec2::new(i_f * w + frac(j_f * offset) * w, j_f * h),
|
||||
TileType::BrickByColumn { offset } => Vec2::new(i_f * w, j_f * h + frac(i_f * offset) * h),
|
||||
TileType::HexByColumn => Vec2::new(i_f * w * 0.75, j_f * h + if i.rem_euclid(2) == 1 { h / 2.0 } else { 0.0 }),
|
||||
TileType::HexByRow => Vec2::new(i_f * w + if j.rem_euclid(2) == 1 { w / 2.0 } else { 0.0 }, j_f * h * 0.75),
|
||||
}
|
||||
}
|
||||
|
||||
/// Smallest `q` in 1..=24 with `q·offset` (almost) an integer (24 when none is).
|
||||
fn brick_repeat(offset: f64) -> f64 {
|
||||
for q in 1..=24 {
|
||||
let v = q as f64 * offset;
|
||||
if (v - v.round()).abs() < 1e-6 {
|
||||
return q as f64;
|
||||
}
|
||||
}
|
||||
24.0
|
||||
}
|
||||
|
||||
/// Size of the rectangular super-tile a tiling repeats on.
|
||||
pub fn tile_period(tt: TileType, w: f64, h: f64) -> (f64, f64) {
|
||||
match tt {
|
||||
TileType::Grid => (w, h),
|
||||
TileType::BrickByRow { offset } => (w, h * brick_repeat(offset)),
|
||||
TileType::BrickByColumn { offset } => (w * brick_repeat(offset), h),
|
||||
TileType::HexByColumn => (w * 1.5, h),
|
||||
TileType::HexByRow => (w, h * 1.5),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pattern Options → Overlap.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Overlap {
|
||||
/// Right in front (default: left in front).
|
||||
#[serde(default)]
|
||||
pub right_in_front: bool,
|
||||
/// Bottom in front (default: top in front).
|
||||
#[serde(default)]
|
||||
pub bottom_in_front: bool,
|
||||
}
|
||||
|
||||
/// A pattern swatch definition.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PatternDef {
|
||||
pub name: String,
|
||||
/// Tile rectangle in the art's coordinates.
|
||||
pub tile: Rect,
|
||||
#[serde(default)]
|
||||
pub tile_type: TileType,
|
||||
pub art: Vec<Arc<Node>>,
|
||||
#[serde(default)]
|
||||
pub overlap: Overlap,
|
||||
/// Size Tile to Art: the tile is the art bounds grown by the spacing.
|
||||
#[serde(default)]
|
||||
pub size_tile_to_art: bool,
|
||||
#[serde(default)]
|
||||
pub h_spacing: f64,
|
||||
#[serde(default)]
|
||||
pub v_spacing: f64,
|
||||
/// Pattern editing preview: copies per side (3, 5, 7, 9).
|
||||
#[serde(default = "five")]
|
||||
pub copies: u32,
|
||||
/// Dim Copies to (percent opacity of the preview copies).
|
||||
#[serde(default = "seventy")]
|
||||
pub dim_copies: f32,
|
||||
#[serde(default = "yes")]
|
||||
pub show_tile_edge: bool,
|
||||
}
|
||||
|
||||
impl PatternDef {
|
||||
/// A pattern of `art` whose tile is the art's bounds.
|
||||
pub fn new(name: &str, art: Vec<Arc<Node>>) -> Self {
|
||||
let tile = crate::live::nodes_bounds(&art).unwrap_or(Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
tile,
|
||||
tile_type: TileType::Grid,
|
||||
art,
|
||||
overlap: Overlap::default(),
|
||||
size_tile_to_art: false,
|
||||
h_spacing: 0.0,
|
||||
v_spacing: 0.0,
|
||||
copies: 5,
|
||||
dim_copies: 70.0,
|
||||
show_tile_edge: true,
|
||||
}
|
||||
}
|
||||
pub fn width(&self) -> f64 {
|
||||
self.tile.width().max(1e-3)
|
||||
}
|
||||
pub fn height(&self) -> f64 {
|
||||
self.tile.height().max(1e-3)
|
||||
}
|
||||
/// Visual bounds of the art (stroke included).
|
||||
pub fn art_bounds(&self) -> Option<Rect> {
|
||||
self.art.iter().fold(None, |acc, n| drawcraft_geom::union_opt(acc, n.visual_bounds()))
|
||||
}
|
||||
/// Re-derive the tile from the art (Size Tile to Art).
|
||||
pub fn fit_tile_to_art(&mut self) {
|
||||
if let Some(b) = crate::live::nodes_bounds(&self.art) {
|
||||
self.tile = Rect::new(b.x0 - self.h_spacing / 2.0, b.y0 - self.v_spacing / 2.0, b.x1 + self.h_spacing / 2.0, b.y1 + self.v_spacing / 2.0);
|
||||
}
|
||||
}
|
||||
/// Offset of instance (column `i`, row `j`) in pattern space.
|
||||
pub fn offset(&self, i: i64, j: i64) -> Vec2 {
|
||||
tile_offset(self.tile_type, self.width(), self.height(), i, j)
|
||||
}
|
||||
/// The rectangular period of the tiling (the super-tile).
|
||||
pub fn period(&self) -> (f64, f64) {
|
||||
tile_period(self.tile_type, self.width(), self.height())
|
||||
}
|
||||
/// Maps the art's coordinates into pattern space for instance offset `o`.
|
||||
pub fn instance_xf(&self, o: Vec2) -> Affine {
|
||||
Affine::translate(o - self.tile.origin().to_vec2())
|
||||
}
|
||||
/// Offsets (pattern space) of every instance whose art touches `region` (pattern space), in
|
||||
/// paint order: the instances "in front" per [`Overlap`] come last.
|
||||
pub fn offsets_covering(&self, region: Rect) -> Vec<Vec2> {
|
||||
let (w, h) = (self.width(), self.height());
|
||||
// Art extent relative to the tile's origin (at least the tile itself).
|
||||
let ab = self.art_bounds().unwrap_or(self.tile).union(self.tile) - self.tile.origin().to_vec2();
|
||||
let (sx, sy) = self.tile_type.steps(w, h);
|
||||
// Shifts of brick/hex rows or columns are < one tile.
|
||||
let (i0, i1) = (((region.x0 - ab.x1 - w) / sx).floor() as i64 - 1, ((region.x1 - ab.x0 + w) / sx).ceil() as i64 + 1);
|
||||
let (j0, j1) = (((region.y0 - ab.y1 - h) / sy).floor() as i64 - 1, ((region.y1 - ab.y0 + h) / sy).ceil() as i64 + 1);
|
||||
if (i1 - i0).saturating_mul(j1 - j0) > 250_000 {
|
||||
return vec![];
|
||||
}
|
||||
let mut out = vec![];
|
||||
let rows: Vec<i64> = if self.overlap.bottom_in_front { (j0..=j1).collect() } else { (j0..=j1).rev().collect() };
|
||||
for &j in &rows {
|
||||
let cols: Vec<i64> = if self.overlap.right_in_front { (i0..=i1).collect() } else { (i0..=i1).rev().collect() };
|
||||
for i in cols {
|
||||
let o = self.offset(i, j);
|
||||
let r = ab + o;
|
||||
if r.x0 < region.x1 && r.x1 > region.x0 && r.y0 < region.y1 && r.y1 > region.y0 {
|
||||
out.push(o);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Offsets of the pattern-editing preview copies (`copies`×`copies` around the tile, the
|
||||
/// centre `(0, 0)` excluded).
|
||||
pub fn preview_offsets(&self) -> Vec<Vec2> {
|
||||
let k = (self.copies.clamp(1, 15) / 2) as i64;
|
||||
let mut out = vec![];
|
||||
for j in -k..=k {
|
||||
for i in -k..=k {
|
||||
if (i, j) != (0, 0) {
|
||||
out.push(self.offset(i, j));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Instances covering `region` (document space) as plain nodes for a pattern painted with
|
||||
/// `paint_xf` (pattern space → document), fresh-id-less (id 0). Used by exporters and Expand.
|
||||
pub fn instances_in(&self, paint_xf: Affine, region: Rect) -> Vec<Node> {
|
||||
let inv = paint_xf.inverse();
|
||||
let pr = inv.transform_rect_bbox(region);
|
||||
let mut out = vec![];
|
||||
for o in self.offsets_covering(pr) {
|
||||
let m = paint_xf * self.instance_xf(o);
|
||||
for a in &self.art {
|
||||
let mut n = (**a).clone();
|
||||
n.transform(m, false);
|
||||
zero_ids(&mut n);
|
||||
out.push(n);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
fn zero_ids(n: &mut Node) {
|
||||
n.id = NodeId(0);
|
||||
if let Some(ch) = n.children_mut() {
|
||||
for c in ch.iter_mut() {
|
||||
zero_ids(Arc::make_mut(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pattern editing mode state (lives in the document so undo and the renderer see it).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PatternEdit {
|
||||
/// Pattern being edited.
|
||||
pub pattern: String,
|
||||
/// The temporary layer holding the tile art while editing.
|
||||
pub layer: NodeId,
|
||||
/// The definition before editing (restored by Cancel); `None` when Make created it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub original: Option<PatternDef>,
|
||||
}
|
||||
|
||||
/// Name of the temporary pattern-editing layer.
|
||||
pub const PATTERN_EDIT_LAYER: &str = "Pattern Editing Mode";
|
||||
|
||||
/// A pattern paint (identity placement).
|
||||
pub fn pattern_paint(name: &str) -> Paint {
|
||||
Paint::Pattern { pattern: name.to_string(), xf: Affine::IDENTITY }
|
||||
}
|
||||
|
||||
/// Does `n` (not its children) paint any fill/stroke with pattern `name` (any pattern for `None`)?
|
||||
pub fn uses_pattern(n: &Node, name: Option<&str>) -> bool {
|
||||
n.appearance.items.iter().any(|it| {
|
||||
let p = match it {
|
||||
AppearanceItem::Fill(f) => &f.paint,
|
||||
AppearanceItem::Stroke(s) => &s.paint,
|
||||
};
|
||||
matches!(p, Paint::Pattern { pattern, .. } if name.is_none_or(|nm| nm == pattern))
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply `m` to the pattern placement of every pattern paint of `n` (Transform Patterns).
|
||||
pub fn transform_pattern_paints(n: &mut Node, m: Affine) {
|
||||
for it in n.appearance.items.iter_mut() {
|
||||
let p = match it {
|
||||
AppearanceItem::Fill(f) => &mut f.paint,
|
||||
AppearanceItem::Stroke(s) => &mut s.paint,
|
||||
};
|
||||
if let Paint::Pattern { xf, .. } = p {
|
||||
*xf = m * *xf;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================================
|
||||
// Repeat
|
||||
// =====================================================================================
|
||||
|
||||
/// The arrangement of a Repeat object.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum RepeatKind {
|
||||
/// Copies rotated around `center`; the source's centre is kept `radius` from it.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Radial {
|
||||
instances: u32,
|
||||
radius: f64,
|
||||
center: Point,
|
||||
#[serde(default)]
|
||||
reverse_overlap: bool,
|
||||
#[serde(default)]
|
||||
start_angle: f64,
|
||||
#[serde(default = "full_turn")]
|
||||
end_angle: f64,
|
||||
},
|
||||
/// Copies on a grid (tile = source bounds + spacing), optionally brick/hex offset and with
|
||||
/// alternate rows/columns flipped.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Grid {
|
||||
h_spacing: f64,
|
||||
v_spacing: f64,
|
||||
#[serde(default = "three")]
|
||||
rows: u32,
|
||||
#[serde(default = "three")]
|
||||
cols: u32,
|
||||
#[serde(default)]
|
||||
grid_type: TileType,
|
||||
/// Flip alternate rows vertically.
|
||||
#[serde(default)]
|
||||
flip_rows: bool,
|
||||
/// Flip alternate columns horizontally.
|
||||
#[serde(default)]
|
||||
flip_cols: bool,
|
||||
},
|
||||
/// The source plus its reflection across the axis through `center` at `angle` degrees
|
||||
/// (90 = vertical axis).
|
||||
Mirror { angle: f64, center: Point },
|
||||
}
|
||||
|
||||
impl RepeatKind {
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
RepeatKind::Radial { .. } => "Radial Repeat",
|
||||
RepeatKind::Grid { .. } => "Grid Repeat",
|
||||
RepeatKind::Mirror { .. } => "Mirror Repeat",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RepeatSpec {
|
||||
pub kind: RepeatKind,
|
||||
pub source: Vec<Arc<Node>>,
|
||||
}
|
||||
|
||||
/// Reflection across the line through `c` with direction angle `deg`.
|
||||
pub fn reflect_about(c: Point, deg: f64) -> Affine {
|
||||
let t = (2.0 * deg).to_radians();
|
||||
let (s2, c2) = t.sin_cos();
|
||||
Affine::translate(c.to_vec2()) * Affine::new([c2, s2, s2, -c2, 0.0, 0.0]) * Affine::translate(-c.to_vec2())
|
||||
}
|
||||
|
||||
/// Instance transforms of a repeat (source space → document), in paint order.
|
||||
pub fn repeat_transforms(kind: &RepeatKind, source_bounds: Option<Rect>) -> Vec<Affine> {
|
||||
let b = source_bounds.unwrap_or(Rect::new(0.0, 0.0, 1.0, 1.0));
|
||||
match kind {
|
||||
RepeatKind::Radial { instances, radius, center, reverse_overlap, start_angle, end_angle } => {
|
||||
let n = (*instances).clamp(1, 1000);
|
||||
let c0 = b.center();
|
||||
let mut d = c0 - *center;
|
||||
if d.hypot() < 1e-9 {
|
||||
d = Vec2::new(0.0, -1.0);
|
||||
}
|
||||
let pre = Affine::translate(*center + d.normalize() * radius.max(0.0) - c0);
|
||||
let sweep = end_angle - start_angle;
|
||||
let full = (sweep.abs() - 360.0).abs() < 1e-9;
|
||||
let step = if full || n == 1 { sweep / n as f64 } else { sweep / (n - 1) as f64 };
|
||||
let mut v: Vec<Affine> = (0..n)
|
||||
.map(|k| {
|
||||
let a = (start_angle + step * k as f64).to_radians();
|
||||
Affine::translate(center.to_vec2()) * Affine::rotate(a) * Affine::translate(-center.to_vec2()) * pre
|
||||
})
|
||||
.collect();
|
||||
if *reverse_overlap {
|
||||
v.reverse();
|
||||
}
|
||||
v
|
||||
}
|
||||
RepeatKind::Grid { h_spacing, v_spacing, rows, cols, grid_type, flip_rows, flip_cols } => {
|
||||
let (w, h) = ((b.width() + h_spacing).max(1e-3), (b.height() + v_spacing).max(1e-3));
|
||||
let mut v = vec![];
|
||||
for j in 0..(*rows).clamp(1, 500) as i64 {
|
||||
for i in 0..(*cols).clamp(1, 500) as i64 {
|
||||
let o = tile_offset(*grid_type, w, h, i, j);
|
||||
let c = b.center();
|
||||
let mut flip = Affine::IDENTITY;
|
||||
if *flip_cols && i % 2 == 1 {
|
||||
flip = reflect_about(c, 90.0) * flip;
|
||||
}
|
||||
if *flip_rows && j % 2 == 1 {
|
||||
flip = reflect_about(c, 0.0) * flip;
|
||||
}
|
||||
v.push(Affine::translate(o) * flip);
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
RepeatKind::Mirror { angle, center } => vec![Affine::IDENTITY, reflect_about(*center, *angle)],
|
||||
}
|
||||
}
|
||||
|
||||
impl RepeatSpec {
|
||||
pub fn source_bounds(&self) -> Option<Rect> {
|
||||
crate::live::nodes_bounds(&self.source)
|
||||
}
|
||||
pub fn transforms(&self) -> Vec<Affine> {
|
||||
repeat_transforms(&self.kind, self.source_bounds())
|
||||
}
|
||||
/// Evaluated instances: one group of the (transformed) source per instance (ids 0).
|
||||
pub fn expand(&self) -> Vec<Node> {
|
||||
self.transforms()
|
||||
.into_iter()
|
||||
.map(|m| {
|
||||
let children = self
|
||||
.source
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let mut n = (**s).clone();
|
||||
n.transform(m, false);
|
||||
zero_ids(&mut n);
|
||||
Arc::new(n)
|
||||
})
|
||||
.collect();
|
||||
Node::group(NodeId(0), children)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// Geometric bounds of all instances.
|
||||
pub fn bounds(&self) -> Option<Rect> {
|
||||
let b = self.source_bounds()?;
|
||||
self.transforms().into_iter().fold(None, |acc, m| drawcraft_geom::union_opt(acc, Some(m.transform_rect_bbox(b))))
|
||||
}
|
||||
/// Apply a document transform (source + arrangement parameters).
|
||||
pub fn transform(&mut self, a: Affine, scale_strokes: bool) {
|
||||
for c in self.source.iter_mut() {
|
||||
Arc::make_mut(c).transform(a, scale_strokes);
|
||||
}
|
||||
let s = a.determinant().abs().sqrt();
|
||||
match &mut self.kind {
|
||||
RepeatKind::Radial { radius, center, .. } => {
|
||||
*center = a * *center;
|
||||
*radius *= s;
|
||||
}
|
||||
RepeatKind::Grid { h_spacing, v_spacing, .. } => {
|
||||
*h_spacing *= s;
|
||||
*v_spacing *= s;
|
||||
}
|
||||
RepeatKind::Mirror { angle, center } => {
|
||||
*center = a * *center;
|
||||
let d = a * Point::new(angle.to_radians().cos(), angle.to_radians().sin()) - a * Point::ZERO;
|
||||
*angle = d.y.atan2(d.x).to_degrees();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Default radial repeat around the source (centre below it, radius = its height × 1.2).
|
||||
pub fn radial(source: Vec<Arc<Node>>, instances: u32, radius: Option<f64>) -> Self {
|
||||
let b = crate::live::nodes_bounds(&source).unwrap_or(Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
let r = radius.unwrap_or((b.height().max(b.width()) * 1.2).max(10.0));
|
||||
let center = b.center() + Vec2::new(0.0, r);
|
||||
Self { kind: RepeatKind::Radial { instances, radius: r, center, reverse_overlap: false, start_angle: 0.0, end_angle: 360.0 }, source }
|
||||
}
|
||||
pub fn grid(source: Vec<Arc<Node>>, h_spacing: f64, v_spacing: f64) -> Self {
|
||||
Self {
|
||||
kind: RepeatKind::Grid { h_spacing, v_spacing, rows: 3, cols: 3, grid_type: TileType::Grid, flip_rows: false, flip_cols: false },
|
||||
source,
|
||||
}
|
||||
}
|
||||
/// Mirror across an axis at `angle` through a point `offset` beyond the source's right edge
|
||||
/// (vertical axis) or below it (other angles pass through the right-edge midpoint).
|
||||
pub fn mirror(source: Vec<Arc<Node>>, angle: f64, offset: f64) -> Self {
|
||||
let b = crate::live::nodes_bounds(&source).unwrap_or(Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
let center = Point::new(b.x1 + offset, b.center().y);
|
||||
Self { kind: RepeatKind::Mirror { angle, center }, source }
|
||||
}
|
||||
}
|
||||
|
||||
/// Is this a Repeat object?
|
||||
pub fn is_repeat(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Repeat(_))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Appearance;
|
||||
use drawcraft_geom::shapes;
|
||||
|
||||
fn sq(x: f64, y: f64, s: f64) -> Arc<Node> {
|
||||
Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(x, y, x + s, y + s)), Appearance::default()))
|
||||
}
|
||||
|
||||
fn close(a: Vec2, b: Vec2) -> bool {
|
||||
(a - b).hypot() < 1e-9
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_offsets() {
|
||||
assert!(close(tile_offset(TileType::Grid, 10.0, 20.0, 2, 3), Vec2::new(20.0, 60.0)));
|
||||
assert!(close(tile_offset(TileType::Grid, 10.0, 20.0, -1, -1), Vec2::new(-10.0, -20.0)));
|
||||
assert_eq!(tile_period(TileType::Grid, 10.0, 20.0), (10.0, 20.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brick_by_row_offsets() {
|
||||
let t = TileType::BrickByRow { offset: 0.5 };
|
||||
assert!(close(tile_offset(t, 10.0, 20.0, 0, 1), Vec2::new(5.0, 20.0)));
|
||||
assert!(close(tile_offset(t, 10.0, 20.0, 0, 2), Vec2::new(0.0, 40.0)));
|
||||
assert!(close(tile_offset(t, 10.0, 20.0, 1, -1), Vec2::new(15.0, -20.0)));
|
||||
assert_eq!(tile_period(t, 10.0, 20.0), (10.0, 40.0));
|
||||
let t3 = TileType::BrickByRow { offset: 1.0 / 3.0 };
|
||||
assert_eq!(tile_period(t3, 10.0, 20.0), (10.0, 60.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brick_by_column_offsets() {
|
||||
let t = TileType::BrickByColumn { offset: 0.25 };
|
||||
assert!(close(tile_offset(t, 10.0, 20.0, 1, 0), Vec2::new(10.0, 5.0)));
|
||||
assert!(close(tile_offset(t, 10.0, 20.0, 3, 1), Vec2::new(30.0, 35.0)));
|
||||
assert_eq!(tile_period(t, 10.0, 20.0), (40.0, 20.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_offsets() {
|
||||
let c = TileType::HexByColumn;
|
||||
assert!(close(tile_offset(c, 10.0, 20.0, 1, 0), Vec2::new(7.5, 10.0)));
|
||||
assert!(close(tile_offset(c, 10.0, 20.0, 2, 1), Vec2::new(15.0, 20.0)));
|
||||
assert!(close(tile_offset(c, 10.0, 20.0, -1, 0), Vec2::new(-7.5, 10.0)));
|
||||
assert_eq!(tile_period(c, 10.0, 20.0), (15.0, 20.0));
|
||||
let r = TileType::HexByRow;
|
||||
assert!(close(tile_offset(r, 10.0, 20.0, 0, 1), Vec2::new(5.0, 15.0)));
|
||||
assert!(close(tile_offset(r, 10.0, 20.0, 1, 2), Vec2::new(10.0, 30.0)));
|
||||
assert_eq!(tile_period(r, 10.0, 20.0), (10.0, 30.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilings_are_periodic_on_their_period() {
|
||||
// Every instance shifted by the period is again an instance.
|
||||
for tt in [
|
||||
TileType::Grid,
|
||||
TileType::BrickByRow { offset: 0.5 },
|
||||
TileType::BrickByColumn { offset: 0.25 },
|
||||
TileType::HexByColumn,
|
||||
TileType::HexByRow,
|
||||
] {
|
||||
let (w, h) = (10.0, 20.0);
|
||||
let (pw, ph) = tile_period(tt, w, h);
|
||||
let all: Vec<Vec2> = (-8..8).flat_map(|j| (-8..8).map(move |i| tile_offset(tt, w, h, i, j))).collect();
|
||||
for o in all.iter().filter(|o| o.x.abs() < 30.0 && o.y.abs() < 30.0) {
|
||||
for d in [Vec2::new(pw, 0.0), Vec2::new(0.0, ph)] {
|
||||
assert!(all.iter().any(|p| close(*p, *o + d)), "{tt:?} {o:?}+{d:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tile_type_parse() {
|
||||
assert_eq!(TileType::parse("brickByRow", Some(0.25)), Some(TileType::BrickByRow { offset: 0.25 }));
|
||||
assert_eq!(TileType::parse("hex-by-row", None), Some(TileType::HexByRow));
|
||||
assert_eq!(TileType::parse("nope", None), None);
|
||||
for id in TileType::IDS {
|
||||
assert_eq!(TileType::parse(id, None).unwrap().id(), id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn covering_offsets_and_overlap_order() {
|
||||
let def = PatternDef::new("p", vec![sq(0.0, 0.0, 10.0)]);
|
||||
let offs = def.offsets_covering(Rect::new(0.0, 0.0, 30.0, 20.0));
|
||||
// 3×2 tiles fully inside, plus neighbours touching the edges are excluded (strict overlap).
|
||||
assert_eq!(offs.len(), 6, "{offs:?}");
|
||||
// Left/top in front: the top-left tile is drawn last.
|
||||
assert!(close(*offs.last().unwrap(), Vec2::ZERO));
|
||||
let mut d2 = def.clone();
|
||||
d2.overlap = Overlap { right_in_front: true, bottom_in_front: true };
|
||||
assert!(close(*d2.offsets_covering(Rect::new(0.0, 0.0, 30.0, 20.0)).last().unwrap(), Vec2::new(20.0, 10.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instances_follow_paint_xf() {
|
||||
let def = PatternDef::new("p", vec![sq(100.0, 100.0, 10.0)]);
|
||||
let n = def.instances_in(Affine::translate((3.0, 0.0)), Rect::new(0.0, 0.0, 20.0, 10.0));
|
||||
let xs: Vec<f64> = n.iter().filter_map(|n| n.geometric_bounds()).map(|b| b.x0).collect();
|
||||
assert!(xs.contains(&3.0) && xs.contains(&-7.0) && xs.contains(&13.0), "{xs:?}");
|
||||
assert!(n.iter().all(|n| n.id == NodeId(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_offsets_count() {
|
||||
let mut def = PatternDef::new("p", vec![sq(0.0, 0.0, 10.0)]);
|
||||
assert_eq!(def.preview_offsets().len(), 24);
|
||||
def.copies = 3;
|
||||
assert_eq!(def.preview_offsets().len(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_transforms() {
|
||||
let r = RepeatSpec::radial(vec![sq(-5.0, -105.0, 10.0)], 4, Some(100.0));
|
||||
let RepeatKind::Radial { center, .. } = r.kind else { panic!() };
|
||||
assert!((center - Point::new(0.0, -0.0)).hypot() < 1e-9, "{center:?}");
|
||||
let cs: Vec<Point> = r.transforms().iter().map(|m| *m * Point::new(0.0, -100.0)).collect();
|
||||
assert!((cs[0] - Point::new(0.0, -100.0)).hypot() < 1e-9);
|
||||
assert!((cs[1] - Point::new(100.0, 0.0)).hypot() < 1e-9, "{cs:?}");
|
||||
assert!((cs[2] - Point::new(0.0, 100.0)).hypot() < 1e-9);
|
||||
assert_eq!(r.expand().len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_radius_is_authoritative() {
|
||||
let mut r = RepeatSpec::radial(vec![sq(-5.0, -105.0, 10.0)], 6, Some(100.0));
|
||||
if let RepeatKind::Radial { radius, .. } = &mut r.kind {
|
||||
*radius = 50.0;
|
||||
}
|
||||
let m = r.transforms()[0];
|
||||
assert!((m * Point::new(0.0, -100.0) - Point::new(0.0, -50.0)).hypot() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_repeat_transforms() {
|
||||
let g = RepeatSpec::grid(vec![sq(0.0, 0.0, 10.0)], 5.0, 2.0);
|
||||
let t = g.transforms();
|
||||
assert_eq!(t.len(), 9);
|
||||
assert!((t[1] * Point::ZERO - Point::new(15.0, 0.0)).hypot() < 1e-9);
|
||||
assert!((t[3] * Point::ZERO - Point::new(0.0, 12.0)).hypot() < 1e-9);
|
||||
assert_eq!(g.bounds(), Some(Rect::new(0.0, 0.0, 40.0, 34.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_repeat_flip() {
|
||||
let mut g = RepeatSpec::grid(vec![sq(0.0, 0.0, 10.0)], 0.0, 0.0);
|
||||
if let RepeatKind::Grid { flip_cols, .. } = &mut g.kind {
|
||||
*flip_cols = true;
|
||||
}
|
||||
let t = g.transforms();
|
||||
// Column 1 is mirrored about the source centre, then moved one tile right.
|
||||
assert!((t[1] * Point::new(0.0, 0.0) - Point::new(20.0, 0.0)).hypot() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_transforms() {
|
||||
let m = RepeatSpec::mirror(vec![sq(0.0, 0.0, 10.0)], 90.0, 5.0);
|
||||
let t = m.transforms();
|
||||
assert_eq!(t.len(), 2);
|
||||
assert!((t[1] * Point::new(0.0, 3.0) - Point::new(30.0, 3.0)).hypot() < 1e-9);
|
||||
let b = m.bounds().unwrap();
|
||||
assert!((b.x1 - 30.0).abs() < 1e-9 && b.x0.abs() < 1e-9 && (b.y1 - 10.0).abs() < 1e-9, "{b:?}");
|
||||
// Horizontal axis.
|
||||
let h = reflect_about(Point::new(0.0, 10.0), 0.0);
|
||||
assert!((h * Point::new(4.0, 0.0) - Point::new(4.0, 20.0)).hypot() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeat_transform_moves_arrangement() {
|
||||
let mut r = RepeatSpec::radial(vec![sq(-5.0, -105.0, 10.0)], 4, Some(100.0));
|
||||
let before = r.bounds().unwrap();
|
||||
r.transform(Affine::translate((50.0, 0.0)), false);
|
||||
let after = r.bounds().unwrap();
|
||||
assert!((after.x0 - before.x0 - 50.0).abs() < 1e-9);
|
||||
let mut m = RepeatSpec::mirror(vec![sq(0.0, 0.0, 10.0)], 90.0, 5.0);
|
||||
m.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), false);
|
||||
let RepeatKind::Mirror { angle, .. } = m.kind else { panic!() };
|
||||
assert!((angle.rem_euclid(180.0) - 0.0).abs() < 1e-9 || (angle.rem_euclid(180.0) - 180.0).abs() < 1e-9, "{angle}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_roundtrip() {
|
||||
let mut def = PatternDef::new("Dots", vec![sq(0.0, 0.0, 10.0)]);
|
||||
def.tile_type = TileType::HexByRow;
|
||||
let s = serde_json::to_string(&def).unwrap();
|
||||
assert_eq!(serde_json::from_str::<PatternDef>(&s).unwrap(), def);
|
||||
let n = Node::new(NodeId(5), NodeKind::Repeat(RepeatSpec::grid(vec![sq(0.0, 0.0, 10.0)], 1.0, 2.0)));
|
||||
let s = serde_json::to_string(&n).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Node>(&s).unwrap(), n);
|
||||
// Old pattern paints (no xf) still load.
|
||||
let p: Paint = serde_json::from_str(r#"{"type":"pattern","pattern":"Dots"}"#).unwrap();
|
||||
assert_eq!(p, pattern_paint("Dots"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,449 @@
|
|||
//! Preferences (Edit → Preferences): `prefs.get`, `prefs.set`, `prefs.reset`, `prefs.list`.
|
||||
//!
|
||||
//! [`PREF_SPECS`] is the single source of truth for every preference: its category, section,
|
||||
//! label and value kind. The Preferences dialog renders from it and `prefs.set` validates with it.
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::Prefs;
|
||||
|
||||
/// What kind of value a preference holds.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum PrefKind {
|
||||
Bool,
|
||||
/// A number in `min..=max` shown with `unit` ("pt", "px", "°", "%", "min", "").
|
||||
Num { min: f64, max: f64, unit: &'static str },
|
||||
/// A whole number in `min..=max`.
|
||||
Int { min: i64, max: i64 },
|
||||
/// One of (value, label).
|
||||
Choice(&'static [(&'static str, &'static str)]),
|
||||
/// `#rrggbb`.
|
||||
Color,
|
||||
Text,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct PrefSpec {
|
||||
pub key: &'static str,
|
||||
pub category: &'static str,
|
||||
/// Group heading inside the category ("" = none).
|
||||
pub section: &'static str,
|
||||
pub label: &'static str,
|
||||
pub kind: PrefKind,
|
||||
}
|
||||
|
||||
/// Categories in Illustrator's order (Plug-ins & Scratch Disks is "Performance & Storage" here).
|
||||
pub const PREF_CATEGORIES: &[&str] = &[
|
||||
"General",
|
||||
"Selection & Anchor Display",
|
||||
"Type",
|
||||
"Units",
|
||||
"Guides & Grid",
|
||||
"Smart Guides",
|
||||
"Slices",
|
||||
"Hyphenation",
|
||||
"Performance & Storage",
|
||||
"User Interface",
|
||||
"Performance",
|
||||
"File Handling",
|
||||
"Clipboard Handling",
|
||||
"Appearance of Black",
|
||||
"Devices",
|
||||
];
|
||||
|
||||
pub const UNITS: &[(&str, &str)] = &[
|
||||
("points", "Points"),
|
||||
("picas", "Picas"),
|
||||
("inches", "Inches"),
|
||||
("millimeters", "Millimeters"),
|
||||
("centimeters", "Centimeters"),
|
||||
("pixels", "Pixels"),
|
||||
];
|
||||
const LINE_STYLE: &[(&str, &str)] = &[("lines", "Lines"), ("dots", "Dots")];
|
||||
const BLACK: &[(&str, &str)] = &[("accurate", "Display All Blacks Accurately"), ("rich", "Display All Blacks as Rich Black")];
|
||||
const BLACK_OUT: &[(&str, &str)] = &[("accurate", "Output All Blacks Accurately"), ("rich", "Output All Blacks as Rich Black")];
|
||||
|
||||
macro_rules! p {
|
||||
($key:literal, $cat:literal, $sec:literal, $label:literal, bool) => {
|
||||
PrefSpec { key: $key, category: $cat, section: $sec, label: $label, kind: PrefKind::Bool }
|
||||
};
|
||||
($key:literal, $cat:literal, $sec:literal, $label:literal, num($min:expr, $max:expr, $unit:literal)) => {
|
||||
PrefSpec { key: $key, category: $cat, section: $sec, label: $label, kind: PrefKind::Num { min: $min, max: $max, unit: $unit } }
|
||||
};
|
||||
($key:literal, $cat:literal, $sec:literal, $label:literal, int($min:expr, $max:expr)) => {
|
||||
PrefSpec { key: $key, category: $cat, section: $sec, label: $label, kind: PrefKind::Int { min: $min, max: $max } }
|
||||
};
|
||||
($key:literal, $cat:literal, $sec:literal, $label:literal, choice($c:expr)) => {
|
||||
PrefSpec { key: $key, category: $cat, section: $sec, label: $label, kind: PrefKind::Choice($c) }
|
||||
};
|
||||
($key:literal, $cat:literal, $sec:literal, $label:literal, color) => {
|
||||
PrefSpec { key: $key, category: $cat, section: $sec, label: $label, kind: PrefKind::Color }
|
||||
};
|
||||
($key:literal, $cat:literal, $sec:literal, $label:literal, text) => {
|
||||
PrefSpec { key: $key, category: $cat, section: $sec, label: $label, kind: PrefKind::Text }
|
||||
};
|
||||
}
|
||||
|
||||
pub const PREF_SPECS: &[PrefSpec] = &[
|
||||
// General
|
||||
p!("keyboardIncrement", "General", "", "Keyboard Increment", num(0.001, 1296.0, "pt")),
|
||||
p!("constrainAngle", "General", "", "Constrain Angle", num(-360.0, 360.0, "°")),
|
||||
p!("cornerRadius", "General", "", "Corner Radius", num(0.0, 1296.0, "pt")),
|
||||
p!("pasteOffset", "General", "", "Paste Offset", num(0.0, 1296.0, "pt")),
|
||||
p!("disableAutoAddDelete", "General", "Options", "Disable Auto Add/Delete", bool),
|
||||
p!("usePreciseCursors", "General", "Options", "Use Precise Cursors", bool),
|
||||
p!("showToolTips", "General", "Options", "Show Tool Tips", bool),
|
||||
p!("antiAliasedArtwork", "General", "Options", "Anti-aliased Artwork", bool),
|
||||
p!("selectSameTintPercent", "General", "Options", "Select Same Tint %", bool),
|
||||
p!("showHomeScreen", "General", "Options", "Show The Home Screen When No Documents Are Open", bool),
|
||||
p!("usePreviewBounds", "General", "Options", "Use Preview Bounds", bool),
|
||||
p!("displayPrintSize", "General", "Options", "Display Print Size at 100% Zoom", bool),
|
||||
p!("doubleClickToIsolate", "General", "Options", "Double Click To Isolate", bool),
|
||||
p!("transformPatternTiles", "General", "Options", "Transform Pattern Tiles", bool),
|
||||
p!("scaleCorners", "General", "Options", "Scale Corners", bool),
|
||||
p!("scaleStrokes", "General", "Options", "Scale Strokes & Effects", bool),
|
||||
p!("zoomWithMouseWheel", "General", "Options", "Zoom with Mouse Wheel", bool),
|
||||
// Selection & Anchor Display
|
||||
p!("selectionTolerance", "Selection & Anchor Display", "Selection", "Tolerance", num(1.0, 8.0, "px")),
|
||||
p!("objectSelectionByPathOnly", "Selection & Anchor Display", "Selection", "Object Selection by Path Only", bool),
|
||||
p!("snapToPointTolerance", "Selection & Anchor Display", "Selection", "Snap to Point", num(1.0, 8.0, "px")),
|
||||
p!("ctrlClickSelectsBehind", "Selection & Anchor Display", "Selection", "Command Click to Select Objects Behind", bool),
|
||||
p!("zoomToSelection", "Selection & Anchor Display", "Selection", "Zoom to Selection", bool),
|
||||
p!("moveLockedWithArtboard", "Selection & Anchor Display", "Selection", "Move Locked and Hidden Artwork with Artboard", bool),
|
||||
p!("anchorSize", "Selection & Anchor Display", "Anchor Points, Handle, and Bounding Box Display", "Size", int(1, 7)),
|
||||
p!(
|
||||
"handleStyle",
|
||||
"Selection & Anchor Display",
|
||||
"Anchor Points, Handle, and Bounding Box Display",
|
||||
"Handles",
|
||||
choice(&[("solid", "Solid"), ("hollow", "Hollow"), ("large", "Large")])
|
||||
),
|
||||
p!("highlightAnchorsOnHover", "Selection & Anchor Display", "Anchor Points, Handle, and Bounding Box Display", "Highlight anchors on mouse over", bool),
|
||||
p!(
|
||||
"showHandlesMultipleAnchors",
|
||||
"Selection & Anchor Display",
|
||||
"Anchor Points, Handle, and Bounding Box Display",
|
||||
"Show handles when multiple anchors are selected",
|
||||
bool
|
||||
),
|
||||
p!("hideCornerWidgetAbove", "Selection & Anchor Display", "Anchor Points, Handle, and Bounding Box Display", "Hide Corner Widget for angles greater than", num(0.0, 180.0, "°")),
|
||||
p!("penRubberBand", "Selection & Anchor Display", "Enable Rubber Band for", "Pen Tool", bool),
|
||||
p!("curvatureRubberBand", "Selection & Anchor Display", "Enable Rubber Band for", "Curvature Tool", bool),
|
||||
// Type
|
||||
p!("typeSizeIncrement", "Type", "", "Size/Leading", num(0.001, 1296.0, "pt")),
|
||||
p!("trackingIncrement", "Type", "", "Tracking", num(1.0, 1000.0, "/1000 em")),
|
||||
p!("baselineShiftIncrement", "Type", "", "Baseline Shift", num(0.001, 1296.0, "pt")),
|
||||
p!("showEastAsianOptions", "Type", "Language Options", "Show East Asian Options", bool),
|
||||
p!("showIndicOptions", "Type", "Language Options", "Show Indic Options", bool),
|
||||
p!("typeSelectionByPathOnly", "Type", "Options", "Type Object Selection by Path Only", bool),
|
||||
p!("fontNamesInEnglish", "Type", "Options", "Show Font Names in English", bool),
|
||||
p!("autoSizeAreaType", "Type", "Options", "Auto Size New Area Type", bool),
|
||||
p!("fontPreview", "Type", "Options", "Enable in-menu font previews", bool),
|
||||
p!("fontPreviewSize", "Type", "Options", "Font Preview Size", choice(&[("small", "Small"), ("medium", "Medium"), ("large", "Large")])),
|
||||
p!("recentFontsCount", "Type", "Options", "Number of Recent Fonts", int(1, 15)),
|
||||
p!("missingGlyphProtection", "Type", "Options", "Enable Missing Glyph Protection", bool),
|
||||
p!("highlightAlternateGlyphs", "Type", "Options", "Highlight Alternate Glyphs", bool),
|
||||
p!("placeholderText", "Type", "Options", "Fill New Type Objects With Placeholder Text", bool),
|
||||
// Units
|
||||
p!("unitsGeneral", "Units", "", "General", choice(UNITS)),
|
||||
p!("unitsStroke", "Units", "", "Stroke", choice(UNITS)),
|
||||
p!("unitsType", "Units", "", "Type", choice(UNITS)),
|
||||
p!("unitsAsianType", "Units", "", "East Asian Type", choice(UNITS)),
|
||||
p!("numbersWithoutUnitsArePoints", "Units", "", "Numbers Without Units Are Points", bool),
|
||||
p!("identifyObjectsBy", "Units", "", "Identify Objects By", choice(&[("objectName", "Object Name"), ("xmlId", "XML ID")])),
|
||||
// Guides & Grid
|
||||
p!("guideColor", "Guides & Grid", "Guides", "Color", color),
|
||||
p!("guideStyle", "Guides & Grid", "Guides", "Style", choice(LINE_STYLE)),
|
||||
p!("gridColor", "Guides & Grid", "Grid", "Color", color),
|
||||
p!("gridStyle", "Guides & Grid", "Grid", "Style", choice(LINE_STYLE)),
|
||||
p!("gridlineEvery", "Guides & Grid", "Grid", "Gridline every", num(1.0, 16383.0, "pt")),
|
||||
p!("gridSubdivisions", "Guides & Grid", "Grid", "Subdivisions", int(1, 1000)),
|
||||
p!("gridsInBack", "Guides & Grid", "Grid", "Grids In Back", bool),
|
||||
p!("showPixelGrid", "Guides & Grid", "Grid", "Show Pixel Grid (Above 600% Zoom)", bool),
|
||||
// Smart Guides
|
||||
p!("smartGuideColor", "Smart Guides", "Display Options", "Color", color),
|
||||
p!("alignmentGuides", "Smart Guides", "Display Options", "Alignment Guides", bool),
|
||||
p!("objectHighlighting", "Smart Guides", "Display Options", "Object Highlighting", bool),
|
||||
p!("transformToolsGuides", "Smart Guides", "Display Options", "Transform Tools", bool),
|
||||
p!("constructionGuides", "Smart Guides", "Display Options", "Construction Guides", bool),
|
||||
p!(
|
||||
"constructionAngles",
|
||||
"Smart Guides",
|
||||
"Display Options",
|
||||
"Angles",
|
||||
choice(&[
|
||||
("90° & 45° Angles", "90° & 45° Angles"),
|
||||
("30° Angles", "30° Angles"),
|
||||
("45° Angles", "45° Angles"),
|
||||
("60° Angles", "60° Angles"),
|
||||
("90° Angles", "90° Angles"),
|
||||
("90° & 45° & 30° Angles", "90° & 45° & 30° Angles"),
|
||||
])
|
||||
),
|
||||
p!("anchorPathLabels", "Smart Guides", "Display Options", "Anchor/Path Labels", bool),
|
||||
p!("measurementLabels", "Smart Guides", "Display Options", "Measurement Labels", bool),
|
||||
p!("spacingGuides", "Smart Guides", "Display Options", "Spacing Guides", bool),
|
||||
p!("snappingTolerance", "Smart Guides", "", "Snapping Tolerance", num(0.0, 40.0, "pt")),
|
||||
// Slices
|
||||
p!("showSliceNumbers", "Slices", "", "Show Slice Numbers", bool),
|
||||
p!("sliceLineColor", "Slices", "", "Line Color", color),
|
||||
// Hyphenation
|
||||
p!(
|
||||
"hyphenationLanguage",
|
||||
"Hyphenation",
|
||||
"",
|
||||
"Default Language",
|
||||
choice(&[
|
||||
("English: USA", "English: USA"),
|
||||
("English: UK", "English: UK"),
|
||||
("French", "French"),
|
||||
("German", "German"),
|
||||
("Spanish", "Spanish"),
|
||||
("Italian", "Italian"),
|
||||
("Dutch", "Dutch"),
|
||||
("Portuguese", "Portuguese")
|
||||
])
|
||||
),
|
||||
p!("hyphenationExceptions", "Hyphenation", "", "Exceptions (comma separated)", text),
|
||||
// Performance & Storage
|
||||
p!("pluginsFolder", "Performance & Storage", "Plug-ins", "Additional Plug-ins Folder", text),
|
||||
p!("scratchPrimary", "Performance & Storage", "Scratch Disks", "Primary", text),
|
||||
p!("scratchSecondary", "Performance & Storage", "Scratch Disks", "Secondary", text),
|
||||
// User Interface
|
||||
p!(
|
||||
"uiBrightness",
|
||||
"User Interface",
|
||||
"",
|
||||
"Brightness",
|
||||
choice(&[("dark", "Dark"), ("mediumDark", "Medium Dark"), ("mediumLight", "Medium Light"), ("light", "Light")])
|
||||
),
|
||||
p!("canvasColor", "User Interface", "", "Canvas Color", choice(&[("matchUi", "Match User Interface Brightness"), ("white", "White")])),
|
||||
p!("autoCollapseIconPanels", "User Interface", "", "Auto-Collapse Iconic Panels", bool),
|
||||
p!("openDocumentsAsTabs", "User Interface", "", "Open Documents As Tabs", bool),
|
||||
p!("largeTabs", "User Interface", "", "Large Tabs", bool),
|
||||
p!("uiScaling", "User Interface", "UI Scaling", "Scale", num(0.75, 2.0, "×")),
|
||||
p!("scaleCursorWithUi", "User Interface", "UI Scaling", "Scale Cursor Proportional to UI", bool),
|
||||
// Performance
|
||||
p!("gpuPerformance", "Performance", "GPU Performance", "GPU Performance", bool),
|
||||
p!("animatedZoom", "Performance", "GPU Performance", "Animated Zoom", bool),
|
||||
p!("historyStates", "Performance", "", "History States", int(5, 1000)),
|
||||
p!("realTimeDrawing", "Performance", "", "Real-time Drawing and Editing", bool),
|
||||
p!("renderThreads", "Performance", "", "Render Threads (-1 = Automatic)", int(-1, 64)),
|
||||
// File Handling
|
||||
p!("backgroundSave", "File Handling", "", "Enable Background Save", bool),
|
||||
p!("backgroundExport", "File Handling", "", "Enable Background Export", bool),
|
||||
p!("autosaveRecovery", "File Handling", "Data Recovery", "Automatically Save Recovery Data", bool),
|
||||
p!("autosaveInterval", "File Handling", "Data Recovery", "Every (minutes)", int(1, 60)),
|
||||
p!("recoveryFolder", "File Handling", "Data Recovery", "Folder", text),
|
||||
p!("recoveryOffForComplex", "File Handling", "Data Recovery", "Turn off Data Recovery for complex documents", bool),
|
||||
p!("recentFilesCount", "File Handling", "Files", "Number of Recent Files to Display", int(0, 30)),
|
||||
p!("lowResProxyEps", "File Handling", "Files", "Use Low Resolution Proxy for Linked EPS", bool),
|
||||
p!("antiAliasedBitmaps", "File Handling", "Files", "Display Bitmaps as Anti-aliased Images in Pixel Preview", bool),
|
||||
p!(
|
||||
"updateLinks",
|
||||
"File Handling",
|
||||
"Files",
|
||||
"Update Links",
|
||||
choice(&[("automatically", "Automatically"), ("manually", "Manually"), ("askWhenModified", "Ask When Modified")])
|
||||
),
|
||||
// Clipboard Handling
|
||||
p!("copyAsSvg", "Clipboard Handling", "On Copy", "Include SVG Code", bool),
|
||||
p!("copyAsPdf", "Clipboard Handling", "On Copy", "PDF", bool),
|
||||
p!("copyAicb", "Clipboard Handling", "On Copy", "AICB (no transparency support)", bool),
|
||||
p!("aicbMode", "Clipboard Handling", "On Copy", "AICB", choice(&[("preservePaths", "Preserve Paths"), ("preserveAppearance", "Preserve Appearance and Overprints")])),
|
||||
p!("pasteTextFormatting", "Clipboard Handling", "On Paste", "When pasting text", choice(&[("keep", "Keep Formatting"), ("plain", "Keep Plain Text")])),
|
||||
// Appearance of Black
|
||||
p!("blackOnScreen", "Appearance of Black", "", "On Screen", choice(BLACK)),
|
||||
p!("blackOutput", "Appearance of Black", "", "Printing / Exporting", choice(BLACK_OUT)),
|
||||
// Devices
|
||||
p!("touchWorkspace", "Devices", "", "Enable Touch Workspace", bool),
|
||||
p!("touchGestures", "Devices", "", "Enable Touch Gestures", bool),
|
||||
];
|
||||
|
||||
pub fn spec(key: &str) -> Option<&'static PrefSpec> {
|
||||
PREF_SPECS.iter().find(|s| s.key == key)
|
||||
}
|
||||
|
||||
/// Validate (and normalize) a value for `key`. Numbers may be given as strings; booleans as
|
||||
/// "true"/"false"; choices by value or label (case-insensitive).
|
||||
pub fn validate(key: &str, v: &Value) -> std::result::Result<Value, String> {
|
||||
let sp = spec(key).ok_or_else(|| format!("unknown preference `{key}`"))?;
|
||||
let num = || v.as_f64().or_else(|| v.as_str().and_then(|s| s.trim().trim_end_matches(|c: char| c.is_alphabetic() || c == '°').trim().parse().ok()));
|
||||
match sp.kind {
|
||||
PrefKind::Bool => match v {
|
||||
Value::Bool(b) => Ok(json!(b)),
|
||||
Value::String(s) if s == "true" || s == "false" => Ok(json!(s == "true")),
|
||||
_ => Err(format!("`{key}` must be true or false")),
|
||||
},
|
||||
PrefKind::Num { min, max, .. } => match num() {
|
||||
Some(x) if x.is_finite() && (min..=max).contains(&x) => Ok(json!(x)),
|
||||
Some(x) => Err(format!("`{key}` must be between {min} and {max} (got {x})")),
|
||||
None => Err(format!("`{key}` must be a number")),
|
||||
},
|
||||
PrefKind::Int { min, max } => match num() {
|
||||
Some(x) if x.fract() == 0.0 && (min as f64..=max as f64).contains(&x) => Ok(json!(x as i64)),
|
||||
Some(x) => Err(format!("`{key}` must be a whole number between {min} and {max} (got {x})")),
|
||||
None => Err(format!("`{key}` must be a whole number")),
|
||||
},
|
||||
PrefKind::Choice(opts) => {
|
||||
let s = v.as_str().ok_or_else(|| format!("`{key}` must be a string"))?;
|
||||
opts.iter()
|
||||
.find(|(val, label)| val.eq_ignore_ascii_case(s) || label.eq_ignore_ascii_case(s))
|
||||
.map(|(val, _)| json!(val))
|
||||
.ok_or_else(|| format!("`{key}` must be one of {}", opts.iter().map(|o| o.0).collect::<Vec<_>>().join(", ")))
|
||||
}
|
||||
PrefKind::Color => {
|
||||
let s = v.as_str().unwrap_or("");
|
||||
let hex = s.trim_start_matches('#');
|
||||
if hex.len() == 6 && hex.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
Ok(json!(format!("#{}", hex.to_ascii_lowercase())))
|
||||
} else {
|
||||
Err(format!("`{key}` must be a #rrggbb colour"))
|
||||
}
|
||||
}
|
||||
PrefKind::Text => match v {
|
||||
Value::String(s) => Ok(json!(s)),
|
||||
_ => Err(format!("`{key}` must be a string")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
impl Prefs {
|
||||
pub fn to_json(&self) -> Value {
|
||||
serde_json::to_value(self).unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
/// Set several validated values at once; on any error nothing changes.
|
||||
pub fn set_values(&mut self, values: &Map<String, Value>) -> std::result::Result<(), String> {
|
||||
let mut obj = self.to_json();
|
||||
for (k, v) in values {
|
||||
obj[k.as_str()] = validate(k, v)?;
|
||||
}
|
||||
*self = serde_json::from_value(obj).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Values of one category (`None` = all) reset to defaults.
|
||||
pub fn reset(&mut self, category: Option<&str>) {
|
||||
let d = Prefs::default().to_json();
|
||||
let mut obj = self.to_json();
|
||||
for sp in PREF_SPECS.iter().filter(|s| category.is_none_or(|c| c.eq_ignore_ascii_case(s.category))) {
|
||||
obj[sp.key] = d[sp.key].clone();
|
||||
}
|
||||
if let Ok(p) = serde_json::from_value(obj) {
|
||||
*self = p;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Replace the preferences and push the ones with engine-side consumers into open documents
|
||||
/// and the renderer (grid, history depth, render threads).
|
||||
pub fn apply_prefs(&mut self, p: Prefs) {
|
||||
let grid_changed = p.gridline_every != self.prefs.gridline_every || p.grid_subdivisions != self.prefs.grid_subdivisions;
|
||||
let history_changed = p.history_states != self.prefs.history_states;
|
||||
self.prefs = p;
|
||||
drawcraft_render::set_default_threads(u16::try_from(self.prefs.render_threads).ok());
|
||||
for st in &mut self.docs {
|
||||
if history_changed {
|
||||
st.history.limit = self.prefs.history_states as usize;
|
||||
let over = st.history.undo.len().saturating_sub(st.history.limit);
|
||||
st.history.undo.drain(..over);
|
||||
}
|
||||
if grid_changed {
|
||||
let d = std::sync::Arc::make_mut(&mut st.doc);
|
||||
d.grid.spacing = self.prefs.gridline_every;
|
||||
d.grid.subdivisions = self.prefs.grid_subdivisions;
|
||||
st.revision += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "prefs.get", "Get Preferences", [], None, "{key?} → the value of one preference, or {prefs: {…all…}}", always, get),
|
||||
cmd!(
|
||||
query "prefs.set",
|
||||
"Set Preference",
|
||||
[],
|
||||
None,
|
||||
"{key, value} or {values: {key: value, …}} set preferences (validated; see prefs.list)",
|
||||
always,
|
||||
set
|
||||
),
|
||||
cmd!(query "prefs.reset", "Reset Preferences", [], None, "{category?} reset one category (or everything) to defaults", always, reset),
|
||||
cmd!(query "prefs.list", "List Preferences", [], None, "{} → [{key, category, section, label, kind, value, …}] every preference", always, list),
|
||||
]
|
||||
}
|
||||
|
||||
fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let all = s.prefs.to_json();
|
||||
match str_param(p, "key") {
|
||||
Some(k) if spec(k).is_some() => Ok(all[k].clone()),
|
||||
Some(k) => Err(bad("prefs.get", format!("unknown preference `{k}`"))),
|
||||
None => Ok(json!({ "prefs": all })),
|
||||
}
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut values = p.get("values").and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
if let Some(k) = str_param(p, "key") {
|
||||
values.insert(k.to_string(), p.get("value").cloned().ok_or_else(|| bad("prefs.set", "missing `value`"))?);
|
||||
}
|
||||
if values.is_empty() {
|
||||
return Err(bad("prefs.set", "give {key, value} or {values}"));
|
||||
}
|
||||
let mut next = s.prefs.clone();
|
||||
next.set_values(&values).map_err(|e| bad("prefs.set", e))?;
|
||||
s.apply_prefs(next);
|
||||
let all = s.prefs.to_json();
|
||||
Ok(Value::Object(values.keys().map(|k| (k.clone(), all[k.as_str()].clone())).collect()))
|
||||
}
|
||||
|
||||
fn reset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cat = str_param(p, "category");
|
||||
if let Some(c) = cat
|
||||
&& !PREF_CATEGORIES.iter().any(|x| x.eq_ignore_ascii_case(c))
|
||||
{
|
||||
return Err(bad("prefs.reset", format!("unknown category `{c}`")));
|
||||
}
|
||||
let mut next = s.prefs.clone();
|
||||
next.reset(cat);
|
||||
s.apply_prefs(next);
|
||||
Ok(json!({ "prefs": s.prefs.to_json() }))
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let all = s.prefs.to_json();
|
||||
Ok(Value::Array(
|
||||
PREF_SPECS
|
||||
.iter()
|
||||
.map(|sp| {
|
||||
let mut o = json!({"key": sp.key, "category": sp.category, "section": sp.section, "label": sp.label, "value": all[sp.key]});
|
||||
match sp.kind {
|
||||
PrefKind::Bool => o["kind"] = json!("bool"),
|
||||
PrefKind::Num { min, max, unit } => {
|
||||
o["kind"] = json!("number");
|
||||
o["min"] = json!(min);
|
||||
o["max"] = json!(max);
|
||||
o["unit"] = json!(unit);
|
||||
}
|
||||
PrefKind::Int { min, max } => {
|
||||
o["kind"] = json!("integer");
|
||||
o["min"] = json!(min);
|
||||
o["max"] = json!(max);
|
||||
}
|
||||
PrefKind::Choice(c) => {
|
||||
o["kind"] = json!("choice");
|
||||
o["options"] = json!(c.iter().map(|x| x.0).collect::<Vec<_>>());
|
||||
}
|
||||
PrefKind::Color => o["kind"] = json!("color"),
|
||||
PrefKind::Text => o["kind"] = json!("text"),
|
||||
}
|
||||
o
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
|
@ -154,21 +154,249 @@ pub enum DrawMode {
|
|||
Inside,
|
||||
}
|
||||
|
||||
/// Application preferences that affect commands.
|
||||
#[derive(Clone, Debug)]
|
||||
/// Application preferences (Edit → Preferences). Every field is reachable through `prefs.get` /
|
||||
/// `prefs.set {key, value}` (camelCase keys, validated against [`cmd::prefscmds::PREF_SPECS`]).
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Prefs {
|
||||
// General
|
||||
/// Arrow-key nudge distance (General → Keyboard Increment), points.
|
||||
pub keyboard_increment: f64,
|
||||
pub constrain_angle: f64,
|
||||
pub corner_radius: f64,
|
||||
pub disable_auto_add_delete: bool,
|
||||
pub use_precise_cursors: bool,
|
||||
pub show_tool_tips: bool,
|
||||
pub anti_aliased_artwork: bool,
|
||||
pub select_same_tint_percent: bool,
|
||||
pub show_home_screen: bool,
|
||||
pub use_preview_bounds: bool,
|
||||
pub display_print_size: bool,
|
||||
pub double_click_to_isolate: bool,
|
||||
pub transform_pattern_tiles: bool,
|
||||
pub scale_corners: bool,
|
||||
/// Scale Strokes & Effects.
|
||||
pub scale_strokes: bool,
|
||||
pub zoom_with_mouse_wheel: bool,
|
||||
/// Offset for Paste / duplicate (Illustrator pastes to the view centre; we offset by this).
|
||||
pub paste_offset: f64,
|
||||
pub corner_radius: f64,
|
||||
// Selection & Anchor Display
|
||||
pub selection_tolerance: f64,
|
||||
pub object_selection_by_path_only: bool,
|
||||
pub snap_to_point_tolerance: f64,
|
||||
pub ctrl_click_selects_behind: bool,
|
||||
pub zoom_to_selection: bool,
|
||||
pub move_locked_with_artboard: bool,
|
||||
pub anchor_size: u32,
|
||||
pub handle_style: String,
|
||||
pub highlight_anchors_on_hover: bool,
|
||||
pub show_handles_multiple_anchors: bool,
|
||||
pub hide_corner_widget_above: f64,
|
||||
pub pen_rubber_band: bool,
|
||||
pub curvature_rubber_band: bool,
|
||||
// Type
|
||||
pub type_size_increment: f64,
|
||||
pub tracking_increment: f64,
|
||||
pub baseline_shift_increment: f64,
|
||||
pub show_east_asian_options: bool,
|
||||
pub show_indic_options: bool,
|
||||
pub type_selection_by_path_only: bool,
|
||||
pub font_names_in_english: bool,
|
||||
pub auto_size_area_type: bool,
|
||||
pub font_preview: bool,
|
||||
pub font_preview_size: String,
|
||||
pub recent_fonts_count: u32,
|
||||
pub missing_glyph_protection: bool,
|
||||
pub highlight_alternate_glyphs: bool,
|
||||
pub placeholder_text: bool,
|
||||
// Units
|
||||
pub units_general: String,
|
||||
pub units_stroke: String,
|
||||
pub units_type: String,
|
||||
pub units_asian_type: String,
|
||||
pub numbers_without_units_are_points: bool,
|
||||
pub identify_objects_by: String,
|
||||
// Guides & Grid
|
||||
pub guide_color: String,
|
||||
pub guide_style: String,
|
||||
pub grid_color: String,
|
||||
pub grid_style: String,
|
||||
pub gridline_every: f64,
|
||||
pub grid_subdivisions: u32,
|
||||
pub grids_in_back: bool,
|
||||
pub show_pixel_grid: bool,
|
||||
// Smart Guides
|
||||
pub smart_guide_color: String,
|
||||
pub alignment_guides: bool,
|
||||
pub object_highlighting: bool,
|
||||
pub transform_tools_guides: bool,
|
||||
pub construction_guides: bool,
|
||||
pub construction_angles: String,
|
||||
pub anchor_path_labels: bool,
|
||||
pub measurement_labels: bool,
|
||||
pub spacing_guides: bool,
|
||||
pub snapping_tolerance: f64,
|
||||
// Slices
|
||||
pub show_slice_numbers: bool,
|
||||
pub slice_line_color: String,
|
||||
// Hyphenation
|
||||
pub hyphenation_language: String,
|
||||
pub hyphenation_exceptions: String,
|
||||
// Performance & Storage (Plug-ins & Scratch Disks)
|
||||
pub plugins_folder: String,
|
||||
pub scratch_primary: String,
|
||||
pub scratch_secondary: String,
|
||||
// User Interface
|
||||
pub ui_brightness: String,
|
||||
pub canvas_color: String,
|
||||
pub auto_collapse_icon_panels: bool,
|
||||
pub open_documents_as_tabs: bool,
|
||||
pub large_tabs: bool,
|
||||
pub ui_scaling: f64,
|
||||
pub scale_cursor_with_ui: bool,
|
||||
// Performance
|
||||
pub gpu_performance: bool,
|
||||
pub animated_zoom: bool,
|
||||
pub history_states: u32,
|
||||
pub real_time_drawing: bool,
|
||||
/// Rasterizer worker threads; -1 = automatic.
|
||||
pub render_threads: i32,
|
||||
// File Handling
|
||||
pub background_save: bool,
|
||||
pub background_export: bool,
|
||||
pub autosave_recovery: bool,
|
||||
pub autosave_interval: u32,
|
||||
pub recovery_folder: String,
|
||||
pub recovery_off_for_complex: bool,
|
||||
pub recent_files_count: u32,
|
||||
pub low_res_proxy_eps: bool,
|
||||
pub anti_aliased_bitmaps: bool,
|
||||
pub update_links: String,
|
||||
// Clipboard Handling
|
||||
pub copy_as_svg: bool,
|
||||
pub copy_as_pdf: bool,
|
||||
pub copy_aicb: bool,
|
||||
pub aicb_mode: String,
|
||||
pub paste_text_formatting: String,
|
||||
// Appearance of Black
|
||||
pub black_on_screen: String,
|
||||
pub black_output: String,
|
||||
// Devices
|
||||
pub touch_workspace: bool,
|
||||
pub touch_gestures: bool,
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
fn default() -> Self {
|
||||
Self { keyboard_increment: 1.0, scale_strokes: true, paste_offset: 10.0, corner_radius: 12.0 }
|
||||
let s = |v: &str| v.to_string();
|
||||
Self {
|
||||
keyboard_increment: 1.0,
|
||||
constrain_angle: 0.0,
|
||||
corner_radius: 12.0,
|
||||
disable_auto_add_delete: false,
|
||||
use_precise_cursors: false,
|
||||
show_tool_tips: true,
|
||||
anti_aliased_artwork: true,
|
||||
select_same_tint_percent: false,
|
||||
show_home_screen: true,
|
||||
use_preview_bounds: false,
|
||||
display_print_size: false,
|
||||
double_click_to_isolate: true,
|
||||
transform_pattern_tiles: false,
|
||||
scale_corners: false,
|
||||
scale_strokes: true,
|
||||
zoom_with_mouse_wheel: false,
|
||||
paste_offset: 10.0,
|
||||
selection_tolerance: 3.0,
|
||||
object_selection_by_path_only: false,
|
||||
snap_to_point_tolerance: 2.0,
|
||||
ctrl_click_selects_behind: true,
|
||||
zoom_to_selection: true,
|
||||
move_locked_with_artboard: false,
|
||||
anchor_size: 3,
|
||||
handle_style: s("solid"),
|
||||
highlight_anchors_on_hover: true,
|
||||
show_handles_multiple_anchors: true,
|
||||
hide_corner_widget_above: 177.0,
|
||||
pen_rubber_band: true,
|
||||
curvature_rubber_band: true,
|
||||
type_size_increment: 2.0,
|
||||
tracking_increment: 20.0,
|
||||
baseline_shift_increment: 2.0,
|
||||
show_east_asian_options: false,
|
||||
show_indic_options: false,
|
||||
type_selection_by_path_only: false,
|
||||
font_names_in_english: true,
|
||||
auto_size_area_type: false,
|
||||
font_preview: true,
|
||||
font_preview_size: s("medium"),
|
||||
recent_fonts_count: 10,
|
||||
missing_glyph_protection: true,
|
||||
highlight_alternate_glyphs: true,
|
||||
placeholder_text: true,
|
||||
units_general: s("points"),
|
||||
units_stroke: s("points"),
|
||||
units_type: s("points"),
|
||||
units_asian_type: s("points"),
|
||||
numbers_without_units_are_points: true,
|
||||
identify_objects_by: s("objectName"),
|
||||
guide_color: s("#4affff"),
|
||||
guide_style: s("lines"),
|
||||
grid_color: s("#c8c8c8"),
|
||||
grid_style: s("lines"),
|
||||
gridline_every: 72.0,
|
||||
grid_subdivisions: 8,
|
||||
grids_in_back: true,
|
||||
show_pixel_grid: true,
|
||||
smart_guide_color: s("#ff4af0"),
|
||||
alignment_guides: true,
|
||||
object_highlighting: true,
|
||||
transform_tools_guides: true,
|
||||
construction_guides: true,
|
||||
construction_angles: s("90° & 45° Angles"),
|
||||
anchor_path_labels: true,
|
||||
measurement_labels: true,
|
||||
spacing_guides: true,
|
||||
snapping_tolerance: 4.0,
|
||||
show_slice_numbers: true,
|
||||
slice_line_color: s("#ff3f3f"),
|
||||
hyphenation_language: s("English: USA"),
|
||||
hyphenation_exceptions: String::new(),
|
||||
plugins_folder: String::new(),
|
||||
scratch_primary: s("Startup"),
|
||||
scratch_secondary: s("None"),
|
||||
ui_brightness: s("mediumDark"),
|
||||
canvas_color: s("matchUi"),
|
||||
auto_collapse_icon_panels: false,
|
||||
open_documents_as_tabs: true,
|
||||
large_tabs: false,
|
||||
ui_scaling: 1.0,
|
||||
scale_cursor_with_ui: false,
|
||||
gpu_performance: true,
|
||||
animated_zoom: true,
|
||||
history_states: 500,
|
||||
real_time_drawing: true,
|
||||
render_threads: -1,
|
||||
background_save: true,
|
||||
background_export: true,
|
||||
autosave_recovery: true,
|
||||
autosave_interval: 2,
|
||||
recovery_folder: String::new(),
|
||||
recovery_off_for_complex: false,
|
||||
recent_files_count: 20,
|
||||
low_res_proxy_eps: false,
|
||||
anti_aliased_bitmaps: false,
|
||||
update_links: s("askWhenModified"),
|
||||
copy_as_svg: true,
|
||||
copy_as_pdf: false,
|
||||
copy_aicb: false,
|
||||
aicb_mode: s("preserveAppearance"),
|
||||
paste_text_formatting: s("keep"),
|
||||
black_on_screen: s("accurate"),
|
||||
black_output: s("accurate"),
|
||||
touch_workspace: true,
|
||||
touch_gestures: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -240,8 +468,23 @@ impl Session {
|
|||
pub fn doc_mut(&mut self) -> Result<&mut DocState> {
|
||||
self.active_mut().ok_or(EngineError::NoDocument)
|
||||
}
|
||||
/// Finish the tool's pending work in the current document and give it fresh state, so nothing
|
||||
/// (e.g. uncommitted typing) leaks into the document we are switching to.
|
||||
fn reset_tool_for_doc_switch(&mut self) {
|
||||
if self.active.is_none() {
|
||||
return;
|
||||
}
|
||||
let view = self.last_view;
|
||||
let acts = self.with_tool_cx(view, |t, cx| t.deactivate(cx));
|
||||
let _ = self.apply_actions(acts);
|
||||
let _ = self.cancel_interaction();
|
||||
self.tool = drawcraft_tools::create(self.tool.id());
|
||||
}
|
||||
pub fn set_active(&mut self, index: usize) -> bool {
|
||||
if index < self.docs.len() {
|
||||
if self.active != Some(index) {
|
||||
self.reset_tool_for_doc_switch();
|
||||
}
|
||||
self.active = Some(index);
|
||||
true
|
||||
} else {
|
||||
|
|
@ -254,7 +497,10 @@ impl Session {
|
|||
}
|
||||
/// Add a document and make it active.
|
||||
pub fn add_document(&mut self, doc: Document, path: Option<String>) -> usize {
|
||||
self.docs.push(DocState::new(doc, path));
|
||||
self.reset_tool_for_doc_switch();
|
||||
let mut st = DocState::new(doc, path);
|
||||
st.history.limit = self.prefs.history_states as usize;
|
||||
self.docs.push(st);
|
||||
let i = self.docs.len() - 1;
|
||||
self.active = Some(i);
|
||||
i
|
||||
|
|
@ -263,6 +509,7 @@ impl Session {
|
|||
if index >= self.docs.len() {
|
||||
return false;
|
||||
}
|
||||
self.reset_tool_for_doc_switch();
|
||||
self.docs.remove(index);
|
||||
self.active = if self.docs.is_empty() { None } else { Some(index.min(self.docs.len() - 1)) };
|
||||
true
|
||||
|
|
@ -423,6 +670,8 @@ mod tests_menucmds;
|
|||
#[cfg(test)]
|
||||
mod tests_panelcmds;
|
||||
#[cfg(test)]
|
||||
mod tests_prefs;
|
||||
#[cfg(test)]
|
||||
mod tests_pathops;
|
||||
#[cfg(test)]
|
||||
mod tests_textedit;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,13 +39,13 @@ impl Session {
|
|||
if self.tool.id() == id {
|
||||
return Ok(());
|
||||
}
|
||||
let acts = self.with_cx(view, |t, cx| t.deactivate(cx));
|
||||
let acts = self.with_tool_cx(view, |t, cx| t.deactivate(cx));
|
||||
self.apply_actions(acts)?;
|
||||
self.tool = drawcraft_tools::create(id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn with_cx<R>(&mut self, view: ViewInfo, f: impl FnOnce(&mut dyn drawcraft_tools::Tool, &ToolContext) -> R) -> R
|
||||
pub(crate) fn with_tool_cx<R>(&mut self, view: ViewInfo, f: impl FnOnce(&mut dyn drawcraft_tools::Tool, &ToolContext) -> R) -> R
|
||||
where
|
||||
R: Default,
|
||||
{
|
||||
|
|
@ -67,18 +67,18 @@ impl Session {
|
|||
/// Feed a pointer event to the active tool. Returns requests for the UI.
|
||||
pub fn pointer(&mut self, ev: &PointerEvent, view: ViewInfo) -> Result<Vec<UiRequest>> {
|
||||
self.last_view = view;
|
||||
let acts = self.with_cx(view, |t, cx| t.pointer(cx, ev));
|
||||
let acts = self.with_tool_cx(view, |t, cx| t.pointer(cx, ev));
|
||||
self.apply_actions(acts)
|
||||
}
|
||||
|
||||
pub fn tool_key(&mut self, key: ToolKey, mods: Mods, view: ViewInfo) -> Result<Vec<UiRequest>> {
|
||||
let acts = self.with_cx(view, |t, cx| t.key(cx, key, mods));
|
||||
let acts = self.with_tool_cx(view, |t, cx| t.key(cx, key, mods));
|
||||
self.apply_actions(acts)
|
||||
}
|
||||
|
||||
/// Typed text for the active tool (Type tool).
|
||||
pub fn tool_text(&mut self, text: &str, view: ViewInfo) -> Result<Vec<UiRequest>> {
|
||||
let acts = self.with_cx(view, |t, cx| t.text_input(cx, text));
|
||||
let acts = self.with_tool_cx(view, |t, cx| t.text_input(cx, text));
|
||||
self.apply_actions(acts)
|
||||
}
|
||||
|
||||
|
|
@ -91,11 +91,11 @@ impl Session {
|
|||
}
|
||||
|
||||
pub fn overlays(&mut self, view: ViewInfo) -> Vec<Overlay> {
|
||||
self.with_cx(view, |t, cx| t.overlays(cx))
|
||||
self.with_tool_cx(view, |t, cx| t.overlays(cx))
|
||||
}
|
||||
|
||||
pub fn cursor(&mut self, p: Point, mods: Mods, view: ViewInfo) -> Cursor {
|
||||
self.with_cx(view, |t, cx| t.cursor(cx, p, mods))
|
||||
self.with_tool_cx(view, |t, cx| t.cursor(cx, p, mods))
|
||||
}
|
||||
|
||||
pub fn tool_options(&self) -> Value {
|
||||
|
|
@ -125,7 +125,7 @@ impl Session {
|
|||
Action::SwitchTool(t) => ui.push(UiRequest::SwitchTool(t)),
|
||||
Action::Notify(what) => {
|
||||
let view = self.last_view;
|
||||
self.with_cx(view, |t, cx| t.notify(cx, &what));
|
||||
self.with_tool_cx(view, |t, cx| t.notify(cx, &what));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ mod brush_fx;
|
|||
mod fx;
|
||||
mod live;
|
||||
mod paint;
|
||||
pub mod proof;
|
||||
mod pattern;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
|
@ -22,6 +24,7 @@ use vello_cpu::{Pixmap, RenderContext, Resources};
|
|||
|
||||
pub use drawcraft_effects as effects;
|
||||
pub use live::expand_live;
|
||||
pub use pattern::render_pattern_swatch;
|
||||
pub use vello_cpu;
|
||||
|
||||
/// Rendering options.
|
||||
|
|
@ -37,11 +40,15 @@ pub struct RenderOptions {
|
|||
pub hidden: Vec<NodeId>,
|
||||
/// Draw template layers dimmed (50%).
|
||||
pub dim_templates: bool,
|
||||
/// Soft proof / separations preview (see [`proof`]).
|
||||
pub proof: Option<proof::ProofSetup>,
|
||||
/// Overprint Preview (see [`proof`] for what overprints).
|
||||
pub overprint_preview: bool,
|
||||
}
|
||||
|
||||
impl Default for RenderOptions {
|
||||
fn default() -> Self {
|
||||
Self { outline: false, background: None, artboards: false, hidden: vec![], dim_templates: true }
|
||||
Self { outline: false, background: None, artboards: false, hidden: vec![], dim_templates: true, proof: None, overprint_preview: false }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -185,6 +192,8 @@ impl Renderer {
|
|||
/// Render `doc` into a `width`×`height` image using `view` (document → pixel transform).
|
||||
pub fn render(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions) -> Rendered {
|
||||
let start = now();
|
||||
let prepared = proof::prepare(doc, opts);
|
||||
let doc: &Document = &prepared;
|
||||
let w = width.clamp(1, u16::MAX as u32) as u16;
|
||||
let h = height.clamp(1, u16::MAX as u32) as u16;
|
||||
// Raster filters (drop shadow, glows, blur) need the single-threaded pipeline.
|
||||
|
|
@ -222,8 +231,10 @@ impl Renderer {
|
|||
}
|
||||
}
|
||||
self.stamp += 1;
|
||||
for layer in &doc.layers {
|
||||
self.draw_arc(&mut ctx, &frame, layer);
|
||||
if !self.draw_pattern_edit(&mut ctx, &frame) {
|
||||
for layer in &doc.layers {
|
||||
self.draw_arc(&mut ctx, &frame, layer);
|
||||
}
|
||||
}
|
||||
// Drop cache entries not seen for a few frames.
|
||||
let g = self.stamp;
|
||||
|
|
@ -238,8 +249,10 @@ impl Renderer {
|
|||
} else {
|
||||
self.ctx = Some(ctx);
|
||||
}
|
||||
let mut pixels = pm.data_as_u8_slice().to_vec();
|
||||
proof::post(&mut pixels, opts);
|
||||
self.stats.micros = now().saturating_sub(start);
|
||||
Rendered { width: w as u32, height: h as u32, pixels: pm.data_as_u8_slice().to_vec() }
|
||||
Rendered { width: w as u32, height: h as u32, pixels }
|
||||
}
|
||||
|
||||
/// Render one artboard (or any document rect) at `scale` pixels per point, transparent or on white.
|
||||
|
|
@ -433,7 +446,7 @@ impl Renderer {
|
|||
self.draw_node(ctx, f, &art, true);
|
||||
}
|
||||
}
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) => self.draw_live_node(ctx, f, n),
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => self.draw_live_node(ctx, f, n),
|
||||
}
|
||||
self.stats.drawn += 1;
|
||||
for _ in 0..layers {
|
||||
|
|
@ -686,14 +699,26 @@ fn painted_items(n: &Node) -> usize {
|
|||
.max(if n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if f.visible && !matches!(f.paint, drawcraft_color::Paint::Solid { .. } | drawcraft_color::Paint::None)) || matches!(i, AppearanceItem::Stroke(s) if s.visible && !matches!(s.paint, drawcraft_color::Paint::Solid { .. } | drawcraft_color::Paint::None))) { 2 } else { 0 })
|
||||
}
|
||||
|
||||
/// Preferred rasterizer thread count set by the app (Preferences → Performance); negative = automatic.
|
||||
static THREADS_OVERRIDE: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1);
|
||||
|
||||
/// Override the worker-thread count used by renderers created from now on (`None` = automatic).
|
||||
pub fn set_default_threads(n: Option<u16>) {
|
||||
THREADS_OVERRIDE.store(n.map_or(-1, i32::from), std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Rasterizer worker threads: 0 on wasm; otherwise up to 4 (vello's sweet spot), leaving a core free.
|
||||
fn default_threads() -> u16 {
|
||||
pub fn default_threads() -> u16 {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
0
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let o = THREADS_OVERRIDE.load(std::sync::atomic::Ordering::Relaxed);
|
||||
if o >= 0 {
|
||||
return o.min(64) as u16;
|
||||
}
|
||||
if std::env::var_os("DRAWCRAFT_RENDER_THREADS").is_some() {
|
||||
return std::env::var("DRAWCRAFT_RENDER_THREADS").ok().and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ impl Renderer {
|
|||
pub(crate) fn draw_live_body(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc<Node>, cache: bool) {
|
||||
match &a.kind {
|
||||
NodeKind::Mesh(m) => self.draw_mesh(ctx, f, a, m, cache),
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } => {
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) => {
|
||||
let items = self.live_expanded(a, cache);
|
||||
for c in items.iter() {
|
||||
self.draw_arc(ctx, f, c);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,342 @@
|
|||
//! Pattern fills and the pattern-editing preview.
|
||||
//!
|
||||
//! A pattern paint is drawn as a repeating image: the tiling's rectangular super-tile
|
||||
//! ([`PatternDef::period`]) is rasterized once at the current device scale (rounded to a power of
|
||||
//! two, so it is re-rasterized only when the zoom changes by more than 2×) and used as an image
|
||||
//! paint with `Extend::Repeat`, with the paint transform mapping pixels → pattern space →
|
||||
//! document. Tiles are cached per pattern name and invalidated when the definition changes (art
|
||||
//! `Arc` identity, tile, tile type, overlap) — the pattern's "revision".
|
||||
//!
|
||||
//! Pattern editing mode (`Document::pattern_edit`) draws only the temporary tile layer, with
|
||||
//! dimmed copies of it at the neighbouring tile positions and the tile edge.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use drawcraft_doc::pattern::{Overlap, PatternDef, TileType};
|
||||
use drawcraft_doc::{Document, Node};
|
||||
use drawcraft_geom::{Affine, Rect, Shape};
|
||||
use vello_cpu::peniko::{self, Extend, ImageQuality, ImageSampler};
|
||||
use vello_cpu::{Pixmap, RenderContext};
|
||||
|
||||
use crate::{Frame, RenderOptions, Rendered, Renderer};
|
||||
|
||||
/// Largest super-tile raster side (pixels).
|
||||
const MAX_TILE_PX: f64 = 2048.0;
|
||||
|
||||
struct TileEntry {
|
||||
art: Vec<Arc<Node>>,
|
||||
params: (Rect, TileType, Overlap),
|
||||
scale: f64,
|
||||
sx: f64,
|
||||
sy: f64,
|
||||
pixmap: Arc<Pixmap>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct PatternCache {
|
||||
tiles: HashMap<String, TileEntry>,
|
||||
renderer: Option<Box<Renderer>>,
|
||||
depth: u32,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static CACHE: RefCell<PatternCache> = RefCell::new(PatternCache::default());
|
||||
}
|
||||
|
||||
fn same_art(a: &[Arc<Node>], b: &[Arc<Node>]) -> bool {
|
||||
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| Arc::ptr_eq(x, y))
|
||||
}
|
||||
|
||||
/// Power-of-two raster scale for a pattern seen at device scale `s`, capped so the super-tile
|
||||
/// stays under [`MAX_TILE_PX`].
|
||||
pub(crate) fn raster_scale(s: f64, period: (f64, f64)) -> f64 {
|
||||
let s = s.clamp(1e-3, 1e3);
|
||||
let p2 = 2f64.powf(s.log2().ceil());
|
||||
let cap = MAX_TILE_PX / period.0.max(period.1).max(1e-6);
|
||||
p2.min(cap).max(1e-3)
|
||||
}
|
||||
|
||||
/// Rasterize `region` (pattern space) of the infinite tiling into a `w`×`h` pixmap.
|
||||
fn rasterize(r: &mut Renderer, doc: &Document, def: &PatternDef, region: Rect, w: u16, h: u16) -> Pixmap {
|
||||
let (w, h) = (w.max(1), h.max(1));
|
||||
let mut ctx = RenderContext::new(w, h);
|
||||
let view = Affine::scale_non_uniform(w as f64 / region.width().max(1e-9), h as f64 / region.height().max(1e-9))
|
||||
* Affine::translate(-region.origin().to_vec2());
|
||||
let opts = RenderOptions::default();
|
||||
r.stamp += 1;
|
||||
let px_rect = Rect::new(0.0, 0.0, w as f64, h as f64);
|
||||
for o in def.offsets_covering(region) {
|
||||
let v = view * def.instance_xf(o);
|
||||
let frame = Frame { doc, view: v, visible: v.inverse().transform_rect_bbox(px_rect), px: 1.0 / v.determinant().abs().sqrt().max(1e-12), opts: &opts };
|
||||
for a in &def.art {
|
||||
r.draw_arc(&mut ctx, &frame, a);
|
||||
}
|
||||
}
|
||||
ctx.flush();
|
||||
let mut pm = Pixmap::new(w, h);
|
||||
ctx.render(&mut pm, &mut r.resources);
|
||||
pm
|
||||
}
|
||||
|
||||
/// Run `f` with the shared off-screen renderer (None when nested too deep: a pattern whose art
|
||||
/// uses a pattern whose art uses a pattern…).
|
||||
fn with_tile_renderer<T>(f: impl FnOnce(&mut Renderer) -> T) -> Option<T> {
|
||||
let r = CACHE.with(|c| {
|
||||
let mut c = c.borrow_mut();
|
||||
if c.depth >= 3 {
|
||||
return None;
|
||||
}
|
||||
c.depth += 1;
|
||||
Some(c.renderer.take().unwrap_or_else(|| {
|
||||
let mut r = Renderer::new();
|
||||
r.threads = 0;
|
||||
Box::new(r)
|
||||
}))
|
||||
})?;
|
||||
let mut r = r;
|
||||
let out = f(&mut r);
|
||||
CACHE.with(|c| {
|
||||
let mut c = c.borrow_mut();
|
||||
c.depth -= 1;
|
||||
if c.renderer.is_none() {
|
||||
c.renderer = Some(r);
|
||||
}
|
||||
});
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Set a pattern paint on `ctx` (its current transform maps the paint's user space to pixels).
|
||||
pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine, doc: &Document) -> bool {
|
||||
let Some(def) = doc.pattern(name) else {
|
||||
ctx.set_paint(peniko::Color::from_rgba8(128, 128, 128, 255));
|
||||
return true;
|
||||
};
|
||||
if def.art.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let dev = *ctx.transform() * xf;
|
||||
let s = dev.determinant().abs().sqrt();
|
||||
let (pw, ph) = def.period();
|
||||
let scale = raster_scale(s, (pw, ph));
|
||||
let params = (def.tile, def.tile_type, def.overlap);
|
||||
let hit = CACHE.with(|c| {
|
||||
c.borrow().tiles.get(name).filter(|e| e.scale == scale && e.params == params && same_art(&e.art, &def.art)).map(|e| (e.pixmap.clone(), e.sx, e.sy))
|
||||
});
|
||||
let (pm, sx, sy) = match hit {
|
||||
Some(h) => h,
|
||||
None => {
|
||||
let w = (pw * scale).ceil().clamp(1.0, 4096.0) as u16;
|
||||
let h = (ph * scale).ceil().clamp(1.0, 4096.0) as u16;
|
||||
let Some(pm) = with_tile_renderer(|r| rasterize(r, doc, def, Rect::new(0.0, 0.0, pw, ph), w, h)) else {
|
||||
ctx.set_paint(peniko::Color::from_rgba8(128, 128, 128, 255));
|
||||
return true;
|
||||
};
|
||||
let pm = Arc::new(pm);
|
||||
let (sx, sy) = (w as f64 / pw, h as f64 / ph);
|
||||
CACHE.with(|c| {
|
||||
let mut c = c.borrow_mut();
|
||||
if c.tiles.len() > 64 {
|
||||
c.tiles.clear();
|
||||
}
|
||||
c.tiles.insert(name.to_string(), TileEntry { art: def.art.clone(), params, scale, sx, sy, pixmap: pm.clone() });
|
||||
});
|
||||
(pm, sx, sy)
|
||||
}
|
||||
};
|
||||
let sampler = ImageSampler { x_extend: Extend::Repeat, y_extend: Extend::Repeat, quality: ImageQuality::Medium, alpha: 1.0 };
|
||||
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler });
|
||||
ctx.set_paint_transform(xf * Affine::scale_non_uniform(1.0 / sx, 1.0 / sy));
|
||||
true
|
||||
}
|
||||
|
||||
/// A swatch thumbnail of pattern `name`: the tiling around the tile, fitted into `size`².
|
||||
pub fn render_pattern_swatch(doc: &Document, name: &str, size: u32) -> Option<Rendered> {
|
||||
let def = doc.pattern(name)?;
|
||||
let (w, h) = (def.width(), def.height());
|
||||
let side = w.max(h);
|
||||
let region = Rect::from_center_size((w / 2.0, h / 2.0), (side, side));
|
||||
let px = size.clamp(1, 1024) as u16;
|
||||
let pm = with_tile_renderer(|r| rasterize(r, doc, def, region, px, px))?;
|
||||
Some(Rendered { width: px as u32, height: px as u32, pixels: pm.data_as_u8_slice().to_vec() })
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
/// Pattern editing mode: draw the tile layer, its dimmed copies and the tile edge instead of
|
||||
/// the document. Returns false when not in pattern editing mode.
|
||||
pub(crate) fn draw_pattern_edit(&mut self, ctx: &mut RenderContext, f: &Frame) -> bool {
|
||||
let Some(pe) = &f.doc.pattern_edit else { return false };
|
||||
let Some(layer) = f.doc.layers.iter().find(|l| l.id == pe.layer) else { return false };
|
||||
let Some(def) = f.doc.pattern(&pe.pattern) else { return false };
|
||||
let children: Vec<Arc<Node>> = layer.children().cloned().unwrap_or_default();
|
||||
if layer.visible && !children.is_empty() {
|
||||
let dim = (def.dim_copies / 100.0).clamp(0.0, 1.0);
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.push_layer(None, None, Some(dim), None, None);
|
||||
for o in def.preview_offsets() {
|
||||
let view = f.view * Affine::translate(o);
|
||||
let visible = f.visible - o;
|
||||
let frame = Frame { doc: f.doc, view, visible, px: f.px, opts: f.opts };
|
||||
for c in &children {
|
||||
self.draw_arc(ctx, &frame, c);
|
||||
}
|
||||
}
|
||||
ctx.pop_layer();
|
||||
}
|
||||
self.draw_arc(ctx, f, layer);
|
||||
if def.show_tile_edge {
|
||||
let bp = def.tile.to_path(0.1);
|
||||
self.hairline(ctx, f, &bp, [0x2d, 0x8c, 0xeb, 255]);
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use drawcraft_color::{Color, Paint};
|
||||
use drawcraft_doc::pattern::{PatternEdit, RepeatSpec, pattern_paint};
|
||||
use drawcraft_doc::{Appearance, NodeId, NodeKind};
|
||||
use drawcraft_geom::shapes;
|
||||
|
||||
/// A 20×20 tile with a red 10×10 square in its top-left corner.
|
||||
fn doc_with_pattern(tt: TileType) -> Document {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let red = Node::path(NodeId(900), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0));
|
||||
let mut def = PatternDef::new("Checks", vec![Arc::new(red)]);
|
||||
def.tile = Rect::new(0.0, 0.0, 20.0, 20.0);
|
||||
def.tile_type = tt;
|
||||
d.patterns.push(def);
|
||||
let id = d.alloc_id();
|
||||
let n = Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0));
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn render(d: &Document, scale: f64) -> Rendered {
|
||||
let px = (200.0 * scale) as u32;
|
||||
Renderer::new().render(d, px, px, Affine::scale(scale), &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
|
||||
}
|
||||
|
||||
fn is_red(p: [u8; 4]) -> bool {
|
||||
p[0] > 200 && p[1] < 60 && p[2] < 60
|
||||
}
|
||||
fn is_white(p: [u8; 4]) -> bool {
|
||||
p[0] > 200 && p[1] > 200 && p[2] > 200
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_fill_is_periodic() {
|
||||
let d = doc_with_pattern(TileType::Grid);
|
||||
let r = render(&d, 1.0);
|
||||
for (x, y) in [(5, 5), (25, 5), (125, 145), (185, 65)] {
|
||||
assert!(is_red(r.pixel(x, y)), "({x},{y}) {:?}", r.pixel(x, y));
|
||||
}
|
||||
for (x, y) in [(15, 5), (5, 15), (135, 135), (175, 195)] {
|
||||
assert!(is_white(r.pixel(x, y)), "({x},{y}) {:?}", r.pixel(x, y));
|
||||
}
|
||||
// Periodic: pixel (x, y) equals (x + 20k, y + 20m).
|
||||
for y in (1..20).step_by(3) {
|
||||
for x in (1..20).step_by(3) {
|
||||
if x == 10 || y == 10 {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(r.pixel(x, y), r.pixel(x + 60, y + 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_fill_is_clipped_to_shape() {
|
||||
let mut d = doc_with_pattern(TileType::Grid);
|
||||
let id = d.layers[0].children().unwrap()[0].id;
|
||||
*d.node_mut(id).unwrap().path_data_mut().unwrap() = shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0));
|
||||
let r = render(&d, 1.0);
|
||||
assert!(is_red(r.pixel(45, 45)));
|
||||
assert!(is_white(r.pixel(65, 65)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brick_pattern_shifts_rows() {
|
||||
let d = doc_with_pattern(TileType::BrickByRow { offset: 0.5 });
|
||||
let r = render(&d, 1.0);
|
||||
assert!(is_red(r.pixel(5, 5)));
|
||||
// Row 1 is shifted by half a tile.
|
||||
assert!(is_white(r.pixel(5, 25)) && is_red(r.pixel(15, 25)), "{:?} {:?}", r.pixel(5, 25), r.pixel(15, 25));
|
||||
assert!(is_red(r.pixel(5, 45)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_follows_zoom_and_paint_transform() {
|
||||
let d = doc_with_pattern(TileType::Grid);
|
||||
let r = render(&d, 2.0);
|
||||
assert!(is_red(r.pixel(10, 10)) && is_white(r.pixel(30, 10)) && is_red(r.pixel(50, 10)));
|
||||
let mut d2 = d.clone();
|
||||
let id = d2.layers[0].children().unwrap()[0].id;
|
||||
drawcraft_doc::pattern::transform_pattern_paints(d2.node_mut(id).unwrap(), Affine::translate((10.0, 0.0)));
|
||||
let r = render(&d2, 1.0);
|
||||
assert!(is_white(r.pixel(5, 5)) && is_red(r.pixel(15, 5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_pattern_is_grey() {
|
||||
let mut d = doc_with_pattern(TileType::Grid);
|
||||
d.patterns.clear();
|
||||
let p = render(&d, 1.0).pixel(50, 50);
|
||||
assert_eq!(p, [128, 128, 128, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swatch_thumbnail_renders() {
|
||||
let d = doc_with_pattern(TileType::Grid);
|
||||
let r = render_pattern_swatch(&d, "Checks", 20).unwrap();
|
||||
assert!(is_red(r.pixel(3, 3)));
|
||||
assert_eq!(r.pixel(15, 15)[3], 0);
|
||||
assert!(render_pattern_swatch(&d, "Nope", 20).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_mode_draws_only_tile_layer_with_copies() {
|
||||
let mut d = doc_with_pattern(TileType::Grid);
|
||||
let l = d.add_layer(Some("Pattern Editing Mode"));
|
||||
let art = d.patterns[0].art[0].clone();
|
||||
d.insert(Some(l), 0, (*art).clone()).unwrap();
|
||||
d.pattern_edit = Some(PatternEdit { pattern: "Checks".into(), layer: l, original: None });
|
||||
d.patterns[0].dim_copies = 50.0;
|
||||
d.patterns[0].show_tile_edge = false;
|
||||
let r = render(&d, 1.0);
|
||||
// The tile itself at full strength, copies dimmed, the filled document rect hidden.
|
||||
assert!(is_red(r.pixel(5, 5)));
|
||||
let c = r.pixel(25, 5);
|
||||
assert!(c[0] > 200 && c[1] > 100 && c[1] < 160, "{c:?}");
|
||||
assert!(is_white(r.pixel(15, 5)));
|
||||
assert!(is_white(r.pixel(125, 125)), "outside the copies");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeat_renders_instances() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let sq = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
|
||||
let id = d.alloc_id();
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, Node::new(id, NodeKind::Repeat(RepeatSpec::grid(vec![Arc::new(sq)], 10.0, 10.0)))).unwrap();
|
||||
let r = render(&d, 1.0);
|
||||
for (x, y) in [(5, 5), (25, 5), (45, 45)] {
|
||||
assert_eq!(r.pixel(x, y), [0, 0, 0, 255], "({x},{y})");
|
||||
}
|
||||
assert!(is_white(r.pixel(15, 5)) && is_white(r.pixel(65, 5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raster_scale_is_power_of_two_and_capped() {
|
||||
assert_eq!(raster_scale(1.0, (10.0, 10.0)), 1.0);
|
||||
assert_eq!(raster_scale(1.5, (10.0, 10.0)), 2.0);
|
||||
assert_eq!(raster_scale(3.0, (10.0, 10.0)), 4.0);
|
||||
// A change within 2× keeps the raster.
|
||||
assert_eq!(raster_scale(2.1, (10.0, 10.0)), raster_scale(3.9, (10.0, 10.0)));
|
||||
assert!(raster_scale(64.0, (1000.0, 10.0)) * 1000.0 <= MAX_TILE_PX);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,292 @@
|
|||
//! Soft proofing (View → Proof Colors), Overprint Preview and Separations Preview.
|
||||
//!
|
||||
//! * **Proof Colors** post-processes the rendered frame: every pixel goes display sRGB → proof
|
||||
//! space (CMYK press or RGB/colour-blindness simulation) → display, through a cached 17³ LUT.
|
||||
//! * **Separations Preview** recolours the *document* before rendering: each paint colour is split
|
||||
//! into process inks (colour-managed) plus a spot ink when it's linked to a spot swatch. With one
|
||||
//! plate visible the plate renders as greyscale ink coverage (black = 100%); with several, the
|
||||
//! visible inks are composited through the proof profile, spots multiplied on top.
|
||||
//! Placed raster images are not separated (limitation).
|
||||
//! * **Overprint Preview:** the document model has no per-object overprint attribute, so
|
||||
//! overprinting is limited to objects listed in `Document.unknown["overprintBlack"]` (set by
|
||||
//! Edit → Edit Colors → Overprint Black) whose fill or stroke is 100% K. Such objects are drawn
|
||||
//! with Multiply, which is what overprinting black ink does to the inks below.
|
||||
//!
|
||||
//! The app's current view state lives in [`view`] / [`set_view`]; the canvas copies it into
|
||||
//! [`crate::RenderOptions`] with [`active_proof`] and [`overprint_preview_on`].
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use drawcraft_color::cms::{self, Cms, PROCESS_PLATES};
|
||||
pub use drawcraft_color::cms::{Intent, ProofSetup, ProofTarget};
|
||||
use drawcraft_color::{BlendMode, Color, Paint};
|
||||
use drawcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind};
|
||||
|
||||
use crate::RenderOptions;
|
||||
|
||||
/// `Document.unknown` key listing the ids of objects marked Overprint Black.
|
||||
pub const OVERPRINT_KEY: &str = "overprintBlack";
|
||||
|
||||
/// Ink coverage of one paint colour.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Inks {
|
||||
/// Process inks C, M, Y, K (0..1).
|
||||
pub cmyk: [f32; 4],
|
||||
/// Spot ink (swatch name, tint 0..1).
|
||||
pub spot: Option<(String, f32)>,
|
||||
}
|
||||
|
||||
/// A printing plate.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Plate {
|
||||
pub name: String,
|
||||
pub spot: bool,
|
||||
/// Display colour of the ink (for the panel swatch).
|
||||
pub rgb: [f32; 3],
|
||||
}
|
||||
|
||||
/// The plates of `doc`: the four process plates plus one per spot swatch.
|
||||
pub fn plates(doc: &Document) -> Vec<Plate> {
|
||||
let c = cms::active();
|
||||
let mut v: Vec<Plate> = PROCESS_PLATES
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| {
|
||||
let mut ink = [0.0; 4];
|
||||
ink[i] = 1.0;
|
||||
Plate { name: (*n).into(), spot: false, rgb: c.cmyk_to_srgb(ink, false) }
|
||||
})
|
||||
.collect();
|
||||
for sw in &doc.swatches {
|
||||
if let (true, Paint::Solid { color, .. }) = (sw.spot, &sw.paint)
|
||||
&& !v.iter().any(|p| p.name == sw.name)
|
||||
{
|
||||
v.push(Plate { name: sw.name.clone(), spot: true, rgb: c.display_rgb(color) });
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn spot_swatch<'a>(doc: &'a Document, name: &str) -> Option<&'a Color> {
|
||||
doc.swatches.iter().find(|s| s.spot && s.name == name).and_then(|s| match &s.paint {
|
||||
Paint::Solid { color, .. } => Some(color),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Separate one colour into inks. Colours linked to a spot swatch print on that plate only, with
|
||||
/// the tint given by the colour's ink total relative to the swatch's.
|
||||
pub fn inks(doc: &Document, c: &Cms, color: &Color, swatch: Option<&str>, intent: Intent) -> Inks {
|
||||
if let Some(name) = swatch
|
||||
&& let Some(sc) = spot_swatch(doc, name)
|
||||
{
|
||||
let full: f32 = c.to_cmyk(sc, intent).iter().sum();
|
||||
let this: f32 = c.to_cmyk(color, intent).iter().sum();
|
||||
let tint = if full <= 1e-4 { 1.0 } else { (this / full).clamp(0.0, 1.0) };
|
||||
return Inks { cmyk: [0.0; 4], spot: Some((name.to_string(), tint)) };
|
||||
}
|
||||
Inks { cmyk: c.to_cmyk(color, intent), spot: None }
|
||||
}
|
||||
|
||||
fn map_paint(p: &mut Paint, f: &mut dyn FnMut(&Color, Option<&str>) -> Color) {
|
||||
match p {
|
||||
Paint::Solid { color, swatch } => *color = f(color, swatch.as_deref()),
|
||||
Paint::Gradient(g) => {
|
||||
for s in &mut g.gradient.stops {
|
||||
s.color = f(&s.color, None);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply `f` to every colour of a node tree (fills, strokes, gradient stops, text runs, mesh
|
||||
/// points), keeping swatch links.
|
||||
pub fn map_node_colors(n: &mut Node, f: &mut dyn FnMut(&Color, Option<&str>) -> Color) {
|
||||
for it in &mut n.appearance.items {
|
||||
match it {
|
||||
AppearanceItem::Fill(l) => map_paint(&mut l.paint, f),
|
||||
AppearanceItem::Stroke(l) => map_paint(&mut l.paint, f),
|
||||
}
|
||||
}
|
||||
match &mut n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
for r in &mut t.runs {
|
||||
map_paint(&mut r.style.fill, f);
|
||||
map_paint(&mut r.style.stroke, f);
|
||||
}
|
||||
}
|
||||
NodeKind::Mesh(m) => {
|
||||
for p in &mut m.points {
|
||||
p.color = f(&p.color, None);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(ch) = n.children_mut() {
|
||||
for c in ch.iter_mut() {
|
||||
map_node_colors(Arc::make_mut(c), f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply `f` to every colour in the document's art and symbol definitions.
|
||||
pub fn map_document_colors(doc: &mut Document, f: &mut dyn FnMut(&Color, Option<&str>) -> Color) {
|
||||
for l in &mut doc.layers {
|
||||
map_node_colors(Arc::make_mut(l), f);
|
||||
}
|
||||
for s in &mut doc.symbols {
|
||||
map_node_colors(Arc::make_mut(&mut s.art), f);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ids marked Overprint Black.
|
||||
pub fn overprint_ids(doc: &Document) -> Vec<NodeId> {
|
||||
doc.unknown
|
||||
.get(OVERPRINT_KEY)
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.iter().filter_map(|v| v.as_u64()).map(NodeId).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 100% black ink with no other process ink.
|
||||
pub fn is_pure_black(c: &Color) -> bool {
|
||||
match *c {
|
||||
Color::Cmyk { c, m, y, k } => k >= 0.995 && c <= 0.005 && m <= 0.005 && y <= 0.005,
|
||||
Color::Gray { k } => k >= 0.995,
|
||||
Color::Rgb { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn has_pure_black(n: &Node) -> bool {
|
||||
n.appearance.items.iter().any(|it| match it {
|
||||
AppearanceItem::Fill(l) => l.paint.color().is_some_and(|c| is_pure_black(&c)),
|
||||
AppearanceItem::Stroke(l) => l.paint.color().is_some_and(|c| is_pure_black(&c)),
|
||||
})
|
||||
}
|
||||
|
||||
fn plate_color(doc: &Document, c: &Cms, proof: &ProofSetup, visible: &[String], color: &Color, swatch: Option<&str>) -> Color {
|
||||
let ink = inks(doc, c, color, swatch, proof.intent);
|
||||
let rgb = if visible.len() == 1 {
|
||||
let name = &visible[0];
|
||||
let v = match PROCESS_PLATES.iter().position(|p| p == name) {
|
||||
Some(i) => ink.cmyk[i],
|
||||
None => ink.spot.as_ref().filter(|(n, _)| n == name).map_or(0.0, |s| s.1),
|
||||
};
|
||||
[1.0 - v; 3]
|
||||
} else {
|
||||
let mut cmyk = ink.cmyk;
|
||||
for (i, p) in PROCESS_PLATES.iter().enumerate() {
|
||||
if !visible.iter().any(|v| v == p) {
|
||||
cmyk[i] = 0.0;
|
||||
}
|
||||
}
|
||||
let mut rgb = c.proof_cmyk_to_srgb(cmyk, proof);
|
||||
if let Some((name, t)) = &ink.spot
|
||||
&& visible.contains(name)
|
||||
&& let Some(sc) = spot_swatch(doc, name)
|
||||
{
|
||||
let s = c.display_rgb(sc);
|
||||
for i in 0..3 {
|
||||
rgb[i] *= 1.0 - t * (1.0 - s[i]);
|
||||
}
|
||||
}
|
||||
rgb
|
||||
};
|
||||
let [r, g, b] = c.srgb_to_rgb(rgb);
|
||||
Color::Rgb { r, g, b }
|
||||
}
|
||||
|
||||
/// The document as it should be drawn for these options (overprints, separations).
|
||||
pub(crate) fn prepare<'a>(doc: &'a Document, opts: &RenderOptions) -> Cow<'a, Document> {
|
||||
let seps = opts.proof.as_ref().and_then(|p| p.separations.as_ref().map(|s| (p, s)));
|
||||
let overprint = opts.overprint_preview || seps.is_some();
|
||||
let ids = if overprint { overprint_ids(doc) } else { vec![] };
|
||||
if ids.is_empty() && seps.is_none() {
|
||||
return Cow::Borrowed(doc);
|
||||
}
|
||||
let mut d = doc.clone();
|
||||
for id in ids {
|
||||
if let Some(n) = d.node_mut(id)
|
||||
&& n.blend == BlendMode::Normal
|
||||
&& has_pure_black(n)
|
||||
{
|
||||
n.blend = BlendMode::Multiply;
|
||||
}
|
||||
}
|
||||
if let Some((proof, visible)) = seps {
|
||||
let c = cms::active();
|
||||
let src = doc.clone();
|
||||
map_document_colors(&mut d, &mut |col, sw| plate_color(&src, &c, proof, visible, col, sw));
|
||||
}
|
||||
Cow::Owned(d)
|
||||
}
|
||||
|
||||
/// Soft-proof the rendered (premultiplied RGBA8) pixels in place.
|
||||
pub(crate) fn post(pixels: &mut [u8], opts: &RenderOptions) {
|
||||
let Some(proof) = opts.proof.as_ref() else { return };
|
||||
if proof.separations.is_some() || matches!(proof.target, ProofTarget::MonitorRgb | ProofTarget::Srgb) {
|
||||
return;
|
||||
}
|
||||
let lut = cms::active().proof_lut(proof);
|
||||
let mut last_in = [0u8; 4];
|
||||
let mut last_out = [0u8; 4];
|
||||
for px in pixels.chunks_exact_mut(4) {
|
||||
let a = px[3];
|
||||
if a == 0 {
|
||||
continue;
|
||||
}
|
||||
if *px == last_in {
|
||||
px.copy_from_slice(&last_out);
|
||||
continue;
|
||||
}
|
||||
last_in.copy_from_slice(px);
|
||||
let un = |c: u8| if a == 255 { c } else { ((c as u32 * 255 + a as u32 / 2) / a as u32).min(255) as u8 };
|
||||
let out = lut.apply8([un(px[0]), un(px[1]), un(px[2])]);
|
||||
for i in 0..3 {
|
||||
px[i] = if a == 255 { out[i] } else { ((out[i] as u32 * a as u32 + 127) / 255) as u8 };
|
||||
}
|
||||
last_out.copy_from_slice(px);
|
||||
}
|
||||
}
|
||||
|
||||
/// View-level proof state (View → Proof Setup / Proof Colors / Overprint Preview, Separations
|
||||
/// Preview panel). Process-wide: the app has one proof state for all windows.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ProofView {
|
||||
/// View → Proof Setup.
|
||||
pub setup: ProofSetup,
|
||||
/// View → Proof Colors.
|
||||
pub proof_colors: bool,
|
||||
/// View → Overprint Preview.
|
||||
pub overprint: bool,
|
||||
/// Separations Preview: `Some(visible plates)` when on.
|
||||
pub separations: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
static VIEW: RwLock<Option<ProofView>> = RwLock::new(None);
|
||||
|
||||
pub fn view() -> ProofView {
|
||||
VIEW.read().unwrap_or_else(|e| e.into_inner()).clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn set_view(v: ProofView) {
|
||||
*VIEW.write().unwrap_or_else(|e| e.into_inner()) = Some(v);
|
||||
}
|
||||
|
||||
/// The proof to pass in [`RenderOptions::proof`] for the current view state.
|
||||
pub fn active_proof() -> Option<ProofSetup> {
|
||||
let v = view();
|
||||
match v.separations {
|
||||
Some(s) => Some(ProofSetup { separations: Some(s), ..v.setup }),
|
||||
None if v.proof_colors => Some(ProofSetup { separations: None, ..v.setup }),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether [`RenderOptions::overprint_preview`] should be on (Separations Preview implies it).
|
||||
pub fn overprint_preview_on() -> bool {
|
||||
let v = view();
|
||||
v.overprint || v.separations.is_some()
|
||||
}
|
||||
|
|
@ -29,6 +29,8 @@ pub(crate) fn export(doc: &Document, opts: &ExportOptions) -> String {
|
|||
used_ids: HashSet::new(),
|
||||
names: HashMap::new(),
|
||||
depth: 1,
|
||||
patterns: HashMap::new(),
|
||||
pattern_nest: 0,
|
||||
};
|
||||
w.assign_name_ids();
|
||||
for l in &doc.layers {
|
||||
|
|
@ -83,6 +85,9 @@ struct Writer<'a> {
|
|||
used_ids: HashSet<String>,
|
||||
names: HashMap<NodeId, String>,
|
||||
depth: usize,
|
||||
/// `<pattern>` def ids by pattern name + placement.
|
||||
patterns: HashMap<String, String>,
|
||||
pattern_nest: u32,
|
||||
}
|
||||
|
||||
pub(crate) fn blend_css(b: BlendMode) -> &'static str {
|
||||
|
|
@ -258,12 +263,57 @@ impl Writer<'_> {
|
|||
|
||||
fn paint(&mut self, p: &Paint, bounds: Option<Rect>) -> String {
|
||||
match p {
|
||||
Paint::None | Paint::Pattern { .. } => "none".into(),
|
||||
Paint::None => "none".into(),
|
||||
Paint::Pattern { pattern, xf } => match self.pattern_def(pattern, *xf) {
|
||||
Some(id) => format!("url(#{id})"),
|
||||
None => "none".into(),
|
||||
},
|
||||
Paint::Solid { color, .. } => color.to_hex(),
|
||||
Paint::Gradient(g) => format!("url(#{})", self.gradient_def(g, bounds.unwrap_or(Rect::new(0.0, 0.0, 1.0, 1.0)))),
|
||||
}
|
||||
}
|
||||
|
||||
/// A `<pattern>` def for pattern `name` placed by `xf`: one period (super-tile) of the tiling,
|
||||
/// with every instance that reaches into it.
|
||||
fn pattern_def(&mut self, name: &str, xf: Affine) -> Option<String> {
|
||||
let doc = self.doc;
|
||||
let def = doc.pattern(name)?;
|
||||
let m = self.xf * xf;
|
||||
let key = format!("{name}|{:?}", m.as_coeffs());
|
||||
if let Some(id) = self.patterns.get(&key) {
|
||||
return Some(id.clone());
|
||||
}
|
||||
if self.pattern_nest > 4 {
|
||||
return None;
|
||||
}
|
||||
let id = self.fresh_id("pattern");
|
||||
self.patterns.insert(key, id.clone());
|
||||
let (pw, ph) = def.period();
|
||||
let (saved_body, saved_xf, saved_depth) = (std::mem::take(&mut self.body), self.xf, self.depth);
|
||||
self.depth = 3;
|
||||
self.pattern_nest += 1;
|
||||
for o in def.offsets_covering(Rect::new(0.0, 0.0, pw, ph)) {
|
||||
self.xf = def.instance_xf(o);
|
||||
for a in &def.art {
|
||||
self.node(a);
|
||||
}
|
||||
}
|
||||
self.pattern_nest -= 1;
|
||||
let content = std::mem::replace(&mut self.body, saved_body);
|
||||
self.xf = saved_xf;
|
||||
self.depth = saved_depth;
|
||||
let head = format!(
|
||||
"<pattern id=\"{id}\" patternUnits=\"userSpaceOnUse\" width=\"{}\" height=\"{}\" patternTransform=\"{}\">",
|
||||
self.num(pw),
|
||||
self.num(ph),
|
||||
self.matrix(m)
|
||||
);
|
||||
self.def(1, &head);
|
||||
self.defs.push_str(&content);
|
||||
self.def(1, "</pattern>");
|
||||
Some(id)
|
||||
}
|
||||
|
||||
fn gradient_def(&mut self, g: &GradientPaint, bounds: Rect) -> String {
|
||||
let mut geom = g.resolve(bounds);
|
||||
geom.transform(self.xf);
|
||||
|
|
@ -486,7 +536,7 @@ impl Writer<'_> {
|
|||
out.push((self.path_d(&p, self.xf), FillRule::NonZero));
|
||||
}
|
||||
NodeKind::Text(_) | NodeKind::SymbolInstance { .. } => {}
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) => {
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
|
||||
let g = drawcraft_doc::live::expand_deep(n, None);
|
||||
self.clip_shapes(&g, out);
|
||||
}
|
||||
|
|
@ -585,7 +635,7 @@ impl Writer<'_> {
|
|||
self.xf = saved;
|
||||
}
|
||||
// Live blends/envelopes/meshes export their evaluated (expanded) form.
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) => {
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
|
||||
let g = drawcraft_doc::live::expand_deep(n, None);
|
||||
self.node(&g);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,166 @@
|
|||
//! Distortion tools: Width (Shift+W), the Liquify family (Warp Shift+R, Twirl, Pucker, Bloat,
|
||||
//! Scallop, Crystallize, Wrinkle), Puppet Warp, Perspective Grid (Shift+P) and Perspective
|
||||
//! Selection (Shift+V).
|
||||
//!
|
||||
//! Like every tool they only emit actions. The geometry kernels the engine commands run live here
|
||||
//! too (so tools, engine and tests share one implementation):
|
||||
//! - [`liquify`]: deterministic brush deformation of anchors and handles with adaptive subdivision;
|
||||
//! - [`arap`]: As-Rigid-As-Possible mesh deformation (Igarashi, Moscovich & Hughes 2005) with a
|
||||
//! small banded Cholesky solver;
|
||||
//! - [`perspective`]: the perspective grid model (one/two/three-point) and plane homographies;
|
||||
//! - [`pathutil`]: arc-length helpers for width points.
|
||||
|
||||
pub mod arap;
|
||||
pub mod liquify;
|
||||
pub mod pathutil;
|
||||
pub mod perspective;
|
||||
mod puppet;
|
||||
mod width;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use drawcraft_color::Paint;
|
||||
use drawcraft_doc::{Appearance, AppearanceItem, Node, NodeKind};
|
||||
use drawcraft_geom::{Affine, Point, Vec2};
|
||||
|
||||
use crate::Tool;
|
||||
|
||||
pub use liquify::LiquifyTool;
|
||||
pub use perspective::{PerspectiveGridTool, PerspectiveSelectionTool};
|
||||
pub use puppet::{PuppetWarpTool, mesh_for, mesh_input};
|
||||
pub use width::WidthTool;
|
||||
|
||||
/// Create a distortion tool by id (None = not one of ours).
|
||||
pub fn create(id: &str) -> Option<Box<dyn Tool>> {
|
||||
Some(match id {
|
||||
"width" => Box::new(WidthTool::default()),
|
||||
"warp" | "twirl" | "pucker" | "bloat" | "scallop" | "crystallize" | "wrinkle" => Box::new(LiquifyTool::new(id)),
|
||||
"puppetWarp" => Box::new(PuppetWarpTool::default()),
|
||||
"perspectiveGrid" => Box::new(PerspectiveGridTool::default()),
|
||||
"perspectiveSelection" => Box::new(PerspectiveSelectionTool::default()),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply a point map to every anchor and handle of `n` and its descendants. Gradient vectors are
|
||||
/// mapped too; objects without editable points (text, images, symbols, meshes, live objects) get
|
||||
/// the map's affine approximation at their centre. Live shapes become plain paths.
|
||||
pub fn warp_node_with(n: &mut Node, f: &dyn Fn(Point) -> Point) {
|
||||
warp_paints(&mut n.appearance, f);
|
||||
match &mut n.kind {
|
||||
NodeKind::Path { path, live, .. } => {
|
||||
*live = None;
|
||||
for sp in &mut path.subpaths {
|
||||
for a in &mut sp.anchors {
|
||||
a.p = f(a.p);
|
||||
a.h_in = f(a.h_in);
|
||||
a.h_out = f(a.h_out);
|
||||
}
|
||||
}
|
||||
}
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } | NodeKind::Blend { children, .. } => {
|
||||
for c in children.iter_mut() {
|
||||
warp_node_with(Arc::make_mut(c), f);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(b) = n.geometric_bounds() {
|
||||
let a = affine_near(f, b.center(), (b.width().max(b.height()) * 0.05).max(0.5));
|
||||
n.transform(a, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn warp_paints(ap: &mut Appearance, f: &dyn Fn(Point) -> Point) {
|
||||
for it in &mut ap.items {
|
||||
let p = match it {
|
||||
AppearanceItem::Fill(fl) => &mut fl.paint,
|
||||
AppearanceItem::Stroke(s) => &mut s.paint,
|
||||
};
|
||||
if let Paint::Gradient(g) = p
|
||||
&& let Some(geom) = &mut g.geom
|
||||
{
|
||||
geom.start = f(geom.start);
|
||||
geom.end = f(geom.end);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The affine map that best matches `f` near `p` (finite differences with step `eps`).
|
||||
pub fn affine_near(f: &dyn Fn(Point) -> Point, p: Point, eps: f64) -> Affine {
|
||||
let o = f(p);
|
||||
let ex = (f(p + Vec2::new(eps, 0.0)) - f(p - Vec2::new(eps, 0.0))) / (2.0 * eps);
|
||||
let ey = (f(p + Vec2::new(0.0, eps)) - f(p - Vec2::new(0.0, eps))) / (2.0 * eps);
|
||||
let lin = Affine::new([ex.x, ex.y, ey.x, ey.y, 0.0, 0.0]);
|
||||
Affine::translate(o.to_vec2()) * lin * Affine::translate(-p.to_vec2())
|
||||
}
|
||||
|
||||
/// Every anchor and handle position in `n` (for meshes and bounds).
|
||||
pub fn collect_points(n: &Node, out: &mut Vec<Point>) {
|
||||
n.walk(&mut |c| match &c.kind {
|
||||
NodeKind::Path { path, .. } => {
|
||||
for sp in &path.subpaths {
|
||||
for a in &sp.anchors {
|
||||
out.extend([a.p, a.h_in, a.h_out]);
|
||||
}
|
||||
}
|
||||
}
|
||||
NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Compound { .. } | NodeKind::Blend { .. } => {}
|
||||
_ => {
|
||||
if let Some(b) = c.geometric_bounds() {
|
||||
out.extend([Point::new(b.x0, b.y0), Point::new(b.x1, b.y1)]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Selection blue used by the distortion tool overlays.
|
||||
pub(crate) const BLUE: [u8; 3] = [0x4a, 0x7c, 0xff];
|
||||
|
||||
/// A small diamond polygon at `p` (half-diagonal `r`).
|
||||
pub(crate) fn diamond(p: Point, r: f64) -> drawcraft_geom::BezPath {
|
||||
crate::xform::polygon(&[Point::new(p.x, p.y - r), Point::new(p.x + r, p.y), Point::new(p.x, p.y + r), Point::new(p.x - r, p.y)], true)
|
||||
}
|
||||
|
||||
/// A circle / ellipse outline for brush feedback.
|
||||
pub(crate) fn ellipse_path(c: Point, rx: f64, ry: f64, angle_deg: f64) -> drawcraft_geom::BezPath {
|
||||
use drawcraft_geom::Shape;
|
||||
let e = drawcraft_geom::kurbo::Ellipse::new(c, (rx.max(0.01), ry.max(0.01)), angle_deg.to_radians());
|
||||
e.to_path(0.05)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use drawcraft_geom::{PathData, Rect, shapes};
|
||||
|
||||
#[test]
|
||||
fn create_covers_all_distort_tools() {
|
||||
for id in ["width", "warp", "twirl", "pucker", "bloat", "scallop", "crystallize", "wrinkle", "puppetWarp", "perspectiveGrid", "perspectiveSelection"] {
|
||||
assert_eq!(create(id).map(|t| t.id()), Some(id));
|
||||
}
|
||||
assert!(create("pen").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warp_node_maps_all_points_and_drops_live_shape() {
|
||||
let mut n = Node::path(drawcraft_doc::NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
|
||||
warp_node_with(&mut n, &|p| Point::new(p.x * 2.0, p.y + 1.0));
|
||||
let b = n.geometric_bounds().unwrap();
|
||||
assert_eq!((b.x0, b.y0, b.x1, b.y1), (0.0, 1.0, 20.0, 11.0));
|
||||
if let NodeKind::Path { live, .. } = &n.kind {
|
||||
assert!(live.is_none());
|
||||
}
|
||||
let _ = PathData::default();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn affine_near_recovers_affine_maps() {
|
||||
let a = Affine::new([2.0, 0.5, -0.3, 1.5, 10.0, -4.0]);
|
||||
let got = affine_near(&|p| a * p, Point::new(3.0, 7.0), 0.5);
|
||||
for (x, y) in a.as_coeffs().iter().zip(got.as_coeffs()) {
|
||||
assert!((x - y).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,573 @@
|
|||
//! As-Rigid-As-Possible shape manipulation (Igarashi, Moscovich & Hughes, SIGGRAPH 2005),
|
||||
//! implemented from the paper.
|
||||
//!
|
||||
//! - The mesh is a regular grid of triangles over the artwork: grid cells are kept when their
|
||||
//! centre is inside the filled outline or near any path (so separate limbs stay separate).
|
||||
//! - Step 1 (similarity): every triangle vertex is expressed in the local frame of the opposite
|
||||
//! edge, `v2 = v0 + x·(v1 − v0) + y·R90(v1 − v0)`; the quadratic error of those relations over
|
||||
//! all triangles (rotation + uniform scale free) plus soft pin constraints is minimised.
|
||||
//! - Step 2 (scale adjustment): each triangle's rest shape is rigidly fitted (optimal 2D rotation)
|
||||
//! to its step-1 result, then the vertices are solved so every edge vector matches its fitted
|
||||
//! triangle's, which removes the scaling step 1 allows.
|
||||
//! - Pins are arbitrary points (barycentric combinations of a triangle's vertices), so both steps
|
||||
//! use weighted soft constraints. Systems are symmetric positive definite and banded (vertices
|
||||
//! are numbered row by row), solved with a banded Cholesky factorisation written here.
|
||||
//! - Artwork points (anchors and handles) follow the mesh through barycentric coordinates in
|
||||
//! their rest triangle.
|
||||
|
||||
use drawcraft_geom::kurbo::ParamCurveNearest;
|
||||
use drawcraft_geom::{BezPath, Point, Rect, Shape, Vec2};
|
||||
|
||||
/// Banded symmetric positive definite matrix (lower band stored row by row).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BandMatrix {
|
||||
n: usize,
|
||||
bw: usize,
|
||||
/// Row i holds columns i-bw..=i at offsets 0..=bw.
|
||||
a: Vec<f64>,
|
||||
}
|
||||
|
||||
impl BandMatrix {
|
||||
pub fn new(n: usize, bw: usize) -> Self {
|
||||
Self { n, bw, a: vec![0.0; n * (bw + 1)] }
|
||||
}
|
||||
pub fn len(&self) -> usize {
|
||||
self.n
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.n == 0
|
||||
}
|
||||
fn idx(&self, i: usize, j: usize) -> usize {
|
||||
// j <= i, i - j <= bw
|
||||
i * (self.bw + 1) + (j + self.bw - i)
|
||||
}
|
||||
/// Add `v` at (i, j) (and implicitly (j, i)); diagonal entries are added once.
|
||||
pub fn add(&mut self, i: usize, j: usize, v: f64) {
|
||||
let (i, j) = if i >= j { (i, j) } else { (j, i) };
|
||||
debug_assert!(i - j <= self.bw, "outside band");
|
||||
let k = self.idx(i, j);
|
||||
self.a[k] += v;
|
||||
}
|
||||
pub fn get(&self, i: usize, j: usize) -> f64 {
|
||||
let (i, j) = if i >= j { (i, j) } else { (j, i) };
|
||||
if i - j > self.bw { 0.0 } else { self.a[self.idx(i, j)] }
|
||||
}
|
||||
/// In-place Cholesky factorisation (L·Lᵀ). Returns false if the matrix isn't positive definite.
|
||||
pub fn factor(&mut self) -> bool {
|
||||
let (n, bw) = (self.n, self.bw);
|
||||
for i in 0..n {
|
||||
let j0 = i.saturating_sub(bw);
|
||||
for j in j0..=i {
|
||||
let mut s = self.a[self.idx(i, j)];
|
||||
let k0 = j0.max(j.saturating_sub(bw));
|
||||
for k in k0..j {
|
||||
s -= self.a[self.idx(i, k)] * self.a[self.idx(j, k)];
|
||||
}
|
||||
if i == j {
|
||||
if s <= 0.0 || !s.is_finite() {
|
||||
return false;
|
||||
}
|
||||
let k = self.idx(i, i);
|
||||
self.a[k] = s.sqrt();
|
||||
} else {
|
||||
let k = self.idx(i, j);
|
||||
self.a[k] = s / self.a[self.idx(j, j)];
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
/// Solve with a factored matrix.
|
||||
pub fn solve(&self, b: &mut [f64]) {
|
||||
let (n, bw) = (self.n, self.bw);
|
||||
for i in 0..n {
|
||||
let mut s = b[i];
|
||||
for k in i.saturating_sub(bw)..i {
|
||||
s -= self.a[self.idx(i, k)] * b[k];
|
||||
}
|
||||
b[i] = s / self.a[self.idx(i, i)];
|
||||
}
|
||||
for i in (0..n).rev() {
|
||||
let mut s = b[i];
|
||||
for k in i + 1..(i + bw + 1).min(n) {
|
||||
s -= self.a[self.idx(k, i)] * b[k];
|
||||
}
|
||||
b[i] = s / self.a[self.idx(i, i)];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A triangle mesh on a regular grid.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Mesh {
|
||||
pub verts: Vec<Point>,
|
||||
pub tris: Vec<[usize; 3]>,
|
||||
origin: Point,
|
||||
cell: f64,
|
||||
nx: usize,
|
||||
ny: usize,
|
||||
/// Per grid cell: its two triangles (None = cell not part of the mesh).
|
||||
cells: Vec<Option<[usize; 2]>>,
|
||||
}
|
||||
|
||||
/// Mesh construction options.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct MeshOptions {
|
||||
/// Cells along the longer side.
|
||||
pub resolution: usize,
|
||||
/// Expand Mesh: extra reach around the outline, points.
|
||||
pub expand: f64,
|
||||
}
|
||||
|
||||
impl Default for MeshOptions {
|
||||
fn default() -> Self {
|
||||
Self { resolution: 20, expand: 3.0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl Mesh {
|
||||
/// Build a mesh covering `bounds` restricted to the region of `outlines` (filled interiors of
|
||||
/// closed subpaths plus a band around every path). With no outlines, the whole bounds.
|
||||
pub fn build(outlines: &[BezPath], bounds: Rect, opt: MeshOptions) -> Mesh {
|
||||
let side = bounds.width().max(bounds.height()).max(1e-3);
|
||||
let cell = (side / opt.resolution.max(1) as f64).max(1e-3);
|
||||
let pad = opt.expand.max(0.0) + cell * 0.01;
|
||||
let b = bounds.inflate(pad, pad);
|
||||
let nx = ((b.width() / cell).ceil() as usize).max(1);
|
||||
let ny = ((b.height() / cell).ceil() as usize).max(1);
|
||||
let origin = Point::new(b.x0, b.y0);
|
||||
let reach = cell * 0.75 + opt.expand.max(0.0);
|
||||
let keep = |c: Point| -> bool {
|
||||
if outlines.is_empty() {
|
||||
return true;
|
||||
}
|
||||
outlines.iter().any(|o| {
|
||||
let bb = o.bounding_box().inflate(reach, reach);
|
||||
if !bb.contains(c) {
|
||||
return false;
|
||||
}
|
||||
if closed_winding(o, c) != 0 {
|
||||
return true;
|
||||
}
|
||||
o.segments().any(|s| s.nearest(c, 1e-3).distance_sq <= reach * reach)
|
||||
})
|
||||
};
|
||||
let mut vid = vec![usize::MAX; (nx + 1) * (ny + 1)];
|
||||
let mut verts = vec![];
|
||||
let mut kept = vec![false; nx * ny];
|
||||
for j in 0..ny {
|
||||
for i in 0..nx {
|
||||
let c = Point::new(origin.x + (i as f64 + 0.5) * cell, origin.y + (j as f64 + 0.5) * cell);
|
||||
kept[j * nx + i] = keep(c);
|
||||
}
|
||||
}
|
||||
// Number vertices row by row (keeps the systems banded).
|
||||
for j in 0..=ny {
|
||||
for i in 0..=nx {
|
||||
let used = [(i.wrapping_sub(1), j.wrapping_sub(1)), (i, j.wrapping_sub(1)), (i.wrapping_sub(1), j), (i, j)]
|
||||
.iter()
|
||||
.any(|&(ci, cj)| ci < nx && cj < ny && kept[cj * nx + ci]);
|
||||
if used {
|
||||
vid[j * (nx + 1) + i] = verts.len();
|
||||
verts.push(Point::new(origin.x + i as f64 * cell, origin.y + j as f64 * cell));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut tris = vec![];
|
||||
let mut cells = vec![None; nx * ny];
|
||||
for j in 0..ny {
|
||||
for i in 0..nx {
|
||||
if !kept[j * nx + i] {
|
||||
continue;
|
||||
}
|
||||
let v = |di: usize, dj: usize| vid[(j + dj) * (nx + 1) + i + di];
|
||||
let (v00, v10, v01, v11) = (v(0, 0), v(1, 0), v(0, 1), v(1, 1));
|
||||
let t0 = tris.len();
|
||||
tris.push([v00, v10, v11]);
|
||||
tris.push([v00, v11, v01]);
|
||||
cells[j * nx + i] = Some([t0, t0 + 1]);
|
||||
}
|
||||
}
|
||||
Mesh { verts, tris, origin, cell, nx, ny, cells }
|
||||
}
|
||||
|
||||
/// Band width (in vertices) of the vertex adjacency.
|
||||
fn vertex_band(&self) -> usize {
|
||||
self.tris.iter().map(|t| t.iter().max().unwrap() - t.iter().min().unwrap()).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The triangle and barycentric coordinates of `p` (extrapolated from the nearest mesh cell
|
||||
/// when `p` is outside the mesh).
|
||||
pub fn locate(&self, p: Point) -> Option<(usize, [f64; 3])> {
|
||||
let fi = ((p.x - self.origin.x) / self.cell).floor();
|
||||
let fj = ((p.y - self.origin.y) / self.cell).floor();
|
||||
let inside = fi >= 0.0 && fj >= 0.0 && (fi as usize) < self.nx && (fj as usize) < self.ny;
|
||||
let cell = if inside && self.cells[fj as usize * self.nx + fi as usize].is_some() {
|
||||
fj as usize * self.nx + fi as usize
|
||||
} else {
|
||||
// Nearest kept cell centre.
|
||||
let mut best = None;
|
||||
for (k, c) in self.cells.iter().enumerate() {
|
||||
if c.is_none() {
|
||||
continue;
|
||||
}
|
||||
let cc = Point::new(self.origin.x + ((k % self.nx) as f64 + 0.5) * self.cell, self.origin.y + ((k / self.nx) as f64 + 0.5) * self.cell);
|
||||
let d = cc.distance_squared(p);
|
||||
if best.is_none_or(|(bd, _)| d < bd) {
|
||||
best = Some((d, k));
|
||||
}
|
||||
}
|
||||
best?.1
|
||||
};
|
||||
let [ta, tb] = self.cells[cell]?;
|
||||
// Pick the triangle on p's side of the diagonal v00–v11.
|
||||
let v00 = self.verts[self.tris[ta][0]];
|
||||
let local = (p - v00) / self.cell;
|
||||
let t = if local.x >= local.y { ta } else { tb };
|
||||
Some((t, self.bary(t, p)))
|
||||
}
|
||||
|
||||
fn bary(&self, t: usize, p: Point) -> [f64; 3] {
|
||||
let [a, b, c] = self.tris[t].map(|i| self.verts[i]);
|
||||
let (v0, v1, v2) = (b - a, c - a, p - a);
|
||||
let den = v0.cross(v1);
|
||||
if den.abs() < 1e-18 {
|
||||
return [1.0, 0.0, 0.0];
|
||||
}
|
||||
let l1 = v2.cross(v1) / den;
|
||||
let l2 = v0.cross(v2) / den;
|
||||
[1.0 - l1 - l2, l1, l2]
|
||||
}
|
||||
|
||||
/// Map a rest-space point through deformed vertex positions.
|
||||
pub fn map(&self, deformed: &[Point], p: Point) -> Point {
|
||||
match self.locate(p) {
|
||||
Some((t, w)) => {
|
||||
let [a, b, c] = self.tris[t].map(|i| deformed[i].to_vec2());
|
||||
(a * w[0] + b * w[1] + c * w[2]).to_point()
|
||||
}
|
||||
None => p,
|
||||
}
|
||||
}
|
||||
|
||||
/// Signed area of triangle `t` for the given vertex positions.
|
||||
pub fn tri_area(&self, pos: &[Point], t: usize) -> f64 {
|
||||
let [a, b, c] = self.tris[t].map(|i| pos[i]);
|
||||
(b - a).cross(c - a) / 2.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Winding number of the closed subpaths of `o` at `p` (open subpaths ignored).
|
||||
fn closed_winding(o: &BezPath, p: Point) -> i32 {
|
||||
let mut total = 0;
|
||||
let mut cur = BezPath::new();
|
||||
let mut closed = false;
|
||||
let flush = |cur: &mut BezPath, closed: bool, total: &mut i32| {
|
||||
if closed && !cur.elements().is_empty() {
|
||||
*total += cur.winding(p);
|
||||
}
|
||||
*cur = BezPath::new();
|
||||
};
|
||||
for el in o.elements() {
|
||||
match el {
|
||||
drawcraft_geom::PathEl::MoveTo(_) => {
|
||||
flush(&mut cur, closed, &mut total);
|
||||
closed = false;
|
||||
cur.push(*el);
|
||||
}
|
||||
drawcraft_geom::PathEl::ClosePath => {
|
||||
closed = true;
|
||||
cur.push(*el);
|
||||
}
|
||||
e => cur.push(*e),
|
||||
}
|
||||
}
|
||||
flush(&mut cur, closed, &mut total);
|
||||
total
|
||||
}
|
||||
|
||||
/// Weight of the soft pin constraints relative to the shape terms.
|
||||
const PIN_WEIGHT: f64 = 1.0e5;
|
||||
/// Tikhonov regularisation toward the rest pose (keeps pin-less islands in place).
|
||||
const REG: f64 = 1.0e-7;
|
||||
|
||||
/// A pin in rest space and where it should go.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Pin {
|
||||
pub rest: Point,
|
||||
pub target: Point,
|
||||
}
|
||||
|
||||
/// Run both ARAP steps. Returns the deformed vertex positions (the rest mesh if fewer than one
|
||||
/// pin lands on it or a system is singular).
|
||||
pub fn deform(mesh: &Mesh, pins: &[Pin]) -> Vec<Point> {
|
||||
let n = mesh.verts.len();
|
||||
if n == 0 || pins.is_empty() {
|
||||
return mesh.verts.clone();
|
||||
}
|
||||
let located: Vec<(usize, [f64; 3], Point)> = pins.iter().filter_map(|p| mesh.locate(p.rest).map(|(t, w)| (t, w, p.target))).collect();
|
||||
let vb = mesh.vertex_band();
|
||||
// ---- step 1: similarity-invariant error, 2n unknowns interleaved (x0, y0, x1, y1, ...).
|
||||
let mut g = BandMatrix::new(2 * n, 2 * vb + 1);
|
||||
for tri in &mesh.tris {
|
||||
for k in 0..3 {
|
||||
let (a, b, c) = (tri[k], tri[(k + 1) % 3], tri[(k + 2) % 3]);
|
||||
let (pa, pb, pc) = (mesh.verts[a], mesh.verts[b], mesh.verts[c]);
|
||||
let e = pb - pa;
|
||||
let l2 = e.hypot2();
|
||||
if l2 < 1e-18 {
|
||||
continue;
|
||||
}
|
||||
let d = pc - pa;
|
||||
let x = d.dot(e) / l2;
|
||||
let y = d.dot(Vec2::new(-e.y, e.x)) / l2;
|
||||
// Residual coefficients over (ax, ay, bx, by, cx, cy); see module docs.
|
||||
let vars = [2 * a, 2 * a + 1, 2 * b, 2 * b + 1, 2 * c, 2 * c + 1];
|
||||
let rx = [x - 1.0, -y, -x, y, 1.0, 0.0];
|
||||
let ry = [y, x - 1.0, -y, -x, 0.0, 1.0];
|
||||
for r in [rx, ry] {
|
||||
for i in 0..6 {
|
||||
for j in 0..=i {
|
||||
let v = r[i] * r[j];
|
||||
if v != 0.0 {
|
||||
g.add(vars[i], vars[j], v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut rhs = vec![0.0; 2 * n];
|
||||
for (t, w, target) in &located {
|
||||
let tri = mesh.tris[*t];
|
||||
for comp in 0..2 {
|
||||
for i in 0..3 {
|
||||
for j in 0..=i {
|
||||
g.add(2 * tri[i] + comp, 2 * tri[j] + comp, PIN_WEIGHT * w[i] * w[j]);
|
||||
}
|
||||
rhs[2 * tri[i] + comp] += PIN_WEIGHT * w[i] * if comp == 0 { target.x } else { target.y };
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i, v) in mesh.verts.iter().enumerate() {
|
||||
g.add(2 * i, 2 * i, REG);
|
||||
g.add(2 * i + 1, 2 * i + 1, REG);
|
||||
rhs[2 * i] += REG * v.x;
|
||||
rhs[2 * i + 1] += REG * v.y;
|
||||
}
|
||||
if !g.factor() {
|
||||
return mesh.verts.clone();
|
||||
}
|
||||
g.solve(&mut rhs);
|
||||
let step1: Vec<Point> = (0..n).map(|i| Point::new(rhs[2 * i], rhs[2 * i + 1])).collect();
|
||||
|
||||
// ---- step 2: fit rigid triangles, then match edge vectors (x and y separable).
|
||||
let mut l = BandMatrix::new(n, vb);
|
||||
let mut bx = vec![0.0; n];
|
||||
let mut by = vec![0.0; n];
|
||||
for tri in &mesh.tris {
|
||||
let rest = tri.map(|i| mesh.verts[i]);
|
||||
let cur = tri.map(|i| step1[i]);
|
||||
let rc = centroid(&rest);
|
||||
let cc = centroid(&cur);
|
||||
let (mut sc, mut ss) = (0.0, 0.0);
|
||||
for k in 0..3 {
|
||||
let (r, c) = (rest[k] - rc, cur[k] - cc);
|
||||
sc += r.dot(c);
|
||||
ss += r.cross(c);
|
||||
}
|
||||
let th = ss.atan2(sc);
|
||||
let (s, co) = th.sin_cos();
|
||||
let fit = rest.map(|r| {
|
||||
let d = r - rc;
|
||||
cc + Vec2::new(d.x * co - d.y * s, d.x * s + d.y * co)
|
||||
});
|
||||
for k in 0..3 {
|
||||
let (i, j) = (tri[k], tri[(k + 1) % 3]);
|
||||
let e = fit[k] - fit[(k + 1) % 3];
|
||||
// (v_i - v_j - e)^2
|
||||
l.add(i, i, 1.0);
|
||||
l.add(j, j, 1.0);
|
||||
l.add(i, j, -1.0);
|
||||
bx[i] += e.x;
|
||||
bx[j] -= e.x;
|
||||
by[i] += e.y;
|
||||
by[j] -= e.y;
|
||||
}
|
||||
}
|
||||
for (t, w, target) in &located {
|
||||
let tri = mesh.tris[*t];
|
||||
for i in 0..3 {
|
||||
for j in 0..=i {
|
||||
l.add(tri[i], tri[j], PIN_WEIGHT * w[i] * w[j]);
|
||||
}
|
||||
bx[tri[i]] += PIN_WEIGHT * w[i] * target.x;
|
||||
by[tri[i]] += PIN_WEIGHT * w[i] * target.y;
|
||||
}
|
||||
}
|
||||
for (i, v) in step1.iter().enumerate() {
|
||||
l.add(i, i, REG);
|
||||
bx[i] += REG * v.x;
|
||||
by[i] += REG * v.y;
|
||||
}
|
||||
if !l.factor() {
|
||||
return step1;
|
||||
}
|
||||
l.solve(&mut bx);
|
||||
l.solve(&mut by);
|
||||
(0..n).map(|i| Point::new(bx[i], by[i])).collect()
|
||||
}
|
||||
|
||||
fn centroid(p: &[Point; 3]) -> Point {
|
||||
Point::new((p[0].x + p[1].x + p[2].x) / 3.0, (p[0].y + p[1].y + p[2].y) / 3.0)
|
||||
}
|
||||
|
||||
/// Pick `count` well-spread pin positions inside the mesh (farthest-point sampling over the
|
||||
/// kept cell centres, starting from the one nearest the centroid).
|
||||
pub fn auto_pins(mesh: &Mesh, count: usize) -> Vec<Point> {
|
||||
let centres: Vec<Point> = mesh
|
||||
.cells
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, c)| c.is_some())
|
||||
.map(|(k, _)| Point::new(mesh.origin.x + ((k % mesh.nx) as f64 + 0.5) * mesh.cell, mesh.origin.y + ((k / mesh.nx) as f64 + 0.5) * mesh.cell))
|
||||
.collect();
|
||||
if centres.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
let c = centres.iter().fold(Vec2::ZERO, |a, p| a + p.to_vec2()) / centres.len() as f64;
|
||||
let first = *centres.iter().min_by(|a, b| a.distance_squared(c.to_point()).total_cmp(&b.distance_squared(c.to_point()))).unwrap();
|
||||
let mut out = vec![first];
|
||||
while out.len() < count.min(centres.len()) {
|
||||
let next = centres
|
||||
.iter()
|
||||
.max_by(|a, b| {
|
||||
let da = out.iter().map(|o| o.distance_squared(**a)).fold(f64::MAX, f64::min);
|
||||
let db = out.iter().map(|o| o.distance_squared(**b)).fold(f64::MAX, f64::min);
|
||||
da.total_cmp(&db)
|
||||
})
|
||||
.copied()
|
||||
.unwrap();
|
||||
out.push(next);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use drawcraft_geom::shapes;
|
||||
|
||||
#[test]
|
||||
fn band_cholesky_solves_spd_systems() {
|
||||
// Tridiagonal [4 1; 1 4 1; ...] vs a dense reference.
|
||||
let n = 12;
|
||||
let mut m = BandMatrix::new(n, 1);
|
||||
for i in 0..n {
|
||||
m.add(i, i, 4.0);
|
||||
if i > 0 {
|
||||
m.add(i, i - 1, 1.0);
|
||||
}
|
||||
}
|
||||
let x: Vec<f64> = (0..n).map(|i| i as f64 * 0.5 - 2.0).collect();
|
||||
let mut b: Vec<f64> = (0..n).map(|i| (0..n).map(|j| m.get(i, j) * x[j]).sum()).collect();
|
||||
assert!(m.factor());
|
||||
m.solve(&mut b);
|
||||
for i in 0..n {
|
||||
assert!((b[i] - x[i]).abs() < 1e-12);
|
||||
}
|
||||
let mut bad = BandMatrix::new(2, 1);
|
||||
bad.add(0, 0, 1.0);
|
||||
bad.add(1, 0, 2.0);
|
||||
bad.add(1, 1, 1.0);
|
||||
assert!(!bad.factor(), "indefinite");
|
||||
}
|
||||
|
||||
fn rect_mesh() -> Mesh {
|
||||
let r = Rect::new(0.0, 0.0, 200.0, 100.0);
|
||||
Mesh::build(&[shapes::rectangle(r).to_bezpath()], r, MeshOptions { resolution: 16, expand: 0.0 })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_covers_shape_and_locates_points() {
|
||||
let m = rect_mesh();
|
||||
assert!(!m.tris.is_empty());
|
||||
for p in [Point::new(10.0, 10.0), Point::new(150.0, 70.0), Point::new(199.0, 99.0)] {
|
||||
let (t, w) = m.locate(p).unwrap();
|
||||
assert!(w.iter().all(|v| *v >= -1e-9), "{w:?}");
|
||||
assert!((m.map(&m.verts, p) - p).hypot() < 1e-9);
|
||||
let _ = t;
|
||||
}
|
||||
// An L-shaped outline drops the empty corner cells.
|
||||
let mut l = BezPath::new();
|
||||
l.move_to((0.0, 0.0));
|
||||
l.line_to((100.0, 0.0));
|
||||
l.line_to((100.0, 20.0));
|
||||
l.line_to((20.0, 20.0));
|
||||
l.line_to((20.0, 100.0));
|
||||
l.line_to((0.0, 100.0));
|
||||
l.close_path();
|
||||
let lm = Mesh::build(&[l], Rect::new(0.0, 0.0, 100.0, 100.0), MeshOptions { resolution: 10, expand: 0.0 });
|
||||
assert!(lm.tris.len() < m.tris.len() / 2, "{} tris", lm.tris.len());
|
||||
assert!(lm.cells.iter().filter(|c| c.is_some()).count() < 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pins_at_rest_leave_the_mesh_unchanged() {
|
||||
let m = rect_mesh();
|
||||
let pins: Vec<Pin> = [(20.0, 50.0), (180.0, 50.0), (100.0, 20.0)].iter().map(|&(x, y)| Pin { rest: Point::new(x, y), target: Point::new(x, y) }).collect();
|
||||
let d = deform(&m, &pins);
|
||||
let err = m.verts.iter().zip(&d).map(|(a, b)| a.distance(*b)).fold(0.0, f64::max);
|
||||
assert!(err < 1e-4, "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rigid_motion_is_reproduced_exactly() {
|
||||
let m = rect_mesh();
|
||||
let rot = |p: Point| {
|
||||
let (s, c) = 0.5f64.sin_cos();
|
||||
Point::new(p.x * c - p.y * s + 30.0, p.x * s + p.y * c - 10.0)
|
||||
};
|
||||
let pins: Vec<Pin> = [(20.0, 50.0), (180.0, 50.0), (100.0, 90.0)].iter().map(|&(x, y)| Pin { rest: Point::new(x, y), target: rot(Point::new(x, y)) }).collect();
|
||||
let d = deform(&m, &pins);
|
||||
let err = m.verts.iter().zip(&d).map(|(a, b)| rot(*a).distance(*b)).fold(0.0, f64::max);
|
||||
assert!(err < 1e-2, "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pins_are_satisfied_and_free_regions_stay_rigid() {
|
||||
let m = rect_mesh();
|
||||
// Hold the left end, lift the right end.
|
||||
let pins = vec![
|
||||
Pin { rest: Point::new(10.0, 30.0), target: Point::new(10.0, 30.0) },
|
||||
Pin { rest: Point::new(10.0, 70.0), target: Point::new(10.0, 70.0) },
|
||||
Pin { rest: Point::new(190.0, 50.0), target: Point::new(180.0, -30.0) },
|
||||
];
|
||||
let d = deform(&m, &pins);
|
||||
for p in &pins {
|
||||
let got = m.map(&d, p.rest);
|
||||
assert!(got.distance(p.target) < 0.05, "pin {:?} → {got:?}", p.target);
|
||||
}
|
||||
// Area is preserved (as-rigid-as-possible): total within a few percent, no flipped triangles.
|
||||
let (a0, a1): (f64, f64) = (0..m.tris.len()).map(|t| (m.tri_area(&m.verts, t), m.tri_area(&d, t))).fold((0.0, 0.0), |s, v| (s.0 + v.0, s.1 + v.1));
|
||||
assert!((a1 / a0 - 1.0).abs() < 0.05, "area ratio {}", a1 / a0);
|
||||
assert!((0..m.tris.len()).all(|t| m.tri_area(&d, t) * m.tri_area(&m.verts, t) > 0.0));
|
||||
// Each triangle stays nearly congruent: edge lengths change by < 25%.
|
||||
let worst = m
|
||||
.tris
|
||||
.iter()
|
||||
.flat_map(|t| (0..3).map(move |k| (t[k], t[(k + 1) % 3])))
|
||||
.map(|(i, j)| (d[i].distance(d[j]) / m.verts[i].distance(m.verts[j]) - 1.0).abs())
|
||||
.fold(0.0, f64::max);
|
||||
assert!(worst < 0.25, "edge stretch {worst}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_pins_are_spread_inside_the_shape() {
|
||||
let m = rect_mesh();
|
||||
let p = auto_pins(&m, 3);
|
||||
assert_eq!(p.len(), 3);
|
||||
assert!(p.iter().all(|q| Rect::new(0.0, 0.0, 200.0, 100.0).contains(*q)));
|
||||
assert!(p[1].distance(p[2]) > 100.0);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,638 @@
|
|||
//! Liquify tools: Warp (Shift+R), Twirl, Pucker, Bloat, Scallop, Crystallize, Wrinkle.
|
||||
//!
|
||||
//! A drag previews `object.liquify {tool, points, width, height, angle, intensity, detail,
|
||||
//! simplify, rate?, complexity?, horizontal?, vertical?, ids?}` with every pointer sample so far;
|
||||
//! the engine re-applies the whole stroke to the interaction snapshot with [`apply_stroke`], so the
|
||||
//! result is a pure function of the parameters (replay reproduces it exactly).
|
||||
//!
|
||||
//! The kernel resamples the stroke into dabs spaced a fraction of the brush apart. For each dab it
|
||||
//! first subdivides the segments the brush touches (Detail: more anchors where the brush passes,
|
||||
//! lines become curves so the result stays smooth), then moves anchors and handles by the tool's
|
||||
//! displacement field weighted by a smooth falloff `(1 − r²)²` inside the (elliptical, rotated)
|
||||
//! brush. Noise for Scallop/Crystallize/Wrinkle is a hash of the dab and anchor indices.
|
||||
|
||||
use drawcraft_geom::kurbo::ParamCurveArclen;
|
||||
use drawcraft_geom::{Anchor, AnchorKind, PathData, Point, Rect, SubPath, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::ellipse_path;
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum LiquifyKind {
|
||||
Warp,
|
||||
Twirl,
|
||||
Pucker,
|
||||
Bloat,
|
||||
Scallop,
|
||||
Crystallize,
|
||||
Wrinkle,
|
||||
}
|
||||
|
||||
impl LiquifyKind {
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
Some(match s {
|
||||
"warp" => Self::Warp,
|
||||
"twirl" => Self::Twirl,
|
||||
"pucker" => Self::Pucker,
|
||||
"bloat" => Self::Bloat,
|
||||
"scallop" => Self::Scallop,
|
||||
"crystallize" => Self::Crystallize,
|
||||
"wrinkle" => Self::Wrinkle,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Warp => "warp",
|
||||
Self::Twirl => "twirl",
|
||||
Self::Pucker => "pucker",
|
||||
Self::Bloat => "bloat",
|
||||
Self::Scallop => "scallop",
|
||||
Self::Crystallize => "crystallize",
|
||||
Self::Wrinkle => "wrinkle",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Warp => "Warp",
|
||||
Self::Twirl => "Twirl",
|
||||
Self::Pucker => "Pucker",
|
||||
Self::Bloat => "Bloat",
|
||||
Self::Scallop => "Scallop",
|
||||
Self::Crystallize => "Crystallize",
|
||||
Self::Wrinkle => "Wrinkle",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Brush and tool options (Warp Tool Options and friends).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct LiquifyParams {
|
||||
pub kind: LiquifyKind,
|
||||
/// Brush width / height in points and rotation in degrees.
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
pub angle: f64,
|
||||
/// 0..1.
|
||||
pub intensity: f64,
|
||||
/// 1..10: anchor density added where the brush passes.
|
||||
pub detail: f64,
|
||||
/// 0..100: removal of redundant flat anchors afterwards.
|
||||
pub simplify: f64,
|
||||
/// Twirl rate, degrees (−180..180).
|
||||
pub rate: f64,
|
||||
/// Scallop/Crystallize/Wrinkle complexity (0..15).
|
||||
pub complexity: f64,
|
||||
/// Wrinkle horizontal / vertical amount (0..1).
|
||||
pub horizontal: f64,
|
||||
pub vertical: f64,
|
||||
pub affect_anchors: bool,
|
||||
pub affect_in: bool,
|
||||
pub affect_out: bool,
|
||||
}
|
||||
|
||||
impl LiquifyParams {
|
||||
pub fn new(kind: LiquifyKind) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
width: 100.0,
|
||||
height: 100.0,
|
||||
angle: 0.0,
|
||||
intensity: 0.5,
|
||||
detail: 2.0,
|
||||
simplify: 50.0,
|
||||
rate: 40.0,
|
||||
complexity: 1.0,
|
||||
horizontal: 0.0,
|
||||
vertical: 1.0,
|
||||
affect_anchors: true,
|
||||
affect_in: true,
|
||||
affect_out: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse command parameters (percentages above 1 are accepted for intensity/horizontal/vertical).
|
||||
pub fn from_json(p: &Value) -> Option<Self> {
|
||||
let kind = LiquifyKind::parse(p.get("tool")?.as_str()?)?;
|
||||
let mut s = Self::new(kind);
|
||||
let f = |k: &str| p.get(k).and_then(Value::as_f64);
|
||||
let pct = |v: f64| if v > 1.0 { v / 100.0 } else { v };
|
||||
if let Some(d) = f("diameter") {
|
||||
s.width = d;
|
||||
s.height = d;
|
||||
}
|
||||
s.width = f("width").unwrap_or(s.width).clamp(0.5, 10_000.0);
|
||||
s.height = f("height").unwrap_or(s.height).clamp(0.5, 10_000.0);
|
||||
s.angle = f("angle").unwrap_or(0.0);
|
||||
s.intensity = pct(f("intensity").unwrap_or(0.5)).clamp(0.0, 1.0);
|
||||
s.detail = f("detail").unwrap_or(2.0).clamp(1.0, 10.0);
|
||||
s.simplify = f("simplify").unwrap_or(50.0).clamp(0.0, 100.0);
|
||||
s.rate = f("rate").unwrap_or(40.0).clamp(-180.0, 180.0);
|
||||
s.complexity = f("complexity").unwrap_or(1.0).clamp(0.0, 15.0);
|
||||
s.horizontal = pct(f("horizontal").unwrap_or(0.0)).clamp(0.0, 1.0);
|
||||
s.vertical = pct(f("vertical").unwrap_or(1.0)).clamp(0.0, 1.0);
|
||||
let b = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(true);
|
||||
s.affect_anchors = b("affectAnchors");
|
||||
s.affect_in = b("affectIn");
|
||||
s.affect_out = b("affectOut");
|
||||
Some(s)
|
||||
}
|
||||
|
||||
pub fn to_json(&self) -> Value {
|
||||
let mut v = json!({
|
||||
"tool": self.kind.id(), "width": self.width, "height": self.height, "angle": self.angle,
|
||||
"intensity": self.intensity, "detail": self.detail, "simplify": self.simplify,
|
||||
});
|
||||
match self.kind {
|
||||
LiquifyKind::Twirl => v["rate"] = json!(self.rate),
|
||||
LiquifyKind::Scallop | LiquifyKind::Crystallize => v["complexity"] = json!(self.complexity),
|
||||
LiquifyKind::Wrinkle => {
|
||||
v["complexity"] = json!(self.complexity);
|
||||
v["horizontal"] = json!(self.horizontal);
|
||||
v["vertical"] = json!(self.vertical);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn radii(&self) -> (f64, f64) {
|
||||
(self.width / 2.0, self.height / 2.0)
|
||||
}
|
||||
|
||||
/// Spacing between dabs.
|
||||
pub fn dab_spacing(&self) -> f64 {
|
||||
let (rx, ry) = self.radii();
|
||||
(rx.min(ry) * 0.2).max(0.5)
|
||||
}
|
||||
|
||||
/// Target segment length inside the brush.
|
||||
fn detail_spacing(&self) -> f64 {
|
||||
let (rx, ry) = self.radii();
|
||||
let extra = match self.kind {
|
||||
LiquifyKind::Scallop | LiquifyKind::Crystallize | LiquifyKind::Wrinkle => 1.0 + self.complexity * 0.5,
|
||||
_ => 1.0,
|
||||
};
|
||||
(rx.min(ry) * 2.0 / (self.detail * 2.0 + 1.0) / extra).max(0.5)
|
||||
}
|
||||
|
||||
/// Bounding box of the brush at `c`.
|
||||
pub fn brush_bounds(&self, c: Point) -> Rect {
|
||||
let r = self.width.max(self.height) / 2.0;
|
||||
Rect::new(c.x - r, c.y - r, c.x + r, c.y + r)
|
||||
}
|
||||
|
||||
/// Falloff weight of `q` for a brush centred at `c` (0 outside).
|
||||
pub fn falloff(&self, c: Point, q: Point) -> f64 {
|
||||
let (rx, ry) = self.radii();
|
||||
let (s, co) = (-self.angle.to_radians()).sin_cos();
|
||||
let d = q - c;
|
||||
let u = Vec2::new(d.x * co - d.y * s, d.x * s + d.y * co);
|
||||
let r2 = (u.x / rx).powi(2) + (u.y / ry).powi(2);
|
||||
if r2 >= 1.0 { 0.0 } else { (1.0 - r2).powi(2) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Resample a stroke polyline into dabs spaced `spacing` apart (the first point is always a dab).
|
||||
pub fn dabs(points: &[Point], spacing: f64) -> Vec<Point> {
|
||||
let mut out: Vec<Point> = vec![];
|
||||
let Some(&first) = points.first() else { return out };
|
||||
out.push(first);
|
||||
let mut last = first;
|
||||
let mut carry = 0.0;
|
||||
for w in points.windows(2) {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
let len = a.distance(b);
|
||||
if len < 1e-12 {
|
||||
continue;
|
||||
}
|
||||
let mut s = spacing - carry;
|
||||
while s <= len {
|
||||
last = a.lerp(b, s / len);
|
||||
out.push(last);
|
||||
s += spacing;
|
||||
}
|
||||
carry = len - (s - spacing);
|
||||
if out.len() > 20_000 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let end = *points.last().unwrap();
|
||||
if end.distance(last) > spacing * 0.25 {
|
||||
out.push(end);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn hash(mut x: u64) -> u64 {
|
||||
// splitmix64
|
||||
x = x.wrapping_add(0x9e37_79b9_7f4a_7c15);
|
||||
x = (x ^ (x >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
|
||||
x = (x ^ (x >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
|
||||
x ^ (x >> 31)
|
||||
}
|
||||
|
||||
/// Deterministic noise in [-1, 1].
|
||||
fn noise(a: u64, b: u64, c: u64) -> f64 {
|
||||
let h = hash(a.wrapping_mul(0x1000_0000_01b3) ^ hash(b.wrapping_mul(31) ^ hash(c)));
|
||||
(h >> 11) as f64 / (1u64 << 53) as f64 * 2.0 - 1.0
|
||||
}
|
||||
|
||||
const MAX_ANCHORS: usize = 20_000;
|
||||
|
||||
/// Split the segments of `sp` that the brush at `c` touches until they are at most `spacing` long.
|
||||
/// Straight segments become curves first so the deformation stays smooth.
|
||||
fn subdivide(sp: &mut SubPath, prm: &LiquifyParams, c: Point, spacing: f64) {
|
||||
let bb = prm.brush_bounds(c);
|
||||
let mut seg = 0;
|
||||
while seg < sp.segment_count() {
|
||||
if sp.anchors.len() >= MAX_ANCHORS {
|
||||
return;
|
||||
}
|
||||
let cub = sp.segment(seg);
|
||||
let sb = drawcraft_geom::kurbo::ParamCurveExtrema::bounding_box(&cub);
|
||||
let touched = sb.intersect(bb).area() > 0.0 || (sb.width() == 0.0 || sb.height() == 0.0) && sb.inflate(1e-6, 1e-6).intersect(bb).area() > 0.0;
|
||||
if !touched || !(0..=8).any(|i| prm.falloff(c, drawcraft_geom::kurbo::ParamCurve::eval(&cub, i as f64 / 8.0)) > 0.0) {
|
||||
seg += 1;
|
||||
continue;
|
||||
}
|
||||
let len = cub.arclen(1e-3);
|
||||
let k = (len / spacing).ceil() as usize;
|
||||
if k <= 1 {
|
||||
seg += 1;
|
||||
continue;
|
||||
}
|
||||
let n = sp.anchors.len();
|
||||
if sp.segment_is_line(seg) {
|
||||
let (i0, i1) = (seg % n, (seg + 1) % n);
|
||||
let (a, b) = (sp.anchors[i0].p, sp.anchors[i1].p);
|
||||
sp.anchors[i0].h_out = a.lerp(b, 1.0 / 3.0);
|
||||
sp.anchors[i1].h_in = a.lerp(b, 2.0 / 3.0);
|
||||
}
|
||||
// Split into k equal-parameter pieces: split at 1/k, then 1/(k-1) of the rest, ...
|
||||
let mut cur = seg;
|
||||
for j in 0..k - 1 {
|
||||
let t = 1.0 / (k - j) as f64;
|
||||
cur = sp.insert_anchor(cur, t);
|
||||
}
|
||||
seg = cur + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Displace one point (`which`: 0 anchor, 1 in-handle, 2 out-handle).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn displace(prm: &LiquifyParams, c: Point, prev: Point, dab: u64, key: u64, q: Point, anchor_new: Option<Point>, anchor_old: Point) -> Point {
|
||||
let f = prm.falloff(c, q);
|
||||
let i = prm.intensity;
|
||||
let (rx, ry) = prm.radii();
|
||||
let r = rx.max(ry);
|
||||
let d = q - c;
|
||||
let len = d.hypot();
|
||||
let dir = if len > 1e-9 { d / len } else { Vec2::ZERO };
|
||||
match prm.kind {
|
||||
LiquifyKind::Warp => q + (c - prev) * (i * f),
|
||||
LiquifyKind::Twirl => {
|
||||
let a = prm.rate.to_radians() * i * f * 0.25;
|
||||
let (s, co) = a.sin_cos();
|
||||
c + Vec2::new(d.x * co - d.y * s, d.x * s + d.y * co)
|
||||
}
|
||||
LiquifyKind::Pucker => q - d * (i * f * 0.2),
|
||||
LiquifyKind::Bloat => q + dir * (i * f * 0.1 * r),
|
||||
LiquifyKind::Scallop => match anchor_new {
|
||||
None => q - d * (i * f * 0.12),
|
||||
Some(a) => {
|
||||
// Handles swing sideways (curls) and stretch: arc-like details on the outline.
|
||||
let h = q - anchor_old;
|
||||
let phi = noise(dab, key, 7) * std::f64::consts::FRAC_PI_2 * i * f * (1.0 + prm.complexity * 0.2);
|
||||
let (s, co) = phi.sin_cos();
|
||||
a + Vec2::new(h.x * co - h.y * s, h.x * s + h.y * co) * (1.0 + 0.6 * i * f)
|
||||
}
|
||||
},
|
||||
LiquifyKind::Crystallize => match anchor_new {
|
||||
None => q + dir * (i * f * 0.1 * r * (0.5 + 0.5 * noise(dab, key, 3).abs())),
|
||||
// Handles are pulled in: spikes.
|
||||
Some(a) => a + (q - anchor_old) * (1.0 - 0.6 * i * f),
|
||||
},
|
||||
LiquifyKind::Wrinkle => {
|
||||
let amp = i * f * 0.06 * r * (1.0 + prm.complexity * 0.1);
|
||||
q + Vec2::new(noise(dab, key, 11) * prm.horizontal * amp, noise(dab, key, 13) * prm.vertical * amp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply one dab to a subpath (after subdivision).
|
||||
fn apply_dab(sp: &mut SubPath, prm: &LiquifyParams, c: Point, prev: Point, dab: u64, salt: u64) -> bool {
|
||||
let bb = prm.brush_bounds(c);
|
||||
let mut changed = false;
|
||||
for (ai, a) in sp.anchors.iter_mut().enumerate() {
|
||||
if !bb.contains(a.p) && !bb.contains(a.h_in) && !bb.contains(a.h_out) {
|
||||
continue;
|
||||
}
|
||||
let key = hash(salt ^ (ai as u64).wrapping_mul(0x9e37_79b9));
|
||||
let old = *a;
|
||||
let np = if prm.affect_anchors { displace(prm, c, prev, dab, key, old.p, None, old.p) } else { old.p };
|
||||
let moved = np - old.p;
|
||||
let handle = |h: Point, affect: bool, k: u64| -> Point {
|
||||
if h.distance(old.p) < 1e-12 {
|
||||
return np;
|
||||
}
|
||||
if !affect {
|
||||
return h + moved;
|
||||
}
|
||||
match prm.kind {
|
||||
LiquifyKind::Scallop | LiquifyKind::Crystallize => displace(prm, c, prev, dab, key ^ k, h, Some(np), old.p),
|
||||
_ => displace(prm, c, prev, dab, key ^ k, h, None, old.p),
|
||||
}
|
||||
};
|
||||
let hi = handle(old.h_in, prm.affect_in, 1);
|
||||
let ho = handle(old.h_out, prm.affect_out, 2);
|
||||
if np != old.p || hi != old.h_in || ho != old.h_out {
|
||||
changed = true;
|
||||
*a = Anchor { p: np, h_in: hi, h_out: ho, kind: if old.kind == AnchorKind::Smooth && prm.kind == LiquifyKind::Crystallize { AnchorKind::Corner } else { old.kind } };
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Distance from `p` to segment `ab`.
|
||||
fn dist_seg(p: Point, a: Point, b: Point) -> f64 {
|
||||
let ab = b - a;
|
||||
let l2 = ab.hypot2();
|
||||
if l2 < 1e-18 {
|
||||
return p.distance(a);
|
||||
}
|
||||
let t = ((p - a).dot(ab) / l2).clamp(0.0, 1.0);
|
||||
p.distance(a + ab * t)
|
||||
}
|
||||
|
||||
/// Remove anchors lying flat between their neighbours (all control points within `tol` of the chord).
|
||||
fn simplify(sp: &mut SubPath, tol: f64, region: Rect) {
|
||||
if tol <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let mut i = 1;
|
||||
loop {
|
||||
let n = sp.anchors.len();
|
||||
let min = if sp.closed { 4 } else { 3 };
|
||||
if n < min {
|
||||
return;
|
||||
}
|
||||
let last = if sp.closed { n } else { n - 1 };
|
||||
if i >= last {
|
||||
return;
|
||||
}
|
||||
let (pa, k, pb) = (sp.anchors[i - 1], sp.anchors[i], sp.anchors[(i + 1) % n]);
|
||||
let flat = region.contains(k.p)
|
||||
&& [k.p, k.h_in, k.h_out, pa.h_out, pb.h_in].iter().all(|q| dist_seg(*q, pa.p, pb.p) <= tol)
|
||||
&& (k.p - pa.p).dot(pb.p - k.p) > 0.0;
|
||||
if flat {
|
||||
let lab = pa.p.distance(pb.p);
|
||||
let (lak, lkb) = (pa.p.distance(k.p).max(1e-9), k.p.distance(pb.p).max(1e-9));
|
||||
sp.anchors[i - 1].h_out = pa.p + (pa.h_out - pa.p) * (lab / lak);
|
||||
sp.anchors[(i + 1) % n].h_in = pb.p + (pb.h_in - pb.p) * (lab / lkb);
|
||||
sp.anchors.remove(i);
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a liquify stroke to `path`. `salt` decorrelates noise between paths. Returns whether
|
||||
/// anything moved.
|
||||
pub fn apply_stroke(path: &mut PathData, dab_pts: &[Point], prm: &LiquifyParams, salt: u64) -> bool {
|
||||
let spacing = prm.detail_spacing();
|
||||
let mut changed = false;
|
||||
let mut region: Option<Rect> = None;
|
||||
for (si, sp) in path.subpaths.iter_mut().enumerate() {
|
||||
let salt = hash(salt ^ (si as u64 + 1).wrapping_mul(0x1234_5678_9abc_def1));
|
||||
for (di, c) in dab_pts.iter().enumerate() {
|
||||
let prev = if di == 0 { *c } else { dab_pts[di - 1] };
|
||||
let bb = prm.brush_bounds(*c);
|
||||
let Some(spb) = sp_bounds(sp) else { continue };
|
||||
if spb.intersect(bb).area() <= 0.0 && !(spb.width() == 0.0 || spb.height() == 0.0) {
|
||||
continue;
|
||||
}
|
||||
subdivide(sp, prm, *c, spacing);
|
||||
if apply_dab(sp, prm, *c, prev, di as u64, salt) {
|
||||
changed = true;
|
||||
region = Some(region.map_or(bb, |r| r.union(bb)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed && let Some(r) = region {
|
||||
let tol = prm.simplify / 100.0 * spacing * 0.02;
|
||||
for sp in &mut path.subpaths {
|
||||
simplify(sp, tol, r);
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
fn sp_bounds(sp: &SubPath) -> Option<Rect> {
|
||||
let mut it = sp.anchors.iter().flat_map(|a| [a.p, a.h_in, a.h_out]);
|
||||
let f = it.next()?;
|
||||
Some(it.fold(Rect::from_points(f, f), |r, p| r.union_pt(p)))
|
||||
}
|
||||
|
||||
// ---------- the tool ----------
|
||||
|
||||
pub struct LiquifyTool {
|
||||
pub params: LiquifyParams,
|
||||
points: Vec<Point>,
|
||||
active: bool,
|
||||
/// Alt-drag resizes the brush from this centre.
|
||||
sizing: Option<Point>,
|
||||
hover: Option<Point>,
|
||||
}
|
||||
|
||||
impl LiquifyTool {
|
||||
pub fn new(id: &str) -> Self {
|
||||
let kind = LiquifyKind::parse(id).unwrap_or(LiquifyKind::Warp);
|
||||
Self { params: LiquifyParams::new(kind), points: vec![], active: false, sizing: None, hover: None }
|
||||
}
|
||||
|
||||
/// The command + params for the stroke so far.
|
||||
pub fn command(&self, cx: &ToolContext) -> (String, Value) {
|
||||
let mut v = self.params.to_json();
|
||||
v["points"] = Value::Array(self.points.iter().map(|p| json!([p.x, p.y])).collect());
|
||||
if !cx.selection.is_empty() {
|
||||
v["ids"] = crate::json_ids(&cx.selection.objects);
|
||||
}
|
||||
("object.liquify".into(), v)
|
||||
}
|
||||
}
|
||||
|
||||
impl Tool for LiquifyTool {
|
||||
fn id(&self) -> &'static str {
|
||||
self.params.kind.id()
|
||||
}
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
let p = ev.pos;
|
||||
self.hover = Some(p);
|
||||
match ev.kind {
|
||||
PointerKind::Down if ev.mods.alt => {
|
||||
self.sizing = Some(p);
|
||||
vec![]
|
||||
}
|
||||
PointerKind::Down => {
|
||||
self.points = vec![p];
|
||||
self.active = true;
|
||||
let (c, v) = self.command(cx);
|
||||
vec![Action::Begin(self.params.kind.label().into()), Action::Preview(c, v)]
|
||||
}
|
||||
PointerKind::Drag => {
|
||||
if let Some(c) = self.sizing {
|
||||
self.params.width = ((p.x - c.x).abs() * 2.0).max(1.0);
|
||||
self.params.height = if ev.mods.shift { self.params.width } else { ((p.y - c.y).abs() * 2.0).max(1.0) };
|
||||
self.hover = Some(c);
|
||||
return vec![];
|
||||
}
|
||||
if !self.active || self.points.last().is_some_and(|l| l.distance(p) < cx.tol(2.0)) {
|
||||
return vec![];
|
||||
}
|
||||
self.points.push(p);
|
||||
let (c, v) = self.command(cx);
|
||||
vec![Action::Preview(c, v)]
|
||||
}
|
||||
PointerKind::Up => {
|
||||
if self.sizing.take().is_some() {
|
||||
return vec![];
|
||||
}
|
||||
if !self.active {
|
||||
return vec![];
|
||||
}
|
||||
self.active = false;
|
||||
self.points.clear();
|
||||
vec![Action::Commit]
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
|
||||
let Some(c) = self.hover else { return vec![] };
|
||||
vec![Overlay::Path { path: ellipse_path(c, self.params.width / 2.0, self.params.height / 2.0, self.params.angle), color: [0x80, 0x80, 0x80], width: 1.0, dashed: false }]
|
||||
}
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
|
||||
Cursor::Crosshair
|
||||
}
|
||||
fn options(&self) -> Value {
|
||||
self.params.to_json()
|
||||
}
|
||||
fn set_option(&mut self, key: &str, value: &Value) {
|
||||
let mut v = self.params.to_json();
|
||||
v[key] = value.clone();
|
||||
if key == "diameter" {
|
||||
v["width"] = value.clone();
|
||||
v["height"] = value.clone();
|
||||
}
|
||||
if let Some(p) = LiquifyParams::from_json(&v) {
|
||||
self.params = p;
|
||||
}
|
||||
}
|
||||
fn busy(&self) -> bool {
|
||||
self.active
|
||||
}
|
||||
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||
self.hover = None;
|
||||
self.sizing = None;
|
||||
if std::mem::take(&mut self.active) { vec![Action::Commit] } else { vec![] }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use drawcraft_geom::shapes;
|
||||
|
||||
fn square() -> PathData {
|
||||
shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0))
|
||||
}
|
||||
|
||||
fn prm(kind: LiquifyKind) -> LiquifyParams {
|
||||
LiquifyParams { intensity: 1.0, ..LiquifyParams::new(kind) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dabs_are_evenly_spaced() {
|
||||
let d = dabs(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0)], 10.0);
|
||||
assert_eq!(d.len(), 11);
|
||||
assert!(d.windows(2).all(|w| (w[0].distance(w[1]) - 10.0).abs() < 1e-9));
|
||||
assert_eq!(dabs(&[Point::new(5.0, 5.0)], 10.0), vec![Point::new(5.0, 5.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warp_is_deterministic_and_localized() {
|
||||
let pts = [Point::new(200.0, 100.0), Point::new(240.0, 100.0)];
|
||||
let p = prm(LiquifyKind::Warp);
|
||||
let d = dabs(&pts, p.dab_spacing());
|
||||
let mut a = square();
|
||||
let mut b = square();
|
||||
assert!(apply_stroke(&mut a, &d, &p, 7));
|
||||
apply_stroke(&mut b, &d, &p, 7);
|
||||
assert_eq!(a, b, "same input, same output");
|
||||
// The right edge bulged outward near y = 100; the left edge (x = 0) did not move.
|
||||
let bb = a.bounds().unwrap();
|
||||
assert!(bb.x1 > 210.0, "{bb:?}");
|
||||
assert!(a.anchors().all(|(_, _, an)| an.p.x > 150.0 || (an.p.x - 0.0).abs() < 1e-9));
|
||||
assert!(a.anchor_count() > 4, "detail added anchors");
|
||||
// Anchors outside the brush are untouched.
|
||||
assert!(a.anchors().any(|(_, _, an)| an.p == Point::new(0.0, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pucker_and_bloat_move_toward_and_away_from_centre() {
|
||||
let c = Point::new(200.0, 100.0);
|
||||
let p = prm(LiquifyKind::Pucker);
|
||||
let mut a = square();
|
||||
apply_stroke(&mut a, &[c], &p, 1);
|
||||
let near = |pd: &PathData| pd.anchors().filter(|(_, _, an)| an.p.distance(c) < 50.0).map(|(_, _, an)| an.p.x).fold(f64::MIN, f64::max);
|
||||
assert!(near(&a) <= 200.0 + 1e-9);
|
||||
let mut b = square();
|
||||
apply_stroke(&mut b, &[c, c, c], &prm(LiquifyKind::Bloat), 1);
|
||||
assert!(b.bounds().unwrap().x1 > 200.5);
|
||||
// Bloat pushes the edge point at the brush centre along the normal (x only).
|
||||
assert!(b.anchors().any(|(_, _, an)| (an.p.y - 100.0).abs() < 1e-6 && an.p.x > 200.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twirl_rotates_about_the_brush_centre() {
|
||||
let c = Point::new(100.0, 100.0);
|
||||
let mut pd = PathData::single(SubPath::polyline(&[Point::new(80.0, 100.0), Point::new(120.0, 100.0)], false));
|
||||
let p = LiquifyParams { detail: 1.0, simplify: 0.0, ..prm(LiquifyKind::Twirl) };
|
||||
apply_stroke(&mut pd, &[c], &p, 0);
|
||||
// Rotation preserves the distance to the centre.
|
||||
for (_, _, a) in pd.anchors() {
|
||||
let d0 = if a.p.x < 100.0 { 20.0 } else { 20.0 };
|
||||
if a.p.distance(c) > 1.0 {
|
||||
assert!((a.p.distance(c) - d0).abs() < 1e-6 || a.p.distance(c) < 20.0);
|
||||
}
|
||||
}
|
||||
let first = pd.subpaths[0].anchors[0].p;
|
||||
assert!(first.y != 100.0 && (first.distance(c) - 20.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noisy_tools_are_reproducible() {
|
||||
for kind in [LiquifyKind::Scallop, LiquifyKind::Crystallize, LiquifyKind::Wrinkle] {
|
||||
let p = LiquifyParams { horizontal: 1.0, ..prm(kind) };
|
||||
let d = dabs(&[Point::new(200.0, 50.0), Point::new(200.0, 150.0)], p.dab_spacing());
|
||||
let (mut a, mut b) = (square(), square());
|
||||
assert!(apply_stroke(&mut a, &d, &p, 3), "{kind:?}");
|
||||
apply_stroke(&mut b, &d, &p, 3);
|
||||
assert_eq!(a, b, "{kind:?}");
|
||||
assert_ne!(a, square());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_round_trip_through_json() {
|
||||
let mut p = LiquifyParams::new(LiquifyKind::Wrinkle);
|
||||
p.horizontal = 0.3;
|
||||
p.width = 60.0;
|
||||
let q = LiquifyParams::from_json(&p.to_json()).unwrap();
|
||||
assert_eq!(p, q);
|
||||
let r = LiquifyParams::from_json(&json!({"tool": "warp", "diameter": 40, "intensity": 80})).unwrap();
|
||||
assert_eq!((r.width, r.height, r.intensity), (40.0, 40.0, 0.8));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
//! Arc-length helpers: width points live at a fraction (0..1) of a subpath's length.
|
||||
|
||||
use drawcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveDeriv};
|
||||
use drawcraft_geom::{PathData, Point, SubPath, Vec2};
|
||||
|
||||
const ACC: f64 = 1e-4;
|
||||
|
||||
/// Arc length of each segment.
|
||||
pub fn seg_lengths(sp: &SubPath) -> Vec<f64> {
|
||||
(0..sp.segment_count()).map(|i| sp.segment(i).arclen(ACC)).collect()
|
||||
}
|
||||
|
||||
/// Fraction of the subpath's length at segment `seg`, parameter `t`.
|
||||
pub fn fraction_at(sp: &SubPath, seg: usize, t: f64) -> f64 {
|
||||
let l = seg_lengths(sp);
|
||||
let total: f64 = l.iter().sum();
|
||||
if total <= 1e-12 {
|
||||
return 0.0;
|
||||
}
|
||||
let before: f64 = l[..seg.min(l.len())].iter().sum();
|
||||
let part = if seg < l.len() { sp.segment(seg).subsegment(0.0..t.clamp(0.0, 1.0)).arclen(ACC) } else { 0.0 };
|
||||
((before + part) / total).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Point and unit tangent at fraction `f` of the subpath's length.
|
||||
pub fn eval_fraction(sp: &SubPath, f: f64) -> Option<(Point, Vec2)> {
|
||||
let l = seg_lengths(sp);
|
||||
let total: f64 = l.iter().sum();
|
||||
if l.is_empty() {
|
||||
return sp.anchors.first().map(|a| (a.p, Vec2::new(1.0, 0.0)));
|
||||
}
|
||||
let mut target = f.clamp(0.0, 1.0) * total;
|
||||
for (i, len) in l.iter().enumerate() {
|
||||
if target <= *len || i == l.len() - 1 {
|
||||
let c = sp.segment(i);
|
||||
let t = if *len > 1e-12 { c.inv_arclen(target.min(*len), ACC) } else { 0.0 };
|
||||
return Some((c.eval(t), tangent(&c, t)));
|
||||
}
|
||||
target -= len;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Unit tangent of a cubic at `t` (falls back to the chord for degenerate derivatives).
|
||||
pub fn tangent(c: &drawcraft_geom::CubicBez, t: f64) -> Vec2 {
|
||||
let d = c.deriv().eval(t).to_vec2();
|
||||
let d = if d.hypot() > 1e-9 {
|
||||
d
|
||||
} else {
|
||||
let d2 = c.eval((t + 0.01).min(1.0)) - c.eval((t - 0.01).max(0.0));
|
||||
if d2.hypot() > 1e-12 { d2 } else { c.p3 - c.p0 }
|
||||
};
|
||||
let h = d.hypot();
|
||||
if h > 1e-12 { d / h } else { Vec2::new(1.0, 0.0) }
|
||||
}
|
||||
|
||||
/// The left normal of a tangent in y-down document space (left of the direction of travel).
|
||||
pub fn left_normal(t: Vec2) -> Vec2 {
|
||||
Vec2::new(t.y, -t.x)
|
||||
}
|
||||
|
||||
/// Nearest point of `path` to `p`: (subpath index, fraction along it, point, unit tangent, distance).
|
||||
pub fn nearest_fraction(path: &PathData, p: Point) -> Option<(usize, f64, Point, Vec2, f64)> {
|
||||
let (si, seg, t, q, d) = path.nearest(p)?;
|
||||
let sp = &path.subpaths[si];
|
||||
Some((si, fraction_at(sp, seg, t), q, tangent(&sp.segment(seg), t), d))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fractions_round_trip_on_a_polyline() {
|
||||
let sp = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0)], false);
|
||||
assert!((fraction_at(&sp, 0, 0.5) - 0.25).abs() < 1e-6);
|
||||
assert!((fraction_at(&sp, 1, 0.5) - 0.75).abs() < 1e-6);
|
||||
let (p, t) = eval_fraction(&sp, 0.75).unwrap();
|
||||
assert!((p - Point::new(100.0, 50.0)).hypot() < 1e-3);
|
||||
assert!((t - Vec2::new(0.0, 1.0)).hypot() < 1e-6);
|
||||
// Walking +x, the left side is up (y-down space).
|
||||
assert_eq!(left_normal(Vec2::new(1.0, 0.0)), Vec2::new(0.0, -1.0));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,759 @@
|
|||
//! Perspective Grid: the grid model, plane homographies, overlays, and the Perspective Grid
|
||||
//! (Shift+P) and Perspective Selection (Shift+V) tools.
|
||||
//!
|
||||
//! The grid is stored in the document (`Document.unknown["perspectiveGrid"]`) as a
|
||||
//! [`PerspectiveGrid`]. Every plane is a homography `H` from plane coordinates `(u, v)` in points
|
||||
//! to the page, built from homogeneous columns: `H = [c1 c2 O]` where `O` is the ground-level
|
||||
//! origin (the corner where the planes meet) and `c1`/`c2` are the images of the plane axes'
|
||||
//! points at infinity — a vanishing point scaled by `1/distance` for receding axes, a direction
|
||||
//! for axes parallel to the picture plane. Along a receding axis `u ↦ O + (VP − O)·u/(u + d)`,
|
||||
//! the projective foreshortening of a camera `d` points from the origin.
|
||||
//!
|
||||
//! - 1-point: Left = the flat front plane, Right = the side wall receding to `vpLeft`, Ground
|
||||
//! recedes to `vpLeft` too.
|
||||
//! - 2-point: Left recedes to `vpLeft`, Right to `vpRight`, verticals stay vertical; Ground spans
|
||||
//! both vanishing points.
|
||||
//! - 3-point: as 2-point, but verticals converge to `vpVertical`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use drawcraft_doc::{Document, NodeId};
|
||||
use drawcraft_geom::{Point, Rect};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext};
|
||||
|
||||
/// Key under `Document.unknown`.
|
||||
pub const DOC_KEY: &str = "perspectiveGrid";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Plane {
|
||||
#[default]
|
||||
Left,
|
||||
Right,
|
||||
Ground,
|
||||
/// No active plane (widget key 4).
|
||||
None,
|
||||
}
|
||||
|
||||
impl Plane {
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
Some(match s {
|
||||
"left" => Self::Left,
|
||||
"right" => Self::Right,
|
||||
"ground" | "horizontal" => Self::Ground,
|
||||
"none" => Self::None,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Left => "left",
|
||||
Self::Right => "right",
|
||||
Self::Ground => "ground",
|
||||
Self::None => "none",
|
||||
}
|
||||
}
|
||||
/// Illustrator's plane colours: left blue, right orange, ground green.
|
||||
pub fn color(self) -> [u8; 3] {
|
||||
match self {
|
||||
Self::Left => [0x33, 0x66, 0xff],
|
||||
Self::Right => [0xff, 0x8c, 0x1a],
|
||||
Self::Ground => [0x2e, 0xb8, 0x4a],
|
||||
Self::None => [0x99, 0x99, 0x99],
|
||||
}
|
||||
}
|
||||
pub const ALL: [Plane; 3] = [Plane::Left, Plane::Right, Plane::Ground];
|
||||
}
|
||||
|
||||
/// A 3×3 projective transform (row-major).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Homography(pub [[f64; 3]; 3]);
|
||||
|
||||
impl Homography {
|
||||
/// From the images of (1,0,0), (0,1,0) and (0,0,1).
|
||||
pub fn from_cols(c1: [f64; 3], c2: [f64; 3], c3: [f64; 3]) -> Self {
|
||||
Self([[c1[0], c2[0], c3[0]], [c1[1], c2[1], c3[1]], [c1[2], c2[2], c3[2]]])
|
||||
}
|
||||
/// Map a point; None when it lands on or beyond the horizon (w ≤ 0).
|
||||
pub fn apply(&self, p: Point) -> Option<Point> {
|
||||
let m = &self.0;
|
||||
let x = m[0][0] * p.x + m[0][1] * p.y + m[0][2];
|
||||
let y = m[1][0] * p.x + m[1][1] * p.y + m[1][2];
|
||||
let w = m[2][0] * p.x + m[2][1] * p.y + m[2][2];
|
||||
if w <= 1e-9 || !x.is_finite() || !y.is_finite() {
|
||||
return None;
|
||||
}
|
||||
Some(Point::new(x / w, y / w))
|
||||
}
|
||||
pub fn det(&self) -> f64 {
|
||||
let m = &self.0;
|
||||
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
|
||||
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
|
||||
}
|
||||
/// Inverse.
|
||||
pub fn inverse(&self) -> Option<Self> {
|
||||
let m = &self.0;
|
||||
let d = self.det();
|
||||
if d.abs() < 1e-15 {
|
||||
return None;
|
||||
}
|
||||
let c = |r0: usize, c0: usize, r1: usize, c1: usize| m[r0][c0] * m[r1][c1] - m[r0][c1] * m[r1][c0];
|
||||
let adj = [
|
||||
[c(1, 1, 2, 2), -c(0, 1, 2, 2), c(0, 1, 1, 2)],
|
||||
[-c(1, 0, 2, 2), c(0, 0, 2, 2), -c(0, 0, 1, 2)],
|
||||
[c(1, 0, 2, 1), -c(0, 0, 2, 1), c(0, 0, 1, 1)],
|
||||
];
|
||||
let mut out = [[0.0; 3]; 3];
|
||||
for (r, row) in adj.iter().enumerate() {
|
||||
for (k, v) in row.iter().enumerate() {
|
||||
out[r][k] = v / d;
|
||||
}
|
||||
}
|
||||
// H⁻¹·(x, y, 1) = (u, v, 1)/w_H, so points in front of the camera keep w > 0.
|
||||
Some(Self(out))
|
||||
}
|
||||
}
|
||||
|
||||
fn two() -> u8 {
|
||||
2
|
||||
}
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// The perspective grid definition (Define Grid) plus view state.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PerspectiveGrid {
|
||||
/// 1, 2 or 3-point perspective.
|
||||
#[serde(default = "two")]
|
||||
pub kind: u8,
|
||||
/// Ground-level origin (where the planes meet), page coordinates.
|
||||
pub origin: [f64; 2],
|
||||
/// Horizon height (page y).
|
||||
pub horizon: f64,
|
||||
/// Left vanishing point x (the single vanishing point in 1-point perspective).
|
||||
pub vp_left: f64,
|
||||
/// Right vanishing point x.
|
||||
pub vp_right: f64,
|
||||
/// Third (vertical) vanishing point, 3-point only.
|
||||
pub vp_vertical: [f64; 2],
|
||||
/// Viewing distance (points): how fast receding axes foreshorten.
|
||||
pub distance: f64,
|
||||
/// Gridline every (points, in plane units).
|
||||
pub cell: f64,
|
||||
/// Horizontal extent of the planes (points, plane units).
|
||||
pub extent: f64,
|
||||
/// Vertical extent of the wall planes (points, plane units).
|
||||
pub height: f64,
|
||||
#[serde(default = "yes")]
|
||||
pub visible: bool,
|
||||
/// Active plane (Plane Switching widget).
|
||||
#[serde(default)]
|
||||
pub plane: Plane,
|
||||
/// Objects attached to planes (node id → plane).
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub attached: BTreeMap<String, Plane>,
|
||||
}
|
||||
|
||||
impl PerspectiveGrid {
|
||||
/// Illustrator-like preset for `kind` fitted to an artboard.
|
||||
pub fn preset(kind: u8, ab: Rect) -> Self {
|
||||
let (w, h) = (ab.width().max(1.0), ab.height().max(1.0));
|
||||
let kind = kind.clamp(1, 3);
|
||||
let horizon = ab.y0 + 0.42 * h;
|
||||
let ground = ab.y0 + 0.78 * h;
|
||||
let ox = if kind == 1 { ab.x0 + 0.3 * w } else { ab.x0 + 0.5 * w };
|
||||
let (vl, vr) = if kind == 1 { (ab.x0 + 0.55 * w, ab.x1) } else { (ab.x0 + 0.02 * w, ab.x1 - 0.02 * w) };
|
||||
Self {
|
||||
kind,
|
||||
origin: [ox, ground],
|
||||
horizon,
|
||||
vp_left: vl,
|
||||
vp_right: vr,
|
||||
vp_vertical: [ox, horizon - 3.0 * (ground - horizon)],
|
||||
distance: 0.5 * w,
|
||||
cell: (w / 30.0).max(1.0).round(),
|
||||
extent: 0.6 * w,
|
||||
height: (ground - ab.y0) * 0.8,
|
||||
visible: true,
|
||||
plane: Plane::Left,
|
||||
attached: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The document's grid, if one was defined.
|
||||
pub fn from_doc(doc: &Document) -> Option<Self> {
|
||||
doc.unknown.get(DOC_KEY).and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
}
|
||||
|
||||
/// The document's grid, or the default 2-point preset for the first artboard (hidden).
|
||||
pub fn effective(doc: &Document) -> Self {
|
||||
Self::from_doc(doc).unwrap_or_else(|| {
|
||||
let ab = doc.artboards.first().map(|a| a.rect).unwrap_or(Rect::new(0.0, 0.0, 612.0, 792.0));
|
||||
Self { visible: false, ..Self::preset(2, ab) }
|
||||
})
|
||||
}
|
||||
|
||||
/// Store into the document.
|
||||
pub fn store(&self, doc: &mut Document) {
|
||||
let mut g = self.clone();
|
||||
g.attached.retain(|k, _| k.parse::<u64>().ok().is_some_and(|id| doc.node(NodeId(id)).is_some()));
|
||||
if let Ok(v) = serde_json::to_value(&g) {
|
||||
doc.unknown.insert(DOC_KEY.into(), v);
|
||||
}
|
||||
}
|
||||
|
||||
/// Definition only (for tool previews and the Define Grid dialog).
|
||||
pub fn definition_json(&self) -> Value {
|
||||
let mut v = serde_json::to_value(self).unwrap_or(Value::Null);
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.remove("attached");
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Merge `patch` (any subset of the fields) into this grid.
|
||||
pub fn merged(&self, patch: &Value) -> Result<Self, String> {
|
||||
let mut v = serde_json::to_value(self).map_err(|e| e.to_string())?;
|
||||
if let (Some(o), Some(p)) = (v.as_object_mut(), patch.as_object()) {
|
||||
for (k, val) in p {
|
||||
if k == "ids" || k == "attached" {
|
||||
continue;
|
||||
}
|
||||
o.insert(k.clone(), val.clone());
|
||||
}
|
||||
}
|
||||
let g: Self = serde_json::from_value(v).map_err(|e| e.to_string())?;
|
||||
g.validate()?;
|
||||
Ok(g)
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if !(1..=3).contains(&self.kind) {
|
||||
return Err("kind must be 1, 2 or 3".into());
|
||||
}
|
||||
let nums = [self.origin[0], self.origin[1], self.horizon, self.vp_left, self.vp_right, self.vp_vertical[0], self.vp_vertical[1], self.distance, self.cell, self.extent, self.height];
|
||||
if nums.iter().any(|v| !v.is_finite() || v.abs() > 4.0e6) {
|
||||
return Err("grid values must be finite".into());
|
||||
}
|
||||
if self.distance <= 0.0 || self.cell <= 0.0 || self.extent <= 0.0 || self.height <= 0.0 {
|
||||
return Err("distance, cell, extent and height must be positive".into());
|
||||
}
|
||||
if (self.origin[1] - self.horizon).abs() < 1e-6 {
|
||||
return Err("ground level must differ from the horizon".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn planes(&self) -> [Plane; 3] {
|
||||
Plane::ALL
|
||||
}
|
||||
|
||||
/// The homography of `plane` (plane coordinates in points → page).
|
||||
pub fn homography(&self, plane: Plane) -> Option<Homography> {
|
||||
let l = 1.0 / self.distance.max(1e-9);
|
||||
let o = [self.origin[0], self.origin[1], 1.0];
|
||||
let vl = [self.vp_left * l, self.horizon * l, l];
|
||||
let vr = [self.vp_right * l, self.horizon * l, l];
|
||||
let up = if self.kind == 3 { [self.vp_vertical[0] * l, self.vp_vertical[1] * l, l] } else { [0.0, -1.0, 0.0] };
|
||||
let flat = [1.0, 0.0, 0.0];
|
||||
let (c1, c2) = match (self.kind, plane) {
|
||||
(_, Plane::None) => return None,
|
||||
(1, Plane::Left) => (flat, [0.0, -1.0, 0.0]),
|
||||
(1, Plane::Right) => (vl, [0.0, -1.0, 0.0]),
|
||||
(1, Plane::Ground) => (flat, vl),
|
||||
(_, Plane::Left) => (vl, up),
|
||||
(_, Plane::Right) => (vr, up),
|
||||
(_, Plane::Ground) => (vr, vl),
|
||||
};
|
||||
let h = Homography::from_cols(c1, c2, o);
|
||||
(h.det().abs() > 1e-15).then_some(h)
|
||||
}
|
||||
|
||||
/// Plane-coordinate extent of a plane.
|
||||
pub fn domain(&self, plane: Plane) -> Rect {
|
||||
match plane {
|
||||
Plane::Ground => Rect::new(0.0, 0.0, self.extent, self.extent),
|
||||
_ => Rect::new(0.0, 0.0, self.extent, self.height),
|
||||
}
|
||||
}
|
||||
|
||||
/// Page → plane coordinates.
|
||||
pub fn to_plane(&self, plane: Plane, p: Point) -> Option<Point> {
|
||||
self.homography(plane)?.inverse()?.apply(p)
|
||||
}
|
||||
/// Plane → page coordinates.
|
||||
pub fn to_page(&self, plane: Plane, q: Point) -> Option<Point> {
|
||||
self.homography(plane)?.apply(q)
|
||||
}
|
||||
|
||||
/// The map that puts flat art with bounds `b` onto `plane`: an axis-aligned affine map sends
|
||||
/// `b` to the plane rectangle whose opposite corners project to `b`'s top-left and
|
||||
/// bottom-right, then the plane homography projects it (so a drawn rectangle keeps the two
|
||||
/// corners the user dragged between).
|
||||
pub fn attach_map(&self, plane: Plane, b: Rect) -> Option<impl Fn(Point) -> Option<Point>> {
|
||||
let h = self.homography(plane)?;
|
||||
let hi = h.inverse()?;
|
||||
let a = hi.apply(Point::new(b.x0, b.y0))?;
|
||||
let c = hi.apply(Point::new(b.x1, b.y1))?;
|
||||
let sx = if b.width().abs() > 1e-9 { (c.x - a.x) / b.width() } else { 1.0 };
|
||||
let sy = if b.height().abs() > 1e-9 { (c.y - a.y) / b.height() } else { sx.abs() * if c.y < a.y { -1.0 } else { 1.0 } };
|
||||
let sx = if b.width().abs() > 1e-9 { sx } else { sy.abs() };
|
||||
let (x0, y0) = (b.x0, b.y0);
|
||||
Some(move |p: Point| h.apply(Point::new(a.x + (p.x - x0) * sx, a.y + (p.y - y0) * sy)))
|
||||
}
|
||||
|
||||
/// The map that slides art lying on `plane` by the plane-space offset between `from` and `to`.
|
||||
pub fn move_map(&self, plane: Plane, from: Point, to: Point) -> Option<impl Fn(Point) -> Option<Point>> {
|
||||
let h = self.homography(plane)?;
|
||||
let hi = h.inverse()?;
|
||||
let d = hi.apply(to)? - hi.apply(from)?;
|
||||
Some(move |p: Point| h.apply(hi.apply(p)? + d))
|
||||
}
|
||||
|
||||
pub fn attached_plane(&self, id: NodeId) -> Option<Plane> {
|
||||
self.attached.get(&id.0.to_string()).copied()
|
||||
}
|
||||
|
||||
/// Grid lines of `plane` (page space).
|
||||
pub fn lines(&self, plane: Plane) -> Vec<(Point, Point)> {
|
||||
let Some(h) = self.homography(plane) else { return vec![] };
|
||||
let d = self.domain(plane);
|
||||
let mut out = vec![];
|
||||
let step = |len: f64| {
|
||||
let mut s = self.cell;
|
||||
while len / s > 120.0 {
|
||||
s *= 2.0;
|
||||
}
|
||||
s
|
||||
};
|
||||
let (su, sv) = (step(d.width()), step(d.height()));
|
||||
let mut u = 0.0;
|
||||
while u <= d.width() + 1e-9 {
|
||||
if let (Some(a), Some(b)) = (h.apply(Point::new(u, 0.0)), h.apply(Point::new(u, d.height()))) {
|
||||
out.push((a, b));
|
||||
}
|
||||
u += su;
|
||||
}
|
||||
let mut v = 0.0;
|
||||
while v <= d.height() + 1e-9 {
|
||||
if let (Some(a), Some(b)) = (h.apply(Point::new(0.0, v)), h.apply(Point::new(d.width(), v))) {
|
||||
out.push((a, b));
|
||||
}
|
||||
v += sv;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Plane Switching widget geometry: (plane, quad) faces and the "no plane" circle (centre, r).
|
||||
pub fn widget(&self, doc: &Document, tol: f64) -> (Vec<(Plane, [Point; 4])>, (Point, f64)) {
|
||||
let ab = doc.artboards.first().map(|a| a.rect).unwrap_or(Rect::new(0.0, 0.0, 612.0, 792.0));
|
||||
let s = 40.0 * tol;
|
||||
let (cx, cy) = (ab.x0 + 10.0 * tol + s / 2.0, ab.y0 + 10.0 * tol + s / 2.0);
|
||||
let p = |dx: f64, dy: f64| Point::new(cx + dx * s, cy + dy * s);
|
||||
let faces = vec![
|
||||
(Plane::Left, [p(-0.5, -0.25), p(0.0, 0.0), p(0.0, 0.5), p(-0.5, 0.25)]),
|
||||
(Plane::Right, [p(0.0, 0.0), p(0.5, -0.25), p(0.5, 0.25), p(0.0, 0.5)]),
|
||||
(Plane::Ground, [p(0.0, -0.5), p(0.5, -0.25), p(0.0, 0.0), p(-0.5, -0.25)]),
|
||||
];
|
||||
(faces, (p(0.62, 0.62), 0.12 * s))
|
||||
}
|
||||
|
||||
/// Which widget control is at `p`.
|
||||
pub fn widget_hit(&self, doc: &Document, tol: f64, p: Point) -> Option<Plane> {
|
||||
let (faces, (c, r)) = self.widget(doc, tol);
|
||||
if p.distance(c) <= r * 1.5 {
|
||||
return Some(Plane::None);
|
||||
}
|
||||
faces.into_iter().find(|(_, q)| in_quad(q, p)).map(|(pl, _)| pl)
|
||||
}
|
||||
}
|
||||
|
||||
fn in_quad(q: &[Point; 4], p: Point) -> bool {
|
||||
let mut sign = 0.0;
|
||||
for i in 0..4 {
|
||||
let (a, b) = (q[i], q[(i + 1) % 4]);
|
||||
let c = (b - a).cross(p - a);
|
||||
if c.abs() < 1e-12 {
|
||||
continue;
|
||||
}
|
||||
if sign == 0.0 {
|
||||
sign = c.signum();
|
||||
} else if c.signum() != sign {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Grid overlays (lines, horizon, vanishing points, widget) drawn whenever the grid is visible or
|
||||
/// a perspective tool is active. `tol` = document units per screen pixel.
|
||||
pub fn grid_overlays(doc: &Document, tol: f64, tool_id: &str) -> Vec<Overlay> {
|
||||
let g = PerspectiveGrid::effective(doc);
|
||||
let tool = matches!(tool_id, "perspectiveGrid" | "perspectiveSelection");
|
||||
if !g.visible && !tool {
|
||||
return vec![];
|
||||
}
|
||||
let mut out = vec![];
|
||||
for pl in g.planes() {
|
||||
let color = pl.color();
|
||||
for (a, b) in g.lines(pl) {
|
||||
out.push(Overlay::Line { a, b, color, dashed: false });
|
||||
}
|
||||
if pl == g.plane
|
||||
&& let Some(h) = g.homography(pl)
|
||||
{
|
||||
let d = g.domain(pl);
|
||||
let corners: Vec<Point> = [(d.x0, d.y0), (d.x1, d.y0), (d.x1, d.y1), (d.x0, d.y1)].iter().filter_map(|&(x, y)| h.apply(Point::new(x, y))).collect();
|
||||
if corners.len() == 4 {
|
||||
out.push(Overlay::Path { path: crate::xform::polygon(&corners, true), color, width: 2.0, dashed: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
let xs = [g.vp_left, g.vp_right, g.origin[0]];
|
||||
let (x0, x1) = (xs.iter().cloned().fold(f64::MAX, f64::min) - 50.0 * tol, xs.iter().cloned().fold(f64::MIN, f64::max) + 50.0 * tol);
|
||||
out.push(Overlay::Line { a: Point::new(x0, g.horizon), b: Point::new(x1, g.horizon), color: [0x60, 0x60, 0x60], dashed: true });
|
||||
// Widget.
|
||||
let (faces, (c, r)) = g.widget(doc, tol);
|
||||
for (pl, q) in faces {
|
||||
let [cr, cg, cb] = pl.color();
|
||||
out.push(Overlay::Highlight { quad: q, color: [cr, cg, cb, if pl == g.plane { 230 } else { 70 }] });
|
||||
out.push(Overlay::Path { path: crate::xform::polygon(&q, true), color: [0x40, 0x40, 0x40], width: 1.0, dashed: false });
|
||||
}
|
||||
out.push(Overlay::Path { path: super::ellipse_path(c, r, r, 0.0), color: if g.plane == Plane::None { [0x20, 0x20, 0x20] } else { [0x99, 0x99, 0x99] }, width: 1.5, dashed: false });
|
||||
out
|
||||
}
|
||||
|
||||
// ---------- Perspective Grid tool ----------
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Handle {
|
||||
VpLeft,
|
||||
VpRight,
|
||||
VpVertical,
|
||||
Horizon,
|
||||
Origin,
|
||||
ExtentLeft,
|
||||
ExtentRight,
|
||||
Height,
|
||||
}
|
||||
|
||||
fn handles(g: &PerspectiveGrid) -> Vec<(Handle, Point)> {
|
||||
let mut v = vec![(Handle::VpLeft, Point::new(g.vp_left, g.horizon))];
|
||||
if g.kind != 1 {
|
||||
v.push((Handle::VpRight, Point::new(g.vp_right, g.horizon)));
|
||||
}
|
||||
if g.kind == 3 {
|
||||
v.push((Handle::VpVertical, Point::new(g.vp_vertical[0], g.vp_vertical[1])));
|
||||
}
|
||||
v.push((Handle::Origin, Point::new(g.origin[0], g.origin[1])));
|
||||
let (lp, rp) = (Plane::Left, Plane::Right);
|
||||
if let Some(p) = g.to_page(lp, Point::new(g.extent, 0.0)) {
|
||||
v.push((Handle::ExtentLeft, p));
|
||||
}
|
||||
if let Some(p) = g.to_page(rp, Point::new(g.extent, 0.0)) {
|
||||
v.push((Handle::ExtentRight, p));
|
||||
}
|
||||
if let Some(p) = g.to_page(Plane::Left, Point::new(0.0, g.height)) {
|
||||
v.push((Handle::Height, p));
|
||||
}
|
||||
// The horizon handle sits on the horizon above the origin.
|
||||
v.push((Handle::Horizon, Point::new(g.origin[0], g.horizon)));
|
||||
v
|
||||
}
|
||||
|
||||
/// Apply a handle drag to a copy of the grid.
|
||||
fn drag_handle(g: &PerspectiveGrid, h: Handle, p: Point) -> PerspectiveGrid {
|
||||
let mut n = g.clone();
|
||||
match h {
|
||||
Handle::VpLeft => {
|
||||
n.vp_left = p.x;
|
||||
n.horizon = p.y;
|
||||
}
|
||||
Handle::VpRight => {
|
||||
n.vp_right = p.x;
|
||||
n.horizon = p.y;
|
||||
}
|
||||
Handle::VpVertical => n.vp_vertical = [p.x, p.y],
|
||||
Handle::Horizon => n.horizon = p.y,
|
||||
Handle::Origin => n.origin = [p.x, p.y],
|
||||
Handle::ExtentLeft | Handle::ExtentRight | Handle::Height => {
|
||||
let pl = if h == Handle::ExtentRight { Plane::Right } else { Plane::Left };
|
||||
if let Some(q) = g.to_plane(pl, p) {
|
||||
match h {
|
||||
Handle::Height => n.height = q.y.max(g.cell),
|
||||
_ => n.extent = q.x.max(g.cell),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if n.validate().is_ok() { n } else { g.clone() }
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct PerspectiveGridTool {
|
||||
drag: Option<Handle>,
|
||||
}
|
||||
|
||||
impl Tool for PerspectiveGridTool {
|
||||
fn id(&self) -> &'static str {
|
||||
"perspectiveGrid"
|
||||
}
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
let p = ev.pos;
|
||||
let g = PerspectiveGrid::effective(cx.doc);
|
||||
match ev.kind {
|
||||
PointerKind::Down => {
|
||||
let mut pre = vec![];
|
||||
if !g.visible {
|
||||
pre.push(Action::Exec("perspective.grid.show".into(), json!({"visible": true})));
|
||||
}
|
||||
if let Some(pl) = g.widget_hit(cx.doc, cx.tol(1.0), p) {
|
||||
pre.push(Action::Exec("perspective.plane.set".into(), json!({"plane": pl.id()})));
|
||||
return pre;
|
||||
}
|
||||
let tol = cx.tol(6.0);
|
||||
if let Some((h, _)) = handles(&g).into_iter().filter(|(_, q)| q.distance(p) <= tol).min_by(|a, b| a.1.distance(p).total_cmp(&b.1.distance(p))) {
|
||||
self.drag = Some(h);
|
||||
pre.push(Action::Begin("Edit Perspective Grid".into()));
|
||||
}
|
||||
pre
|
||||
}
|
||||
PointerKind::Drag => match self.drag {
|
||||
Some(h) => vec![Action::Preview("perspective.grid.set".into(), drag_handle(&g, h, p).definition_json())],
|
||||
None => vec![],
|
||||
},
|
||||
PointerKind::Up => match self.drag.take() {
|
||||
Some(_) => vec![Action::Commit],
|
||||
None => vec![],
|
||||
},
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let g = PerspectiveGrid::effective(cx.doc);
|
||||
handles(&g)
|
||||
.into_iter()
|
||||
.map(|(h, p)| match h {
|
||||
Handle::VpLeft | Handle::VpRight | Handle::VpVertical => Overlay::Anchor { p, color: [0x20, 0x20, 0x20], filled: true, size: 7.0 },
|
||||
_ => Overlay::Anchor { p, color: [0x20, 0x20, 0x20], filled: false, size: 6.0 },
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor {
|
||||
let g = PerspectiveGrid::effective(cx.doc);
|
||||
let tol = cx.tol(6.0);
|
||||
match handles(&g).into_iter().find(|(_, q)| q.distance(p) <= tol).map(|h| h.0) {
|
||||
Some(Handle::Horizon | Handle::Height) => Cursor::ResizeV,
|
||||
Some(Handle::ExtentLeft | Handle::ExtentRight) => Cursor::ResizeH,
|
||||
Some(_) => Cursor::Move,
|
||||
None => Cursor::Arrow,
|
||||
}
|
||||
}
|
||||
fn busy(&self) -> bool {
|
||||
self.drag.is_some()
|
||||
}
|
||||
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||
if self.drag.take().is_some() { vec![Action::Commit] } else { vec![] }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Perspective Selection tool ----------
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct PerspectiveSelectionTool {
|
||||
drag: Option<(Vec<NodeId>, Point)>,
|
||||
}
|
||||
|
||||
impl Tool for PerspectiveSelectionTool {
|
||||
fn id(&self) -> &'static str {
|
||||
"perspectiveSelection"
|
||||
}
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
let p = ev.pos;
|
||||
let g = PerspectiveGrid::effective(cx.doc);
|
||||
match ev.kind {
|
||||
PointerKind::Down => {
|
||||
if g.visible
|
||||
&& let Some(pl) = g.widget_hit(cx.doc, cx.tol(1.0), p)
|
||||
{
|
||||
return vec![Action::Exec("perspective.plane.set".into(), json!({"plane": pl.id()}))];
|
||||
}
|
||||
let Some(h) = drawcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options()) else {
|
||||
return vec![Action::Exec("select.set".into(), json!({"ids": []}))];
|
||||
};
|
||||
let top = h.top_object(cx.isolation);
|
||||
let mut acts = vec![];
|
||||
let ids = if cx.selection.objects.contains(&top) {
|
||||
cx.selection.objects.clone()
|
||||
} else {
|
||||
acts.push(Action::Exec("select.set".into(), json!({"ids": [top.0]})));
|
||||
vec![top]
|
||||
};
|
||||
acts.push(Action::Begin("Move in Perspective".into()));
|
||||
self.drag = Some((ids, p));
|
||||
acts
|
||||
}
|
||||
PointerKind::Drag => match &self.drag {
|
||||
Some((ids, from)) => {
|
||||
let mut v = json!({"ids": crate::json_ids(ids), "from": [from.x, from.y], "to": [p.x, p.y]});
|
||||
if g.plane != Plane::None {
|
||||
v["plane"] = json!(g.plane.id());
|
||||
}
|
||||
vec![Action::Preview("perspective.move".into(), v)]
|
||||
}
|
||||
None => vec![],
|
||||
},
|
||||
PointerKind::Up => match self.drag.take() {
|
||||
Some(_) => vec![Action::Commit],
|
||||
None => vec![],
|
||||
},
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
// Attached objects in the selection get their plane's colour outline.
|
||||
let g = PerspectiveGrid::effective(cx.doc);
|
||||
let mut out = vec![];
|
||||
for id in &cx.selection.objects {
|
||||
if let (Some(pl), Some(n)) = (g.attached_plane(*id), cx.doc.node(*id))
|
||||
&& let Some(b) = n.geometric_bounds()
|
||||
{
|
||||
out.push(Overlay::Path { path: crate::xform::polygon(&crate::xform::rect_corners(b), true), color: pl.color(), width: 1.0, dashed: true });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
|
||||
Cursor::Arrow
|
||||
}
|
||||
fn busy(&self) -> bool {
|
||||
self.drag.is_some()
|
||||
}
|
||||
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||
if self.drag.take().is_some() { vec![Action::Commit] } else { vec![] }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testutil::*;
|
||||
use drawcraft_doc::Selection;
|
||||
|
||||
fn grid(kind: u8) -> PerspectiveGrid {
|
||||
PerspectiveGrid::preset(kind, Rect::new(0.0, 0.0, 800.0, 600.0))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn homography_maps_plane_corners_to_the_vanishing_points() {
|
||||
for kind in [1u8, 2, 3] {
|
||||
let g = grid(kind);
|
||||
for pl in Plane::ALL {
|
||||
let h = g.homography(pl).unwrap();
|
||||
// The plane origin is the ground-level origin.
|
||||
let o = h.apply(Point::new(0.0, 0.0)).unwrap();
|
||||
assert!((o - Point::new(g.origin[0], g.origin[1])).hypot() < 1e-9, "{kind} {pl:?}");
|
||||
// Far along a receding axis, points approach the vanishing point on the horizon.
|
||||
let far = h.apply(Point::new(1e9, 0.0)).unwrap();
|
||||
let expect = match (kind, pl) {
|
||||
(1, Plane::Left | Plane::Ground) => None,
|
||||
(1, Plane::Right) => Some(Point::new(g.vp_left, g.horizon)),
|
||||
(_, Plane::Left) => Some(Point::new(g.vp_left, g.horizon)),
|
||||
_ => Some(Point::new(g.vp_right, g.horizon)),
|
||||
};
|
||||
if let Some(e) = expect {
|
||||
assert!((far - e).hypot() < 1e-3, "{kind} {pl:?} {far:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2-point walls: verticals stay vertical and unforeshortened at the origin edge.
|
||||
let g = grid(2);
|
||||
let top = g.to_page(Plane::Left, Point::new(0.0, 100.0)).unwrap();
|
||||
assert!((top - Point::new(g.origin[0], g.origin[1] - 100.0)).hypot() < 1e-9);
|
||||
// Foreshortening: u = distance lands halfway to the vanishing point.
|
||||
let mid = g.to_page(Plane::Right, Point::new(g.distance, 0.0)).unwrap();
|
||||
let want = Point::new(g.origin[0], g.origin[1]).lerp(Point::new(g.vp_right, g.horizon), 0.5);
|
||||
assert!((mid - want).hypot() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn homography_round_trips() {
|
||||
for kind in [1u8, 2, 3] {
|
||||
let g = grid(kind);
|
||||
for pl in Plane::ALL {
|
||||
for q in [Point::new(10.0, 20.0), Point::new(300.0, 5.0), Point::new(0.0, 0.0), Point::new(123.0, 77.0)] {
|
||||
let p = g.to_page(pl, q).unwrap();
|
||||
let back = g.to_plane(pl, p).unwrap();
|
||||
assert!((back - q).hypot() < 1e-6, "{kind} {pl:?} {q:?} → {back:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(grid(2).homography(Plane::None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_map_keeps_the_dragged_corners() {
|
||||
let g = grid(2);
|
||||
let b = Rect::new(450.0, 300.0, 550.0, 420.0);
|
||||
let m = g.attach_map(Plane::Right, b).unwrap();
|
||||
assert!((m(Point::new(b.x0, b.y0)).unwrap() - Point::new(b.x0, b.y0)).hypot() < 1e-6);
|
||||
assert!((m(Point::new(b.x1, b.y1)).unwrap() - Point::new(b.x1, b.y1)).hypot() < 1e-6);
|
||||
// The other corners lie on grid lines through the plane: the top-right corner is on the
|
||||
// line from the top-left corner to the right vanishing point.
|
||||
let tr = m(Point::new(b.x1, b.y0)).unwrap();
|
||||
let vp = Point::new(g.vp_right, g.horizon);
|
||||
assert!((tr - Point::new(b.x0, b.y0)).cross(vp - Point::new(b.x0, b.y0)).abs() < 1e-6);
|
||||
assert!(tr.y != b.y0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_map_slides_within_the_plane() {
|
||||
let g = grid(2);
|
||||
let from = g.to_page(Plane::Left, Point::new(50.0, 50.0)).unwrap();
|
||||
let to = g.to_page(Plane::Left, Point::new(80.0, 60.0)).unwrap();
|
||||
let m = g.move_map(Plane::Left, from, to).unwrap();
|
||||
let p = g.to_page(Plane::Left, Point::new(10.0, 10.0)).unwrap();
|
||||
let q = g.to_plane(Plane::Left, m(p).unwrap()).unwrap();
|
||||
assert!((q - Point::new(40.0, 20.0)).hypot() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_serde_and_merge() {
|
||||
let g = grid(3);
|
||||
let mut d = Document::new(800.0, 600.0);
|
||||
g.store(&mut d);
|
||||
assert_eq!(PerspectiveGrid::from_doc(&d), Some(g.clone()));
|
||||
let m = g.merged(&json!({"kind": 1, "cell": 25})).unwrap();
|
||||
assert_eq!((m.kind, m.cell), (1, 25.0));
|
||||
assert!(g.merged(&json!({"distance": -1})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_tool_widget_and_handles() {
|
||||
let mut d = Document::new(800.0, 600.0);
|
||||
grid(2).store(&mut d);
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let c = cx(&d, &s, &p);
|
||||
let g = PerspectiveGrid::effective(&d);
|
||||
let mut t = PerspectiveGridTool::default();
|
||||
// Click the right face of the widget.
|
||||
let (faces, _) = g.widget(&d, 1.0);
|
||||
let q = faces[1].1;
|
||||
let centre = Point::new((q[0].x + q[1].x + q[2].x + q[3].x) / 4.0, (q[0].y + q[1].y + q[2].y + q[3].y) / 4.0);
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, centre.x, centre.y)), vec![Action::Exec("perspective.plane.set".into(), json!({"plane": "right"}))]);
|
||||
// Drag the left vanishing point.
|
||||
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, g.vp_left, g.horizon));
|
||||
assert_eq!(acts, vec![Action::Begin("Edit Perspective Grid".into())]);
|
||||
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, g.vp_left - 40.0, g.horizon + 10.0));
|
||||
let Action::Preview(cmd, v) = &acts[0] else { panic!() };
|
||||
assert_eq!(cmd, "perspective.grid.set");
|
||||
assert_eq!(v["vpLeft"], json!(g.vp_left - 40.0));
|
||||
assert_eq!(v["horizon"], json!(g.horizon + 10.0));
|
||||
assert!(!grid_overlays(&d, 1.0, "selection").is_empty());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,227 @@
|
|||
//! Puppet Warp tool.
|
||||
//!
|
||||
//! With artwork selected the tool places three pins automatically (spread over the mesh). Click
|
||||
//! on the artwork adds a pin, Alt-click deletes one, Delete/Backspace removes the selected pin,
|
||||
//! and dragging a pin warps the art: every drag previews
|
||||
//! `object.puppetWarp {ids, pins, moved}` (pins = current pin positions, moved = the same list
|
||||
//! with the dragged pin at the pointer), committed on release as one undo step. The mesh follows
|
||||
//! the current shape, so successive drags compose.
|
||||
|
||||
use drawcraft_doc::{Document, NodeId};
|
||||
use drawcraft_geom::{BezPath, Point, Rect};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::arap::{Mesh, MeshOptions, auto_pins};
|
||||
use super::{BLUE, collect_points};
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||
|
||||
/// Outlines and bounds of the given objects (what the mesh is built over).
|
||||
pub fn mesh_input(doc: &Document, ids: &[NodeId]) -> Option<(Vec<BezPath>, Rect)> {
|
||||
let mut outlines = vec![];
|
||||
let mut pts = vec![];
|
||||
for id in ids {
|
||||
let n = doc.node(*id)?;
|
||||
collect_points(n, &mut pts);
|
||||
n.walk(&mut |c| {
|
||||
if let Some(p) = c.path_data() {
|
||||
outlines.push(p.to_bezpath());
|
||||
}
|
||||
});
|
||||
}
|
||||
let first = *pts.first()?;
|
||||
let b = pts.iter().fold(Rect::from_points(first, first), |r, p| r.union_pt(*p));
|
||||
Some((outlines, b))
|
||||
}
|
||||
|
||||
/// The mesh the engine uses for `ids` (shared so tool overlays match the command).
|
||||
pub fn mesh_for(doc: &Document, ids: &[NodeId], expand: f64) -> Option<Mesh> {
|
||||
let (o, b) = mesh_input(doc, ids)?;
|
||||
Some(Mesh::build(&o, b, MeshOptions { expand, ..Default::default() }))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct PuppetWarpTool {
|
||||
ids: Vec<NodeId>,
|
||||
pins: Vec<Point>,
|
||||
selected: Option<usize>,
|
||||
drag: Option<(usize, Point)>,
|
||||
/// Control bar: Show Mesh.
|
||||
show_mesh: bool,
|
||||
/// Control bar: Expand Mesh (points).
|
||||
expand: f64,
|
||||
}
|
||||
|
||||
impl PuppetWarpTool {
|
||||
/// Re-seed pins when the selection changed.
|
||||
fn sync(&mut self, cx: &ToolContext) {
|
||||
let ids = cx.selection.objects.clone();
|
||||
if ids == self.ids {
|
||||
return;
|
||||
}
|
||||
self.ids = ids;
|
||||
self.selected = None;
|
||||
self.pins = if self.ids.is_empty() { vec![] } else { mesh_for(cx.doc, &self.ids, self.expand_or_default()).map(|m| auto_pins(&m, 3)).unwrap_or_default() };
|
||||
}
|
||||
fn expand_or_default(&self) -> f64 {
|
||||
if self.expand > 0.0 { self.expand } else { 3.0 }
|
||||
}
|
||||
fn pin_at(&self, cx: &ToolContext, p: Point) -> Option<usize> {
|
||||
let tol = cx.tol(7.0);
|
||||
self.pins.iter().enumerate().filter(|(_, q)| q.distance(p) <= tol).min_by(|a, b| a.1.distance(p).total_cmp(&b.1.distance(p))).map(|(i, _)| i)
|
||||
}
|
||||
fn params(&self, i: usize, to: Point) -> Value {
|
||||
let mut moved = self.pins.clone();
|
||||
moved[i] = to;
|
||||
let pj = |v: &[Point]| Value::Array(v.iter().map(|p| json!([p.x, p.y])).collect());
|
||||
json!({"ids": crate::json_ids(&self.ids), "pins": pj(&self.pins), "moved": pj(&moved), "expand": self.expand_or_default()})
|
||||
}
|
||||
}
|
||||
|
||||
impl Tool for PuppetWarpTool {
|
||||
fn id(&self) -> &'static str {
|
||||
"puppetWarp"
|
||||
}
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
let p = ev.pos;
|
||||
match ev.kind {
|
||||
PointerKind::Down => {
|
||||
self.sync(cx);
|
||||
if self.ids.is_empty() {
|
||||
// Click an object to select it; pins appear on the next event.
|
||||
if let Some(h) = drawcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options()) {
|
||||
return vec![Action::Exec("select.set".into(), json!({"ids": [h.top_object(cx.isolation).0]}))];
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
if let Some(i) = self.pin_at(cx, p) {
|
||||
if ev.mods.alt {
|
||||
self.pins.remove(i);
|
||||
self.selected = None;
|
||||
return vec![];
|
||||
}
|
||||
self.selected = Some(i);
|
||||
self.drag = Some((i, p));
|
||||
return vec![Action::Begin("Puppet Warp".into())];
|
||||
}
|
||||
let inside = cx.doc.bounds_of(&self.ids, false).is_some_and(|b| b.inflate(cx.tol(4.0), cx.tol(4.0)).contains(p));
|
||||
if inside && !ev.mods.alt {
|
||||
self.pins.push(p);
|
||||
self.selected = Some(self.pins.len() - 1);
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
PointerKind::Drag => match self.drag {
|
||||
Some((i, _)) => {
|
||||
self.drag = Some((i, p));
|
||||
vec![Action::Preview("object.puppetWarp".into(), self.params(i, p))]
|
||||
}
|
||||
None => vec![],
|
||||
},
|
||||
PointerKind::Up => match self.drag.take() {
|
||||
Some((i, _)) => {
|
||||
self.pins[i] = p;
|
||||
vec![Action::Commit]
|
||||
}
|
||||
None => vec![],
|
||||
},
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
||||
if matches!(key, ToolKey::Delete | ToolKey::Backspace)
|
||||
&& let Some(i) = self.selected.take()
|
||||
&& i < self.pins.len()
|
||||
{
|
||||
self.pins.remove(i);
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let mut out = vec![];
|
||||
if self.show_mesh
|
||||
&& cx.selection.objects == self.ids
|
||||
&& let Some(m) = mesh_for(cx.doc, &self.ids, self.expand_or_default())
|
||||
{
|
||||
let mut bp = BezPath::new();
|
||||
for t in &m.tris {
|
||||
bp.move_to(m.verts[t[0]]);
|
||||
bp.line_to(m.verts[t[1]]);
|
||||
bp.line_to(m.verts[t[2]]);
|
||||
bp.close_path();
|
||||
}
|
||||
out.push(Overlay::Path { path: bp, color: [0x9a, 0x9a, 0x9a], width: 0.5, dashed: false });
|
||||
}
|
||||
let r = cx.tol(5.0);
|
||||
for (i, q) in self.pins.iter().enumerate() {
|
||||
let q = match self.drag {
|
||||
Some((d, to)) if d == i => to,
|
||||
_ => *q,
|
||||
};
|
||||
let circle = super::ellipse_path(q, r, r, 0.0);
|
||||
out.push(Overlay::Path { path: circle, color: BLUE, width: if self.selected == Some(i) { 3.0 } else { 1.5 }, dashed: false });
|
||||
out.push(Overlay::Anchor { p: q, color: BLUE, filled: self.selected == Some(i), size: 3.0 });
|
||||
}
|
||||
out
|
||||
}
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, mods: Mods) -> Cursor {
|
||||
match self.pin_at(cx, p) {
|
||||
Some(_) if mods.alt => Cursor::PenDelete,
|
||||
Some(_) => Cursor::Move,
|
||||
None => Cursor::PenAdd,
|
||||
}
|
||||
}
|
||||
fn options(&self) -> Value {
|
||||
json!({"showMesh": self.show_mesh, "expand": self.expand_or_default(), "pins": self.pins.iter().map(|p| json!([p.x, p.y])).collect::<Vec<_>>()})
|
||||
}
|
||||
fn set_option(&mut self, key: &str, value: &Value) {
|
||||
match key {
|
||||
"showMesh" => self.show_mesh = value.as_bool().unwrap_or(self.show_mesh),
|
||||
"expand" => self.expand = value.as_f64().unwrap_or(self.expand).clamp(0.0, 1000.0),
|
||||
"selectAllPins" => self.selected = None,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fn busy(&self) -> bool {
|
||||
self.drag.is_some()
|
||||
}
|
||||
fn notify(&mut self, cx: &ToolContext, _what: &str) {
|
||||
self.sync(cx);
|
||||
}
|
||||
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||
self.ids.clear();
|
||||
self.pins.clear();
|
||||
if self.drag.take().is_some() { vec![Action::Commit] } else { vec![] }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testutil::*;
|
||||
use drawcraft_doc::Selection;
|
||||
|
||||
#[test]
|
||||
fn puppet_tool_autopins_adds_and_drags() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let s = Selection { objects: vec![id], ..Default::default() };
|
||||
let p = paint();
|
||||
let c = cx(&d, &s, &p);
|
||||
let mut t = PuppetWarpTool::default();
|
||||
// Click inside the rect away from auto pins: adds a pin.
|
||||
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 130.0, 170.0));
|
||||
assert!(acts.is_empty());
|
||||
assert_eq!(t.pins.len(), 4, "3 auto pins + 1");
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 130.0, 170.0));
|
||||
// Drag the new pin.
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 130.0, 170.0)), vec![Action::Begin("Puppet Warp".into())]);
|
||||
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 120.0, 190.0));
|
||||
let Action::Preview(cmd, v) = &acts[0] else { panic!() };
|
||||
assert_eq!(cmd, "object.puppetWarp");
|
||||
assert_eq!(v["moved"][3], json!([120.0, 190.0]));
|
||||
assert_eq!(v["pins"][3], json!([130.0, 170.0]));
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 120.0, 190.0)), vec![Action::Commit]);
|
||||
// Alt-click deletes it.
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 120.0, 190.0).with_mods(Mods { alt: true, ..Default::default() }));
|
||||
assert_eq!(t.pins.len(), 3);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,328 @@
|
|||
//! Width tool (Shift+W).
|
||||
//!
|
||||
//! Hovering a stroked path shows a hollow width-point diamond with the stroke's width at that
|
||||
//! spot. Dragging outward from the path creates (or, on an existing width point's handle end,
|
||||
//! edits) a width point — symmetric by default, Alt changes only the side being dragged. Dragging
|
||||
//! a width point's centre slides it along the path. Delete/Backspace removes the selected width
|
||||
//! point. Gestures preview `stroke.widthPoint.set {id, t, left, right, index?}` (side widths in
|
||||
//! points) and commit on release; Delete runs `stroke.widthPoint.remove {id, index}`.
|
||||
|
||||
use drawcraft_doc::{Document, NodeId, NodeKind, StrokeLayer};
|
||||
use drawcraft_geom::{Point, Vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use super::pathutil::{eval_fraction, left_normal, nearest_fraction};
|
||||
use super::{BLUE, diamond};
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||
|
||||
/// Where the pointer is relative to a stroked path.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Spot {
|
||||
id: NodeId,
|
||||
sub: usize,
|
||||
/// Fraction along the subpath.
|
||||
t: f64,
|
||||
p: Point,
|
||||
/// Left normal.
|
||||
n: Vec2,
|
||||
/// Side widths (points) at `t`.
|
||||
left: f64,
|
||||
right: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Mode {
|
||||
/// Change widths; `Some(left?)` = only one side (Alt).
|
||||
Width(Option<bool>),
|
||||
/// Slide along the path.
|
||||
Move,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Drag {
|
||||
spot: Spot,
|
||||
index: Option<usize>,
|
||||
mode: Mode,
|
||||
left: f64,
|
||||
right: f64,
|
||||
t: f64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct WidthTool {
|
||||
hover: Option<Spot>,
|
||||
drag: Option<Drag>,
|
||||
/// Selected width point: (path, position t).
|
||||
selected: Option<(NodeId, f64)>,
|
||||
}
|
||||
|
||||
fn stroke_of(doc: &Document, id: NodeId) -> Option<&StrokeLayer> {
|
||||
doc.node(id)?.appearance.stroke().filter(|s| s.visible && s.width > 0.0 && !s.paint.is_none())
|
||||
}
|
||||
|
||||
/// Side widths (points) of a stroke at fraction `t`.
|
||||
pub fn sides_at(st: &StrokeLayer, t: f64) -> (f64, f64) {
|
||||
let (l, r) = st.profile.as_ref().map(|p| p.at(t)).unwrap_or((1.0, 1.0));
|
||||
(l * st.width / 2.0, r * st.width / 2.0)
|
||||
}
|
||||
|
||||
impl WidthTool {
|
||||
/// The stroked path nearest to `p` within reach (stroke half-width + a few pixels).
|
||||
fn spot_at(cx: &ToolContext, p: Point) -> Option<Spot> {
|
||||
let consider = |id: NodeId| -> Option<(f64, Spot)> {
|
||||
if !cx.doc.is_editable(id) {
|
||||
return None;
|
||||
}
|
||||
let n = cx.doc.node(id)?;
|
||||
let NodeKind::Path { path, guide: false, .. } = &n.kind else { return None };
|
||||
let st = stroke_of(cx.doc, id)?;
|
||||
let widest = st.profile.as_ref().map_or(1.0, |pr| pr.points.iter().map(|q| q.1.max(q.2)).fold(1.0, f64::max));
|
||||
let reach = widest * st.width / 2.0 + cx.tol(4.0);
|
||||
if !path.bounds()?.inflate(reach, reach).contains(p) {
|
||||
return None;
|
||||
}
|
||||
let (sub, t, q, tan, d) = nearest_fraction(path, p)?;
|
||||
if d > reach {
|
||||
return None;
|
||||
}
|
||||
let (left, right) = sides_at(st, t);
|
||||
Some((d, Spot { id, sub, t, p: q, n: left_normal(tan), left, right }))
|
||||
};
|
||||
let best_of = |ids: &mut dyn Iterator<Item = NodeId>| ids.filter_map(consider).min_by(|a, b| a.0.total_cmp(&b.0));
|
||||
if let Some(b) = best_of(&mut cx.selection.objects.iter().copied()) {
|
||||
return Some(b.1);
|
||||
}
|
||||
let mut all = vec![];
|
||||
cx.doc.walk(|n| {
|
||||
if matches!(n.kind, NodeKind::Path { .. }) {
|
||||
all.push(n.id);
|
||||
}
|
||||
});
|
||||
best_of(&mut all.into_iter().rev()).map(|b| b.1)
|
||||
}
|
||||
|
||||
/// Width points of `id`: (index, t, on-path point, left normal, left, right).
|
||||
fn points_of(cx: &ToolContext, id: NodeId, sub: usize) -> Vec<(usize, f64, Point, Vec2, f64, f64)> {
|
||||
let Some(st) = stroke_of(cx.doc, id) else { return vec![] };
|
||||
let Some(pr) = &st.profile else { return vec![] };
|
||||
let Some(NodeKind::Path { path, .. }) = cx.doc.node(id).map(|n| &n.kind) else { return vec![] };
|
||||
let Some(sp) = path.subpaths.get(sub) else { return vec![] };
|
||||
pr.points
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, (t, l, r))| {
|
||||
let (p, tan) = eval_fraction(sp, *t)?;
|
||||
Some((i, *t, p, left_normal(tan), l * st.width / 2.0, r * st.width / 2.0))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn params(d: &Drag) -> serde_json::Value {
|
||||
let mut v = json!({"id": d.spot.id.0, "t": d.t, "left": d.left, "right": d.right});
|
||||
if let Some(i) = d.index {
|
||||
v["index"] = json!(i);
|
||||
}
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
impl Tool for WidthTool {
|
||||
fn id(&self) -> &'static str {
|
||||
"width"
|
||||
}
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
let p = ev.pos;
|
||||
match ev.kind {
|
||||
PointerKind::Move => {
|
||||
self.hover = Self::spot_at(cx, p);
|
||||
vec![]
|
||||
}
|
||||
PointerKind::Down => {
|
||||
let Some(spot) = Self::spot_at(cx, p) else {
|
||||
self.selected = None;
|
||||
return vec![];
|
||||
};
|
||||
let tol = cx.tol(5.0);
|
||||
// An existing width point under the pointer: its centre (move) or a handle end (width).
|
||||
let mut hit = None;
|
||||
for (i, t, q, n, l, r) in Self::points_of(cx, spot.id, spot.sub) {
|
||||
if q.distance(p) <= tol {
|
||||
hit = Some((i, t, q, n, l, r, Mode::Move));
|
||||
break;
|
||||
}
|
||||
if (q + n * l).distance(p) <= tol {
|
||||
hit = Some((i, t, q, n, l, r, Mode::Width(ev.mods.alt.then_some(true))));
|
||||
break;
|
||||
}
|
||||
if (q - n * r).distance(p) <= tol {
|
||||
hit = Some((i, t, q, n, l, r, Mode::Width(ev.mods.alt.then_some(false))));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let drag = match hit {
|
||||
Some((i, t, q, n, l, r, mode)) => {
|
||||
Drag { spot: Spot { t, p: q, n, left: l, right: r, ..spot }, index: Some(i), mode, left: l, right: r, t }
|
||||
}
|
||||
None => {
|
||||
let side = (p - spot.p).dot(spot.n) >= 0.0;
|
||||
Drag { spot, index: None, mode: Mode::Width(ev.mods.alt.then_some(side)), left: spot.left, right: spot.right, t: spot.t }
|
||||
}
|
||||
};
|
||||
self.selected = Some((spot.id, drag.t));
|
||||
self.drag = Some(drag);
|
||||
vec![Action::Begin("Width Point".into())]
|
||||
}
|
||||
PointerKind::Drag => {
|
||||
let Some(d) = &mut self.drag else { return vec![] };
|
||||
match d.mode {
|
||||
Mode::Width(side) => {
|
||||
let off = (p - d.spot.p).dot(d.spot.n);
|
||||
match side {
|
||||
None => {
|
||||
d.left = off.abs();
|
||||
d.right = off.abs();
|
||||
}
|
||||
Some(true) => d.left = off.max(0.0),
|
||||
Some(false) => d.right = (-off).max(0.0),
|
||||
}
|
||||
}
|
||||
Mode::Move => {
|
||||
if let Some(NodeKind::Path { path, .. }) = cx.doc.node(d.spot.id).map(|n| &n.kind)
|
||||
&& let Some(sp) = path.subpaths.get(d.spot.sub)
|
||||
{
|
||||
let one = drawcraft_geom::PathData::single(sp.clone());
|
||||
if let Some((_, t, q, tan, _)) = nearest_fraction(&one, p) {
|
||||
d.t = t;
|
||||
d.spot.p = q;
|
||||
d.spot.n = left_normal(tan);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let v = Self::params(d);
|
||||
vec![Action::Preview("stroke.widthPoint.set".into(), v)]
|
||||
}
|
||||
PointerKind::Up => match self.drag.take() {
|
||||
Some(d) => {
|
||||
self.selected = Some((d.spot.id, d.t));
|
||||
vec![Action::Commit]
|
||||
}
|
||||
None => vec![],
|
||||
},
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
||||
if !matches!(key, ToolKey::Delete | ToolKey::Backspace) {
|
||||
return vec![];
|
||||
}
|
||||
let Some((id, t)) = self.selected else { return vec![] };
|
||||
let Some(pr) = stroke_of(cx.doc, id).and_then(|s| s.profile.as_ref()) else { return vec![] };
|
||||
let Some((index, _)) = pr.points.iter().enumerate().min_by(|a, b| (a.1.0 - t).abs().total_cmp(&(b.1.0 - t).abs())) else { return vec![] };
|
||||
self.selected = None;
|
||||
vec![Action::Exec("stroke.widthPoint.remove".into(), json!({"id": id.0, "index": index}))]
|
||||
}
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let mut out = vec![];
|
||||
let r = cx.tol(4.0);
|
||||
let focus = self.drag.map(|d| d.spot).or(self.hover);
|
||||
let show = |id: NodeId, sub: usize, out: &mut Vec<Overlay>| {
|
||||
for (_, t, q, n, l, rr) in Self::points_of(cx, id, sub) {
|
||||
let sel = self.selected.is_some_and(|(sid, st)| sid == id && (st - t).abs() < 1e-6);
|
||||
out.push(Overlay::Line { a: q + n * l, b: q - n * rr, color: BLUE, dashed: false });
|
||||
out.push(Overlay::Path { path: diamond(q, r), color: BLUE, width: if sel { 2.5 } else { 1.0 }, dashed: false });
|
||||
out.push(Overlay::Handle { p: q + n * l, color: BLUE });
|
||||
out.push(Overlay::Handle { p: q - n * rr, color: BLUE });
|
||||
}
|
||||
};
|
||||
if let Some(s) = focus {
|
||||
show(s.id, s.sub, &mut out);
|
||||
}
|
||||
if let Some((id, _)) = self.selected
|
||||
&& focus.is_none_or(|s| s.id != id)
|
||||
{
|
||||
show(id, 0, &mut out);
|
||||
}
|
||||
if let Some(d) = &self.drag {
|
||||
let q = d.spot.p;
|
||||
out.push(Overlay::Line { a: q + d.spot.n * d.left, b: q - d.spot.n * d.right, color: BLUE, dashed: false });
|
||||
out.push(Overlay::Path { path: diamond(q, r), color: BLUE, width: 2.0, dashed: false });
|
||||
out.push(Overlay::Measure { p: q + d.spot.n * (d.left + r * 3.0), text: format!("W: {:.2} pt", d.left + d.right) });
|
||||
} else if let Some(h) = &self.hover {
|
||||
out.push(Overlay::Line { a: h.p + h.n * h.left, b: h.p - h.n * h.right, color: BLUE, dashed: true });
|
||||
out.push(Overlay::Path { path: diamond(h.p, r), color: BLUE, width: 1.0, dashed: false });
|
||||
}
|
||||
out
|
||||
}
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
|
||||
Cursor::Crosshair
|
||||
}
|
||||
fn busy(&self) -> bool {
|
||||
self.drag.is_some()
|
||||
}
|
||||
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||
self.hover = None;
|
||||
if self.drag.take().is_some() { vec![Action::Commit] } else { vec![] }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testutil::*;
|
||||
use drawcraft_doc::{Appearance, Node, Selection, WidthProfile};
|
||||
use drawcraft_geom::{PathData, SubPath};
|
||||
|
||||
fn doc_line() -> (Document, NodeId) {
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let mut ap = Appearance::default_art();
|
||||
ap.stroke_mut().unwrap().width = 10.0;
|
||||
let path = PathData::single(SubPath::polyline(&[Point::new(100.0, 200.0), Point::new(300.0, 200.0)], false));
|
||||
d.insert(Some(l), 0, Node::path(id, path, ap)).unwrap();
|
||||
(d, id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_outward_creates_symmetric_width_point() {
|
||||
let (d, id) = doc_line();
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let c = cx(&d, &s, &p);
|
||||
let mut t = WidthTool::default();
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 201.0)), vec![Action::Begin("Width Point".into())]);
|
||||
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 150.0, 220.0));
|
||||
let Action::Preview(cmd, v) = &acts[0] else { panic!("{acts:?}") };
|
||||
assert_eq!(cmd, "stroke.widthPoint.set");
|
||||
assert_eq!(v["id"], json!(id.0));
|
||||
assert!((v["t"].as_f64().unwrap() - 0.25).abs() < 1e-3);
|
||||
assert!((v["left"].as_f64().unwrap() - 20.0).abs() < 1e-6 && (v["right"].as_f64().unwrap() - 20.0).abs() < 1e-6);
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 150.0, 220.0)), vec![Action::Commit]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_drag_changes_one_side_and_move_slides() {
|
||||
let (mut d, id) = doc_line();
|
||||
let n = d.node_mut(id).unwrap();
|
||||
n.appearance.stroke_mut().unwrap().profile = Some(WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 2.0, 2.0), (1.0, 1.0, 1.0)] });
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let c = cx(&d, &s, &p);
|
||||
let mut t = WidthTool::default();
|
||||
// Alt-drag the left handle end (y = 200 - 10) further up.
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 200.0, 190.0).with_mods(alt));
|
||||
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 200.0, 170.0).with_mods(alt));
|
||||
let Action::Preview(_, v) = &acts[0] else { panic!() };
|
||||
assert_eq!(v["index"], json!(1));
|
||||
assert!((v["left"].as_f64().unwrap() - 30.0).abs() < 1e-6 && (v["right"].as_f64().unwrap() - 10.0).abs() < 1e-6);
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 200.0, 170.0));
|
||||
// Drag the centre along the path.
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 200.0, 200.0));
|
||||
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 205.0));
|
||||
let Action::Preview(_, v) = &acts[0] else { panic!() };
|
||||
assert!((v["t"].as_f64().unwrap() - 0.75).abs() < 1e-3);
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 250.0, 205.0));
|
||||
let del = t.key(&c, ToolKey::Delete, Mods::default());
|
||||
assert!(matches!(&del[0], Action::Exec(c, _) if c == "stroke.widthPoint.remove"));
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ pub mod bbox;
|
|||
pub mod builder;
|
||||
pub mod catalog;
|
||||
pub mod direct;
|
||||
pub mod distort;
|
||||
pub mod draw2;
|
||||
pub mod guides;
|
||||
pub mod meshblend;
|
||||
|
|
@ -255,6 +256,7 @@ pub fn create(id: &str) -> Box<dyn Tool> {
|
|||
.or_else(|| draw2::create(other))
|
||||
.or_else(|| xform::create(other))
|
||||
.or_else(|| meshblend::create(other))
|
||||
.or_else(|| distort::create(other))
|
||||
.unwrap_or_else(|| Box::new(NoopTool(tool_info(other).map(|t| t.id).unwrap_or("selection")))),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ fn title(kind: &str) -> &'static str {
|
|||
"splitIntoGrid" => "Split Into Grid",
|
||||
"documentSetup" => "Document Setup",
|
||||
"preferences" => "Preferences",
|
||||
"shortcuts" => "Keyboard Shortcuts",
|
||||
"artboardOptions" => "Artboard Options",
|
||||
"allTools" => "All Tools",
|
||||
"exportForScreens" => "Export for Screens",
|
||||
|
|
@ -121,6 +122,12 @@ fn title(kind: &str) -> &'static str {
|
|||
/// Apply the open dialog (OK).
|
||||
pub fn confirm(app: &mut DrawcraftApp) -> Result<Value, String> {
|
||||
let Some(d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) };
|
||||
match d.kind.as_str() {
|
||||
"preferences" => return crate::prefs_dialog::confirm(app),
|
||||
"shortcuts" => return crate::shortcut_editor::confirm(app),
|
||||
"newWorkspace" | "manageWorkspaces" => return crate::workspaces::confirm(app),
|
||||
_ => {}
|
||||
}
|
||||
let copy = d.bool("copy");
|
||||
if d.kind == "recolor" {
|
||||
let map = recolor_map(&d);
|
||||
|
|
@ -206,11 +213,6 @@ pub fn confirm(app: &mut DrawcraftApp) -> Result<Value, String> {
|
|||
"simplify" => app.run("object.path.simplify", json!({"tolerance": d.f64("tolerance", 1.0)})),
|
||||
"splitIntoGrid" => app.run("object.path.splitIntoGrid", json!({"rows": d.f64("rows", 2.0), "columns": d.f64("columns", 2.0), "gutter": d.f64("gutter", 12.0)})),
|
||||
"documentSetup" => app.run("document.setUnits", json!({"units": d.str("units")})),
|
||||
"preferences" => {
|
||||
app.session.prefs.keyboard_increment = d.f64("keyboardIncrement", 1.0).max(0.001);
|
||||
app.session.prefs.scale_strokes = d.bool("scaleStrokes");
|
||||
Ok(Value::Null)
|
||||
}
|
||||
_ => Ok(Value::Null),
|
||||
};
|
||||
app.ui.dialog = None;
|
||||
|
|
@ -256,6 +258,12 @@ pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) {
|
|||
app.ui.about = open;
|
||||
}
|
||||
let Some(mut d) = app.ui.dialog.clone() else { return };
|
||||
match d.kind.as_str() {
|
||||
"preferences" => return crate::prefs_dialog::show(app, ctx),
|
||||
"shortcuts" => return crate::shortcut_editor::show(app, ctx),
|
||||
"newWorkspace" | "manageWorkspaces" => return crate::workspaces::show(app, ctx),
|
||||
_ => {}
|
||||
}
|
||||
let t = Tokens::get(ctx);
|
||||
let mut ok = false;
|
||||
let mut cancel = false;
|
||||
|
|
@ -331,9 +339,6 @@ pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) {
|
|||
}
|
||||
check(ui, &mut d, "copy", "Copy (make a transformed copy)");
|
||||
}
|
||||
if d.kind == "preferences" {
|
||||
check(ui, &mut d, "scaleStrokes", "Scale Strokes & Effects");
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -54,7 +54,15 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
|||
("file.exportForScreens", "Export for Screens…", "Cmd+Alt+E", "{path?, scale?}"),
|
||||
("file.documentSetup", "Document Setup…", "Cmd+Alt+P", "{}"),
|
||||
("file.newDialog", "New…", "Cmd+N", "{} opens the New Document dialog"),
|
||||
("edit.preferences", "Preferences…", "Cmd+K", "{}"),
|
||||
("edit.preferences", "Preferences…", "Cmd+K", "{category?} open Preferences (engine: prefs.get / prefs.set / prefs.list)"),
|
||||
("edit.keyboardShortcuts", "Keyboard Shortcuts…", "Cmd+Alt+Shift+K", "{}"),
|
||||
("shortcuts.set", "Set Keyboard Shortcut", "", "{id: command id or tool:<id>, shortcut: \"Cmd+Shift+K\" | \"\" (none) | null (default), force?}"),
|
||||
("shortcuts.list", "List Keyboard Shortcuts", "", "{query?} → [{id, label, group, shortcut, default, overridden}]"),
|
||||
("shortcuts.conflicts", "Keyboard Shortcut Conflicts", "", "{} → [{shortcut, ids}]"),
|
||||
("shortcuts.reset", "Reset Keyboard Shortcuts", "", "{}"),
|
||||
("shortcuts.preset", "Keyboard Shortcut Set", "", "{name: \"DrawCraft Defaults\" | \"Illustrator Defaults\"}"),
|
||||
("shortcuts.export", "Export Keyboard Shortcuts…", "", "{path?}"),
|
||||
("shortcuts.import", "Import Keyboard Shortcuts…", "", "{path? | data?}"),
|
||||
("view.outline", "Outline", "Cmd+Y", "{} toggle Outline/Preview"),
|
||||
("view.pixelPreview", "Pixel Preview", "Cmd+Alt+Y", "{}"),
|
||||
("view.zoomIn", "Zoom In", "Cmd+=", "{}"),
|
||||
|
|
@ -84,7 +92,13 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
|||
("window.dock", "Panels", "Tab", "{} show/hide all panels"),
|
||||
("window.panel", "Show Panel", "", "{panel: id} e.g. layers, swatches, stroke"),
|
||||
("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"),
|
||||
("window.workspace.reset", "Reset Essentials", "", "{}"),
|
||||
("window.workspace", "Workspace", "", "{name} switch workspace (Essentials, Essentials Classic, Painting, …)"),
|
||||
("window.workspace.reset", "Reset Essentials", "", "{} reset the current workspace"),
|
||||
("window.workspace.new", "New Workspace…", "", "{name?} save the current layout"),
|
||||
("window.workspace.manage", "Manage Workspaces…", "", "{}"),
|
||||
("window.workspace.delete", "Delete Workspace", "", "{name}"),
|
||||
("window.workspace.rename", "Rename Workspace", "", "{name, to}"),
|
||||
("window.workspace.list", "List Workspaces", "", "{}"),
|
||||
("window.newWindow", "New Window", "", "{}"),
|
||||
("tool.select", "Select Tool", "", "{tool: id} (see tools)"),
|
||||
("tool.setOption", "Tool Option", "", "{key, value}"),
|
||||
|
|
@ -103,6 +117,9 @@ pub fn run_ui_command(app: &mut DrawcraftApp, id: &str, p: &Value) -> Option<Res
|
|||
if let Some(r) = crate::panels::character::intercept_text_command(app, id) {
|
||||
return Some(r);
|
||||
}
|
||||
if let Some(r) = crate::shortcut_editor::run_command(app, id, p).or_else(|| crate::workspaces::run_command(app, id, p)) {
|
||||
return Some(r);
|
||||
}
|
||||
let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string);
|
||||
let flag = |b: &mut bool| {
|
||||
*b = !*b;
|
||||
|
|
@ -166,10 +183,7 @@ pub fn run_ui_command(app: &mut DrawcraftApp, id: &str, p: &Value) -> Option<Res
|
|||
Ok(Value::Null)
|
||||
}
|
||||
"edit.preferences" => {
|
||||
app.ui.dialog = Some(crate::state::Dialog::new(
|
||||
"preferences",
|
||||
json!({"keyboardIncrement": app.session.prefs.keyboard_increment, "scaleStrokes": app.session.prefs.scale_strokes}),
|
||||
));
|
||||
crate::prefs_dialog::open(app, s("category").as_deref());
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"view.outline" => flag(&mut app.ui.view.outline),
|
||||
|
|
@ -269,16 +283,12 @@ pub fn run_ui_command(app: &mut DrawcraftApp, id: &str, p: &Value) -> Option<Res
|
|||
"window.brightness" => match s("brightness").as_deref().and_then(Brightness::parse) {
|
||||
Some(b) => {
|
||||
app.ui.brightness = b;
|
||||
app.session.prefs.ui_brightness = b.id().into();
|
||||
app.canvas.key = None;
|
||||
Ok(json!(b.id()))
|
||||
}
|
||||
None => Err("brightness must be dark|mediumDark|mediumLight|light".into()),
|
||||
},
|
||||
"window.workspace.reset" => {
|
||||
let b = app.ui.brightness;
|
||||
app.ui = crate::state::UiState { brightness: b, ..Default::default() };
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"window.newWindow" => Err("multiple windows land with M11.5".into()),
|
||||
"tool.select" => match s("tool") {
|
||||
Some(t) if drawcraft_tools::tool_info(&t).is_some() => {
|
||||
|
|
@ -370,6 +380,7 @@ pub fn checked(app: &DrawcraftApp, id: &str, p: &Value) -> Option<bool> {
|
|||
_ => app.ui.open_panel.as_deref() == Some(panel),
|
||||
}
|
||||
}
|
||||
"window.workspace" => p.get("name").and_then(Value::as_str) == Some(app.ui.workspace.as_str()),
|
||||
"window.brightness" => p.get("brightness").and_then(Value::as_str).and_then(Brightness::parse) == Some(app.ui.brightness),
|
||||
"file.documentColorMode" => {
|
||||
let cmyk = app.session.active().is_some_and(|d| d.doc.color_mode == drawcraft_engine::doc::ColorMode::Cmyk);
|
||||
|
|
@ -384,6 +395,7 @@ pub fn dynamic_label(app: &DrawcraftApp, id: &str, label: &str) -> String {
|
|||
let v = &app.ui.view;
|
||||
match id {
|
||||
"view.outline" => if v.outline { "Preview" } else { "Outline" }.into(),
|
||||
"window.workspace.reset" => format!("Reset {}", app.ui.workspace),
|
||||
"view.edges" => if v.edges { "Hide Edges" } else { "Show Edges" }.into(),
|
||||
"view.artboards" => if v.artboards { "Hide Artboards" } else { "Show Artboards" }.into(),
|
||||
"view.rulers" => if v.rulers { "Hide Rulers" } else { "Show Rulers" }.into(),
|
||||
|
|
@ -398,11 +410,9 @@ pub fn dynamic_label(app: &DrawcraftApp, id: &str, label: &str) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
/// Effective shortcut of a command: the user's override (Edit → Keyboard Shortcuts) or the default.
|
||||
pub fn shortcut_of(id: &str) -> Option<&'static str> {
|
||||
if let Some(c) = drawcraft_engine::find_command(id) {
|
||||
return c.shortcut;
|
||||
}
|
||||
UI_COMMANDS.iter().find(|c| c.0 == id).map(|c| c.2).filter(|s| !s.is_empty())
|
||||
crate::shortcut_editor::command_shortcut(id)
|
||||
}
|
||||
|
||||
/// Is a command currently enabled?
|
||||
|
|
@ -538,7 +548,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
|||
todos("Color Settings…", "Cmd+Shift+K"),
|
||||
todo("Assign Profile…"),
|
||||
Sep,
|
||||
todos("Keyboard Shortcuts…", "Cmd+Alt+Shift+K"),
|
||||
c("Keyboard Shortcuts…", "edit.keyboardShortcuts"),
|
||||
c("Preferences…", "edit.preferences"),
|
||||
],
|
||||
),
|
||||
|
|
@ -893,17 +903,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
|||
vec![
|
||||
c("New Window", "window.newWindow"),
|
||||
sub("Arrange", vec![todo("Cascade"), todo("Tile"), todo("Float in Window"), todo("Consolidate All Windows")]),
|
||||
sub(
|
||||
"Workspace",
|
||||
vec![
|
||||
todo("Essentials"),
|
||||
todo("Essentials Classic"),
|
||||
todo("Painting"),
|
||||
todo("Typography"),
|
||||
Sep,
|
||||
c("Reset Essentials", "window.workspace.reset"),
|
||||
],
|
||||
),
|
||||
sub("Workspace", crate::workspaces::menu_items()),
|
||||
Sep,
|
||||
c("Control", "window.control"),
|
||||
c("Contextual Task Bar", "window.taskBar"),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,352 @@
|
|||
//! Edit → Preferences (⌘K): Illustrator-style category list + fields, rendered from the engine's
|
||||
//! preference table ([`PREF_SPECS`]). The dialog edits a working copy in `Dialog::fields`
|
||||
//! (one field per preference key, so agents can `ui.dialog.set` any of them) and OK applies it
|
||||
//! through `prefs.set`. Also: persistence of engine prefs inside `UiState` and the per-frame
|
||||
//! application of the UI-side preferences (brightness, canvas colour, UI scaling, render threads).
|
||||
|
||||
use drawcraft_engine::Prefs;
|
||||
use drawcraft_engine::cmd::prefscmds::{PREF_CATEGORIES, PREF_SPECS, PrefKind};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::{self, Brightness, Tokens};
|
||||
use crate::{DrawcraftApp, widgets};
|
||||
|
||||
/// UI-only fields shown in the dialog (backed by `UiState`, not engine prefs).
|
||||
const UI_FIELDS: &[(&str, &str, &str)] = &[
|
||||
("__smartGuides", "Smart Guides", "Smart Guides (View → Smart Guides)"),
|
||||
("__snapToGrid", "Guides & Grid", "Snap to Grid"),
|
||||
("__taskBar", "General", "Enable Contextual Task Bar"),
|
||||
];
|
||||
|
||||
pub fn open(app: &mut DrawcraftApp, category: Option<&str>) {
|
||||
let mut fields: Map<String, Value> = app.session.prefs.to_json().as_object().cloned().unwrap_or_default();
|
||||
let cat = category.and_then(|c| PREF_CATEGORIES.iter().find(|x| x.eq_ignore_ascii_case(c))).copied().unwrap_or(PREF_CATEGORIES[0]);
|
||||
fields.insert("__category".into(), json!(cat));
|
||||
fields.insert("__smartGuides".into(), json!(app.ui.view.smart_guides));
|
||||
fields.insert("__snapToGrid".into(), json!(app.ui.view.snap_to_grid));
|
||||
fields.insert("__taskBar".into(), json!(app.ui.task_bar));
|
||||
app.ui.dialog = Some(Dialog { kind: "preferences".into(), fields });
|
||||
}
|
||||
|
||||
/// OK: validate and apply every preference in the dialog (nothing changes on error).
|
||||
pub fn confirm(app: &mut DrawcraftApp) -> Result<Value, String> {
|
||||
let Some(d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) };
|
||||
let values: Map<String, Value> = d.fields.iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone())).collect();
|
||||
app.run("prefs.set", json!({ "values": values }))?;
|
||||
app.ui.view.smart_guides = d.fields.get("__smartGuides").and_then(Value::as_bool).unwrap_or(app.ui.view.smart_guides);
|
||||
app.ui.view.snap_to_grid = d.fields.get("__snapToGrid").and_then(Value::as_bool).unwrap_or(app.ui.view.snap_to_grid);
|
||||
app.ui.task_bar = d.fields.get("__taskBar").and_then(Value::as_bool).unwrap_or(app.ui.task_bar);
|
||||
app.ui.dialog = None;
|
||||
app.ui.engine_prefs = app.session.prefs.to_json();
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Copy the engine prefs into `UiState` so the host persists them with the UI state.
|
||||
pub fn snapshot(app: &mut DrawcraftApp) {
|
||||
app.ui.engine_prefs = app.session.prefs.to_json();
|
||||
}
|
||||
|
||||
/// Restore engine prefs from `UiState` after the host loaded saved UI state.
|
||||
pub fn restore(app: &mut DrawcraftApp) {
|
||||
let mut p: Prefs = serde_json::from_value(app.ui.engine_prefs.clone()).unwrap_or_default();
|
||||
if app.ui.engine_prefs.is_null() {
|
||||
// Older preference files: keep the saved brightness.
|
||||
p.ui_brightness = app.ui.brightness.id().into();
|
||||
}
|
||||
app.session.apply_prefs(p);
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
struct Applied {
|
||||
brightness: Brightness,
|
||||
white_canvas: bool,
|
||||
threads: i32,
|
||||
}
|
||||
|
||||
/// Per frame: push UI-side preferences into egui / the renderer when they change.
|
||||
pub fn apply_runtime(app: &mut DrawcraftApp, ctx: &egui::Context) {
|
||||
let p = &app.session.prefs;
|
||||
if let Some(b) = Brightness::parse(&p.ui_brightness)
|
||||
&& b != app.ui.brightness
|
||||
{
|
||||
app.ui.brightness = b;
|
||||
}
|
||||
let want = Applied { brightness: app.ui.brightness, white_canvas: p.canvas_color == "white", threads: p.render_threads };
|
||||
let id = egui::Id::new("dc-applied-prefs");
|
||||
let prev: Option<Applied> = ctx.data(|d| d.get_temp::<Option<Applied>>(id)).flatten();
|
||||
if prev != Some(want) {
|
||||
theme::apply(ctx, want.brightness);
|
||||
if want.white_canvas {
|
||||
let mut t = Tokens::get(ctx);
|
||||
t.pasteboard = egui::Color32::WHITE;
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::NULL, t));
|
||||
}
|
||||
if prev.is_some_and(|pr| pr.threads != want.threads) {
|
||||
drawcraft_render::set_default_threads(u16::try_from(want.threads).ok());
|
||||
app.canvas.renderer.threads = drawcraft_render::default_threads();
|
||||
app.canvas.worker = None;
|
||||
app.canvas.worker_started = false;
|
||||
}
|
||||
app.canvas.key = None;
|
||||
ctx.data_mut(|d| d.insert_temp(id, Some(want)));
|
||||
}
|
||||
let z = app.session.prefs.ui_scaling.clamp(0.75, 2.0) as f32;
|
||||
if (ctx.zoom_factor() - z).abs() > 1e-3 {
|
||||
ctx.set_zoom_factor(z);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) {
|
||||
let Some(mut d) = app.ui.dialog.clone() else { return };
|
||||
let t = Tokens::get(ctx);
|
||||
let (mut ok, mut cancel, mut reset) = (false, false, false);
|
||||
egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| {
|
||||
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
|
||||
});
|
||||
let cat = d.str("__category");
|
||||
let cat = PREF_CATEGORIES.iter().find(|c| **c == cat).copied().unwrap_or(PREF_CATEGORIES[0]);
|
||||
egui::Window::new("Preferences")
|
||||
.id(egui::Id::new("dialog-preferences"))
|
||||
.order(egui::Order::Foreground)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.title_bar(false)
|
||||
.anchor(egui::Align2::CENTER_CENTER, [0.0, -20.0])
|
||||
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(20)))
|
||||
.show(ctx, |ui| {
|
||||
ui.set_width(760.0);
|
||||
ui.label(egui::RichText::new("Preferences").font(theme::semibold(16.0)).color(t.text));
|
||||
ui.add_space(12.0);
|
||||
ui.horizontal_top(|ui| {
|
||||
// Category list.
|
||||
egui::Frame::NONE.fill(t.panel_darker).corner_radius(egui::CornerRadius::same(4)).inner_margin(egui::Margin::same(6)).show(ui, |ui| {
|
||||
ui.set_width(196.0);
|
||||
ui.set_min_height(430.0);
|
||||
ui.spacing_mut().item_spacing.y = 1.0;
|
||||
for c in PREF_CATEGORIES {
|
||||
let sel = *c == cat;
|
||||
let text = egui::RichText::new(*c).size(12.5).color(if sel { t.text_strong } else { t.text });
|
||||
let r = ui.add_sized([ui.available_width(), 24.0], egui::Button::selectable(sel, text).frame_when_inactive(false));
|
||||
if r.clicked() {
|
||||
d.fields.insert("__category".into(), json!(c));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
// Fields.
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(530.0);
|
||||
ui.label(egui::RichText::new(cat).font(theme::semibold(14.0)).color(t.text_strong));
|
||||
ui.add_space(8.0);
|
||||
egui::ScrollArea::vertical().id_salt(("prefs", cat)).max_height(400.0).auto_shrink([false, false]).show(ui, |ui| {
|
||||
category_fields(ui, &mut d, cat);
|
||||
});
|
||||
});
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
ui.horizontal(|ui| {
|
||||
if widgets::secondary_button(ui, "Reset Preferences").on_hover_text("Restore every preference to its default (applied on OK)").clicked() {
|
||||
reset = true;
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if widgets::primary_button(ui, "OK").clicked() {
|
||||
ok = true;
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if widgets::secondary_button(ui, "Cancel").clicked() {
|
||||
cancel = true;
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
let i = PREF_CATEGORIES.iter().position(|c| *c == cat).unwrap_or(0);
|
||||
if ui.add_enabled(i + 1 < PREF_CATEGORIES.len(), egui::Button::new("Next")).clicked() {
|
||||
d.fields.insert("__category".into(), json!(PREF_CATEGORIES[i + 1]));
|
||||
}
|
||||
if ui.add_enabled(i > 0, egui::Button::new("Previous")).clicked() {
|
||||
d.fields.insert("__category".into(), json!(PREF_CATEGORIES[i - 1]));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if reset {
|
||||
for (k, v) in Prefs::default().to_json().as_object().cloned().unwrap_or_default() {
|
||||
d.fields.insert(k, v);
|
||||
}
|
||||
}
|
||||
app.ui.dialog = Some(d);
|
||||
if cancel {
|
||||
app.ui.dialog = None;
|
||||
} else if ok && let Err(e) = confirm(app) {
|
||||
app.status(e);
|
||||
}
|
||||
}
|
||||
|
||||
fn category_fields(ui: &mut egui::Ui, d: &mut Dialog, cat: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut section = "";
|
||||
let mut first = true;
|
||||
let specs: Vec<_> = PREF_SPECS.iter().filter(|s| s.category == cat).collect();
|
||||
for (key, c, label) in UI_FIELDS {
|
||||
if *c == cat && cat != "Smart Guides" {
|
||||
bool_row(ui, d, key, label);
|
||||
}
|
||||
}
|
||||
if cat == "Smart Guides" {
|
||||
bool_row(ui, d, "__smartGuides", UI_FIELDS[0].2);
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
for sp in specs {
|
||||
if sp.section != section {
|
||||
section = sp.section;
|
||||
if !section.is_empty() {
|
||||
if !first {
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
ui.label(egui::RichText::new(section).font(theme::semibold(12.5)).color(t.text_strong));
|
||||
ui.add_space(2.0);
|
||||
}
|
||||
}
|
||||
first = false;
|
||||
let v = d.fields.get(sp.key).cloned().unwrap_or(Value::Null);
|
||||
match sp.kind {
|
||||
PrefKind::Bool => bool_row(ui, d, sp.key, sp.label),
|
||||
PrefKind::Num { min, max, unit } => {
|
||||
labeled(ui, sp.label, |ui| {
|
||||
let mut x = v.as_f64().unwrap_or(min);
|
||||
let r = if sp.key == "uiScaling" {
|
||||
ui.add(egui::Slider::new(&mut x, min..=max).step_by(0.05).text("Smaller ↔ Larger"))
|
||||
} else {
|
||||
let speed = if max - min > 100.0 { 0.5 } else { 0.05 };
|
||||
ui.add(egui::DragValue::new(&mut x).range(min..=max).speed(speed).max_decimals(3).suffix(format!(" {unit}")))
|
||||
};
|
||||
if r.changed() {
|
||||
d.fields.insert(sp.key.into(), json!(x));
|
||||
}
|
||||
});
|
||||
}
|
||||
PrefKind::Int { min, max } => {
|
||||
labeled(ui, sp.label, |ui| {
|
||||
let mut x = v.as_i64().unwrap_or(min);
|
||||
let r = if sp.key == "anchorSize" {
|
||||
ui.add(egui::Slider::new(&mut x, min..=max).show_value(false).text("Size"))
|
||||
} else {
|
||||
ui.add(egui::DragValue::new(&mut x).range(min..=max).speed(0.2))
|
||||
};
|
||||
if r.changed() {
|
||||
d.fields.insert(sp.key.into(), json!(x));
|
||||
}
|
||||
});
|
||||
}
|
||||
PrefKind::Choice(opts) => {
|
||||
labeled(ui, sp.label, |ui| {
|
||||
let cur = v.as_str().unwrap_or("");
|
||||
let cur_label = opts.iter().find(|o| o.0 == cur).map(|o| o.1).unwrap_or(cur);
|
||||
let labels: Vec<&str> = opts.iter().map(|o| o.1).collect();
|
||||
if let Some(i) = widgets::dropdown(ui, sp.key, cur_label, &labels, 260.0) {
|
||||
d.fields.insert(sp.key.into(), json!(opts[i].0));
|
||||
}
|
||||
});
|
||||
}
|
||||
PrefKind::Color => {
|
||||
labeled(ui, sp.label, |ui| {
|
||||
let hex = v.as_str().unwrap_or("#000000");
|
||||
let c = drawcraft_color::Color::from_hex(hex).map(|c| c.to_rgba8(1.0)).unwrap_or([0, 0, 0, 255]);
|
||||
let mut rgb = [c[0], c[1], c[2]];
|
||||
if ui.color_edit_button_srgb(&mut rgb).changed() {
|
||||
d.fields.insert(sp.key.into(), json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])));
|
||||
}
|
||||
ui.label(egui::RichText::new(hex).color(t.text_dim).size(11.0));
|
||||
});
|
||||
}
|
||||
PrefKind::Text => {
|
||||
labeled(ui, sp.label, |ui| {
|
||||
let mut s = v.as_str().unwrap_or("").to_string();
|
||||
if ui.add(egui::TextEdit::singleline(&mut s).desired_width(260.0)).changed() {
|
||||
d.fields.insert(sp.key.into(), json!(s));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn bool_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
|
||||
let mut b = d.bool(key);
|
||||
if ui.checkbox(&mut b, label).changed() {
|
||||
d.fields.insert(key.into(), json!(b));
|
||||
}
|
||||
}
|
||||
|
||||
fn labeled(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui)) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized([210.0, 22.0], egui::Label::new(egui::RichText::new(format!("{label}:")).color(t.text)).truncate());
|
||||
add(ui);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Services;
|
||||
use drawcraft_engine::Session;
|
||||
|
||||
fn app() -> DrawcraftApp {
|
||||
DrawcraftApp::new(Session::new(), Services::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialog_ok_applies_prefs_and_ui_fields() {
|
||||
let mut a = app();
|
||||
open(&mut a, Some("general"));
|
||||
let d = a.ui.dialog.as_mut().unwrap();
|
||||
assert_eq!(d.str("__category"), "General");
|
||||
d.fields.insert("keyboardIncrement".into(), json!(4));
|
||||
d.fields.insert("uiBrightness".into(), json!("light"));
|
||||
d.fields.insert("__smartGuides".into(), json!(false));
|
||||
confirm(&mut a).unwrap();
|
||||
assert!(a.ui.dialog.is_none());
|
||||
assert_eq!(a.session.prefs.keyboard_increment, 4.0);
|
||||
assert_eq!(a.session.prefs.ui_brightness, "light");
|
||||
assert!(!a.ui.view.smart_guides);
|
||||
assert_eq!(a.ui.engine_prefs["keyboardIncrement"], json!(4.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialog_ok_with_invalid_value_keeps_dialog_and_prefs() {
|
||||
let mut a = app();
|
||||
open(&mut a, None);
|
||||
a.ui.dialog.as_mut().unwrap().fields.insert("anchorSize".into(), json!(42));
|
||||
assert!(confirm(&mut a).is_err());
|
||||
assert!(a.ui.dialog.is_some());
|
||||
assert_eq!(a.session.prefs, Prefs::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefs_persist_through_ui_state() {
|
||||
let mut a = app();
|
||||
a.run("prefs.set", json!({"key": "gridlineEvery", "value": 50})).unwrap();
|
||||
snapshot(&mut a);
|
||||
let saved = serde_json::to_vec(&a.ui).unwrap();
|
||||
let mut b = app();
|
||||
b.ui = serde_json::from_slice::<crate::UiState>(&saved).unwrap().sanitized();
|
||||
restore(&mut b);
|
||||
assert_eq!(b.session.prefs.gridline_every, 50.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_pref_files_keep_their_brightness() {
|
||||
let mut a = app();
|
||||
a.ui.brightness = Brightness::Light;
|
||||
a.ui.engine_prefs = Value::Null;
|
||||
restore(&mut a);
|
||||
assert_eq!(a.session.prefs.ui_brightness, "light");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_category_renders_fields() {
|
||||
for c in PREF_CATEGORIES {
|
||||
assert!(PREF_SPECS.iter().any(|s| s.category == *c));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,783 @@
|
|||
//! Edit → Keyboard Shortcuts: user overrides of tool and command shortcuts, conflict detection,
|
||||
//! presets, import/export, and the dialog.
|
||||
//!
|
||||
//! Overrides live in [`UiState::shortcut_overrides`] (persisted) keyed by command id or
|
||||
//! `tool:<id>`; an empty string removes a shortcut. A process-wide mirror (synced every frame by
|
||||
//! [`sync`]) lets `menus::shortcut_of`, the egui dispatcher, the native menu and tooltips consult
|
||||
//! them without threading the app state through.
|
||||
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Mutex, OnceLock, RwLock};
|
||||
|
||||
use egui::{Key, KeyboardShortcut, Modifiers};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::state::{Dialog, UiState};
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{DrawcraftApp, menus, widgets};
|
||||
|
||||
pub const PRESETS: &[&str] = &["DrawCraft Defaults", "Illustrator Defaults"];
|
||||
pub const CUSTOM: &str = "Custom";
|
||||
|
||||
/// Bumped whenever overrides or the workspace list change (the native menu rebuilds on it).
|
||||
pub static GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn store() -> &'static RwLock<BTreeMap<String, &'static str>> {
|
||||
static S: OnceLock<RwLock<BTreeMap<String, &'static str>>> = OnceLock::new();
|
||||
S.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
/// Leak-once interning for override strings (bounded by the chords a user ever assigns).
|
||||
pub fn intern(s: &str) -> &'static str {
|
||||
static SET: OnceLock<Mutex<HashSet<&'static str>>> = OnceLock::new();
|
||||
let mut set = SET.get_or_init(Default::default).lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(v) = set.get(s) {
|
||||
return v;
|
||||
}
|
||||
let v: &'static str = Box::leak(s.to_string().into_boxed_str());
|
||||
set.insert(v);
|
||||
v
|
||||
}
|
||||
|
||||
/// Mirror `ui.shortcut_overrides` into the global store (cheap when unchanged).
|
||||
pub fn sync(ui: &UiState) {
|
||||
let same = store().read().map(|m| m.len() == ui.shortcut_overrides.len() && m.iter().all(|(k, v)| ui.shortcut_overrides.get(k).map(String::as_str) == Some(*v))).unwrap_or(false);
|
||||
if !same {
|
||||
if let Ok(mut m) = store().write() {
|
||||
*m = ui.shortcut_overrides.iter().map(|(k, v)| (k.clone(), intern(v))).collect();
|
||||
}
|
||||
GENERATION.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
crate::workspaces::sync(ui);
|
||||
}
|
||||
|
||||
fn global_override(key: &str) -> Option<&'static str> {
|
||||
store().read().ok().and_then(|m| m.get(key).copied())
|
||||
}
|
||||
|
||||
/// Registry default shortcut of a command (engine or UI command).
|
||||
pub fn default_command_shortcut(id: &str) -> Option<&'static str> {
|
||||
if let Some(c) = drawcraft_engine::find_command(id) {
|
||||
return c.shortcut;
|
||||
}
|
||||
menus::UI_COMMANDS.iter().find(|c| c.0 == id).map(|c| c.2).filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Default shortcut of an entry key (`tool:<id>` or a command id).
|
||||
pub fn default_of(key: &str) -> Option<&'static str> {
|
||||
match key.strip_prefix("tool:") {
|
||||
Some(t) => drawcraft_tools::tool_info(t).and_then(|t| t.shortcut),
|
||||
None => default_command_shortcut(key),
|
||||
}
|
||||
}
|
||||
|
||||
/// Effective shortcut of `key` under an explicit override map: override > default; "" = none.
|
||||
pub fn effective_in(overrides: &BTreeMap<String, String>, key: &str) -> Option<String> {
|
||||
match overrides.get(key) {
|
||||
Some(s) if s.is_empty() => None,
|
||||
Some(s) => Some(s.clone()),
|
||||
None => default_of(key).map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
/// Effective shortcut of a command, honouring the user's overrides.
|
||||
pub fn command_shortcut(id: &str) -> Option<&'static str> {
|
||||
match global_override(id) {
|
||||
Some("") => None,
|
||||
Some(s) => Some(s),
|
||||
None => default_command_shortcut(id),
|
||||
}
|
||||
}
|
||||
|
||||
/// Effective shortcut of a tool.
|
||||
pub fn tool_shortcut(tool: &str) -> Option<&'static str> {
|
||||
match global_override(&format!("tool:{tool}")) {
|
||||
Some("") => None,
|
||||
Some(s) => Some(s),
|
||||
None => drawcraft_tools::tool_info(tool).and_then(|t| t.shortcut),
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool whose (effective) shortcut is `key` ("V", "Shift+M").
|
||||
pub fn tool_for_key(key: &str) -> Option<&'static str> {
|
||||
let n = normalize(key)?;
|
||||
drawcraft_tools::catalog::all_tools().find(|t| tool_shortcut(t.id).and_then(normalize).as_deref() == Some(n.as_str())).map(|t| t.id)
|
||||
}
|
||||
|
||||
/// Command whose effective shortcut is exactly `key` (for single-key command shortcuts: X, D, /).
|
||||
pub fn command_for_key(key: &str) -> Option<&'static str> {
|
||||
let n = normalize(key)?;
|
||||
let hit = |id: &'static str| command_shortcut(id).and_then(normalize).as_deref() == Some(n.as_str());
|
||||
drawcraft_engine::command_specs().iter().map(|c| c.id).chain(menus::UI_COMMANDS.iter().map(|c| c.0)).find(|id| hit(id))
|
||||
}
|
||||
|
||||
// ---------- chords ----------
|
||||
|
||||
/// Canonical text of a chord: `Cmd+Ctrl+Alt+Shift+Key`.
|
||||
pub fn format(sc: &KeyboardShortcut) -> String {
|
||||
let m = sc.modifiers;
|
||||
let mut s = String::new();
|
||||
if m.command || m.mac_cmd {
|
||||
s.push_str("Cmd+");
|
||||
}
|
||||
if m.ctrl && (cfg!(target_os = "macos") || !m.command) {
|
||||
s.push_str("Ctrl+");
|
||||
}
|
||||
if m.alt {
|
||||
s.push_str("Alt+");
|
||||
}
|
||||
if m.shift {
|
||||
s.push_str("Shift+");
|
||||
}
|
||||
let k = sc.logical_key;
|
||||
let name = match k {
|
||||
Key::CloseBracket => "]",
|
||||
Key::OpenBracket => "[",
|
||||
Key::Semicolon => ";",
|
||||
Key::Quote => "'",
|
||||
Key::Slash => "/",
|
||||
Key::Backslash => "\\",
|
||||
Key::Equals => "=",
|
||||
Key::Minus => "-",
|
||||
Key::Plus => "+",
|
||||
Key::Backtick => "~",
|
||||
Key::Comma => "Comma",
|
||||
Key::Period => "Period",
|
||||
k => k.name(),
|
||||
};
|
||||
s.push_str(name);
|
||||
s
|
||||
}
|
||||
|
||||
/// Canonical form of a shortcut string (None if it doesn't parse).
|
||||
pub fn normalize(s: &str) -> Option<String> {
|
||||
crate::shortcuts::parse(s).map(|sc| format(&sc))
|
||||
}
|
||||
|
||||
/// A recorded key press → shortcut text (None for keys that can't be shortcuts).
|
||||
pub fn chord_from_event(key: Key, m: Modifiers) -> Option<String> {
|
||||
if matches!(key, Key::Escape) {
|
||||
return None;
|
||||
}
|
||||
let s = format(&KeyboardShortcut::new(m, key));
|
||||
crate::shortcuts::parse(&s).map(|_| s)
|
||||
}
|
||||
|
||||
// ---------- entries & conflicts ----------
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Entry {
|
||||
/// `tool:<id>` or a command id.
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
/// Menu path ("Object › Arrange") or "Tools".
|
||||
pub group: String,
|
||||
pub is_tool: bool,
|
||||
}
|
||||
|
||||
/// Every tool and menu command the editor lists.
|
||||
pub fn entries() -> &'static [Entry] {
|
||||
static E: OnceLock<Vec<Entry>> = OnceLock::new();
|
||||
E.get_or_init(|| {
|
||||
let mut v: Vec<Entry> = drawcraft_tools::catalog::all_tools()
|
||||
.map(|t| Entry { key: format!("tool:{}", t.id), label: t.label.to_string(), group: "Tools".into(), is_tool: true })
|
||||
.collect();
|
||||
let mut seen = HashSet::new();
|
||||
for c in drawcraft_engine::command_specs() {
|
||||
if (c.menu.is_empty() && c.shortcut.is_none()) || !seen.insert(c.id) {
|
||||
continue;
|
||||
}
|
||||
v.push(Entry { key: c.id.into(), label: c.label.into(), group: c.menu.join(" › "), is_tool: false });
|
||||
}
|
||||
for (id, label, sc, params) in menus::UI_COMMANDS {
|
||||
if !seen.insert(id) || (sc.is_empty() && !params.starts_with("{}") && !params.starts_with("{path")) {
|
||||
continue;
|
||||
}
|
||||
let group = id.split('.').next().map(|g| {
|
||||
let mut c = g.chars();
|
||||
c.next().map(|f| f.to_uppercase().collect::<String>() + c.as_str()).unwrap_or_default()
|
||||
});
|
||||
v.push(Entry { key: id.to_string(), label: label.to_string(), group: group.unwrap_or_default(), is_tool: false });
|
||||
}
|
||||
v
|
||||
})
|
||||
}
|
||||
|
||||
pub fn entry(key: &str) -> Option<&'static Entry> {
|
||||
entries().iter().find(|e| e.key == key)
|
||||
}
|
||||
|
||||
/// Entries (other than `except`) whose effective shortcut equals `chord`.
|
||||
pub fn conflicts_with(overrides: &BTreeMap<String, String>, chord: &str, except: &str) -> Vec<String> {
|
||||
let Some(n) = normalize(chord) else { return vec![] };
|
||||
entries()
|
||||
.iter()
|
||||
.filter(|e| e.key != except)
|
||||
.filter(|e| effective_in(overrides, &e.key).and_then(|s| normalize(&s)).as_deref() == Some(n.as_str()))
|
||||
.map(|e| e.key.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// All chords used by more than one entry: (chord, keys).
|
||||
pub fn all_conflicts(overrides: &BTreeMap<String, String>) -> Vec<(String, Vec<String>)> {
|
||||
let mut by: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
||||
for e in entries() {
|
||||
if let Some(n) = effective_in(overrides, &e.key).and_then(|s| normalize(&s)) {
|
||||
by.entry(n).or_default().push(e.key.clone());
|
||||
}
|
||||
}
|
||||
by.into_iter().filter(|(_, v)| v.len() > 1).collect()
|
||||
}
|
||||
|
||||
/// Assign `chord` (None = remove, "default" handled by callers via [`reset_one`]) to `key` in
|
||||
/// `overrides`. Conflicting entries lose the chord when `force`, otherwise it's an error.
|
||||
pub fn assign(overrides: &mut BTreeMap<String, String>, key: &str, chord: Option<&str>, force: bool) -> Result<Vec<String>, String> {
|
||||
if entry(key).is_none() && default_of(key).is_none() {
|
||||
return Err(format!("unknown tool or command `{key}`"));
|
||||
}
|
||||
let chord = match chord {
|
||||
None | Some("") => {
|
||||
set_or_clear(overrides, key, "");
|
||||
return Ok(vec![]);
|
||||
}
|
||||
Some(c) => normalize(c).ok_or_else(|| format!("can't parse shortcut `{c}`"))?,
|
||||
};
|
||||
if key.starts_with("tool:") {
|
||||
let sc = crate::shortcuts::parse(&chord).ok_or("bad shortcut")?;
|
||||
if sc.modifiers.command || sc.modifiers.alt || sc.modifiers.ctrl {
|
||||
return Err("tool shortcuts must be a single key (optionally with Shift)".into());
|
||||
}
|
||||
}
|
||||
let clashes = conflicts_with(overrides, &chord, key);
|
||||
if !clashes.is_empty() && !force {
|
||||
let names: Vec<String> = clashes.iter().map(|k| entry(k).map(|e| e.label.clone()).unwrap_or(k.clone())).collect();
|
||||
return Err(format!("{} is already used by {}", menus::pretty_shortcut(&chord), names.join(", ")));
|
||||
}
|
||||
for c in &clashes {
|
||||
set_or_clear(overrides, c, "");
|
||||
}
|
||||
set_or_clear(overrides, key, &chord);
|
||||
Ok(clashes)
|
||||
}
|
||||
|
||||
/// Store `value` for `key`, dropping the override when it equals the default.
|
||||
fn set_or_clear(overrides: &mut BTreeMap<String, String>, key: &str, value: &str) {
|
||||
let default = default_of(key).and_then(normalize).unwrap_or_default();
|
||||
let v = if value.is_empty() { String::new() } else { normalize(value).unwrap_or(value.to_string()) };
|
||||
if v == default {
|
||||
overrides.remove(key);
|
||||
} else {
|
||||
overrides.insert(key.to_string(), v);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset_one(overrides: &mut BTreeMap<String, String>, key: &str) {
|
||||
overrides.remove(key);
|
||||
}
|
||||
|
||||
/// Overrides of a preset (both presets are the registry defaults for now).
|
||||
pub fn preset(name: &str) -> Option<BTreeMap<String, String>> {
|
||||
PRESETS.contains(&name).then(BTreeMap::new)
|
||||
}
|
||||
|
||||
/// Export format: `{"format": "drawcraft-shortcuts", "set": name, "overrides": {...}}`.
|
||||
pub fn export_json(set: &str, overrides: &BTreeMap<String, String>) -> Value {
|
||||
json!({"format": "drawcraft-shortcuts", "version": 1, "set": set, "overrides": overrides})
|
||||
}
|
||||
|
||||
pub fn import_json(v: &Value) -> Result<(String, BTreeMap<String, String>), String> {
|
||||
let o = v.get("overrides").and_then(Value::as_object).ok_or("not a DrawCraft shortcut set (missing `overrides`)")?;
|
||||
let mut out = BTreeMap::new();
|
||||
for (k, v) in o {
|
||||
let s = v.as_str().ok_or_else(|| format!("shortcut for `{k}` must be a string"))?;
|
||||
if !s.is_empty() && normalize(s).is_none() {
|
||||
return Err(format!("can't parse shortcut `{s}` for `{k}`"));
|
||||
}
|
||||
out.insert(k.clone(), normalize(s).unwrap_or_default());
|
||||
}
|
||||
Ok((v.get("set").and_then(Value::as_str).unwrap_or(CUSTOM).to_string(), out))
|
||||
}
|
||||
|
||||
// ---------- UI commands ----------
|
||||
|
||||
pub fn open(app: &mut DrawcraftApp) {
|
||||
let ov = serde_json::to_value(&app.ui.shortcut_overrides).unwrap_or(json!({}));
|
||||
app.ui.dialog = Some(Dialog::new(
|
||||
"shortcuts",
|
||||
json!({"tab": "tools", "query": "", "set": app.ui.shortcut_set, "overrides": ov, "__recording": "", "__selected": "", "__message": "", "__conflict": ""}),
|
||||
));
|
||||
}
|
||||
|
||||
fn dialog_overrides(d: &Dialog) -> BTreeMap<String, String> {
|
||||
d.fields.get("overrides").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// OK: commit the working set.
|
||||
pub fn confirm(app: &mut DrawcraftApp) -> Result<Value, String> {
|
||||
let Some(d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) };
|
||||
app.ui.shortcut_overrides = dialog_overrides(&d);
|
||||
app.ui.shortcut_set = d.str("set");
|
||||
app.ui.dialog = None;
|
||||
sync(&app.ui);
|
||||
Ok(json!({"overrides": app.ui.shortcut_overrides.len()}))
|
||||
}
|
||||
|
||||
/// `shortcuts.*` UI commands. `None` = not ours.
|
||||
pub fn run_command(app: &mut DrawcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
|
||||
let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string);
|
||||
let r = match id {
|
||||
"edit.keyboardShortcuts" => {
|
||||
open(app);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"shortcuts.set" => {
|
||||
let key = s("id").unwrap_or_default();
|
||||
let mut ov = app.ui.shortcut_overrides.clone();
|
||||
let r = if p.get("shortcut").is_some_and(Value::is_null) {
|
||||
reset_one(&mut ov, &key);
|
||||
Ok(vec![])
|
||||
} else {
|
||||
assign(&mut ov, &key, s("shortcut").as_deref(), p.get("force").and_then(Value::as_bool).unwrap_or(false))
|
||||
};
|
||||
r.map(|removed| {
|
||||
app.ui.shortcut_overrides = ov;
|
||||
app.ui.shortcut_set = CUSTOM.into();
|
||||
sync(&app.ui);
|
||||
json!({"id": key, "shortcut": effective_in(&app.ui.shortcut_overrides, &key), "removedFrom": removed})
|
||||
})
|
||||
}
|
||||
"shortcuts.list" => {
|
||||
let q = s("query").unwrap_or_default().to_lowercase();
|
||||
let ov = &app.ui.shortcut_overrides;
|
||||
Ok(Value::Array(
|
||||
entries()
|
||||
.iter()
|
||||
.filter(|e| q.is_empty() || e.label.to_lowercase().contains(&q) || e.key.to_lowercase().contains(&q))
|
||||
.map(|e| json!({"id": e.key, "label": e.label, "group": e.group, "shortcut": effective_in(ov, &e.key), "default": default_of(&e.key), "overridden": ov.contains_key(&e.key)}))
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
"shortcuts.conflicts" => Ok(json!(all_conflicts(&app.ui.shortcut_overrides).into_iter().map(|(c, k)| json!({"shortcut": c, "ids": k})).collect::<Vec<_>>())),
|
||||
"shortcuts.reset" => {
|
||||
app.ui.shortcut_overrides.clear();
|
||||
app.ui.shortcut_set = PRESETS[0].into();
|
||||
sync(&app.ui);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"shortcuts.preset" => match s("name").as_deref().and_then(|n| preset(n).map(|p| (n.to_string(), p))) {
|
||||
Some((n, p)) => {
|
||||
app.ui.shortcut_overrides = p;
|
||||
app.ui.shortcut_set = n;
|
||||
sync(&app.ui);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
None => Err(format!("unknown preset (one of {})", PRESETS.join(", "))),
|
||||
},
|
||||
"shortcuts.export" => {
|
||||
let v = export_json(&app.ui.shortcut_set, &app.ui.shortcut_overrides);
|
||||
let bytes = serde_json::to_vec_pretty(&v).unwrap_or_default();
|
||||
let path = s("path").or_else(|| app.services.pick_save.as_mut().and_then(|f| f("DrawCraft Shortcuts.json")));
|
||||
match path {
|
||||
Some(path) => match app.services.write.as_mut() {
|
||||
Some(w) => w(&path, &bytes).map(|_| json!({"path": path})),
|
||||
None => Err("no file writer".into()),
|
||||
},
|
||||
None => match app.services.download.as_mut() {
|
||||
Some(dl) => {
|
||||
dl("DrawCraft Shortcuts.json", &bytes);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
None => Ok(v),
|
||||
},
|
||||
}
|
||||
}
|
||||
"shortcuts.import" => {
|
||||
let data = if let Some(v) = p.get("data") {
|
||||
Ok(v.clone())
|
||||
} else {
|
||||
let path = s("path").or_else(|| app.services.pick_open.as_mut().and_then(|f| f()));
|
||||
match (path, app.services.read.as_ref()) {
|
||||
(Some(path), Some(r)) => r(&path).and_then(|b| serde_json::from_slice(&b).map_err(|e| e.to_string())),
|
||||
(None, _) => Err("cancelled".into()),
|
||||
(_, None) => Err("no file reader".into()),
|
||||
}
|
||||
};
|
||||
data.and_then(|v| import_json(&v)).map(|(set, ov)| {
|
||||
let n = ov.len();
|
||||
app.ui.shortcut_overrides = ov;
|
||||
app.ui.shortcut_set = set;
|
||||
sync(&app.ui);
|
||||
if let Some(d) = app.ui.dialog.as_mut().filter(|d| d.kind == "shortcuts") {
|
||||
d.fields.insert("overrides".into(), serde_json::to_value(&app.ui.shortcut_overrides).unwrap_or(json!({})));
|
||||
d.fields.insert("set".into(), json!(app.ui.shortcut_set));
|
||||
}
|
||||
json!({"overrides": n})
|
||||
})
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(r)
|
||||
}
|
||||
|
||||
/// While recording, Escape cancels the recording instead of closing the dialog.
|
||||
pub fn is_recording(app: &DrawcraftApp) -> bool {
|
||||
app.ui.dialog.as_ref().is_some_and(|d| d.kind == "shortcuts" && !d.str("__recording").is_empty())
|
||||
}
|
||||
|
||||
// ---------- dialog ----------
|
||||
|
||||
pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) {
|
||||
let Some(mut d) = app.ui.dialog.clone() else { return };
|
||||
let t = Tokens::get(ctx);
|
||||
let mut ov = dialog_overrides(&d);
|
||||
let mut ok = false;
|
||||
let mut cancel = false;
|
||||
let recording = d.str("__recording");
|
||||
// Record a chord (before widgets see the keys).
|
||||
if !recording.is_empty() {
|
||||
let ev = ctx.input(|i| {
|
||||
i.events.iter().find_map(|e| match e {
|
||||
egui::Event::Key { key, pressed: true, modifiers, .. } => Some((*key, *modifiers)),
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
if let Some((key, m)) = ev {
|
||||
ctx.input_mut(|i| i.events.clear());
|
||||
if key == Key::Escape {
|
||||
d.fields.insert("__recording".into(), json!(""));
|
||||
} else if let Some(chord) = chord_from_event(key, m) {
|
||||
record(&mut d, &mut ov, &recording, &chord);
|
||||
}
|
||||
}
|
||||
}
|
||||
egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| {
|
||||
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
|
||||
});
|
||||
egui::Window::new("Keyboard Shortcuts")
|
||||
.id(egui::Id::new("dialog-shortcuts"))
|
||||
.order(egui::Order::Foreground)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.title_bar(false)
|
||||
.anchor(egui::Align2::CENTER_CENTER, [0.0, -20.0])
|
||||
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(20)))
|
||||
.show(ctx, |ui| {
|
||||
ui.set_width(640.0);
|
||||
ui.label(egui::RichText::new("Keyboard Shortcuts").font(theme::semibold(16.0)).color(t.text));
|
||||
ui.add_space(10.0);
|
||||
// Set row.
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("Set:").color(t.text_dim));
|
||||
let cur = d.str("set");
|
||||
let mut opts: Vec<&str> = PRESETS.to_vec();
|
||||
if !PRESETS.contains(&cur.as_str()) {
|
||||
opts.push(CUSTOM);
|
||||
}
|
||||
if let Some(i) = widgets::dropdown(ui, "kbset", &cur, &opts, 200.0)
|
||||
&& let Some(p) = preset(opts[i])
|
||||
{
|
||||
ov = p;
|
||||
d.fields.insert("set".into(), json!(opts[i]));
|
||||
d.fields.insert("__message".into(), json!(""));
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
if ui.button("Import…").clicked() {
|
||||
d.fields.insert("__import".into(), json!(true));
|
||||
}
|
||||
if ui.button("Export…").clicked() {
|
||||
d.fields.insert("__export".into(), json!(true));
|
||||
}
|
||||
if ui.button("Reset to Defaults").clicked() {
|
||||
ov.clear();
|
||||
d.fields.insert("set".into(), json!(PRESETS[0]));
|
||||
d.fields.insert("__message".into(), json!("All shortcuts reset to defaults."));
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
// Tabs + search.
|
||||
ui.horizontal(|ui| {
|
||||
let tab = d.str("tab");
|
||||
for (id, label) in [("tools", "Tools"), ("menu", "Menu Commands")] {
|
||||
if ui.selectable_label(tab == id, egui::RichText::new(label).font(theme::semibold(12.5))).clicked() {
|
||||
d.fields.insert("tab".into(), json!(id));
|
||||
}
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let mut q = d.str("query");
|
||||
if ui.add(egui::TextEdit::singleline(&mut q).hint_text("Search").desired_width(200.0)).changed() {
|
||||
d.fields.insert("query".into(), json!(q));
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
let tools = d.str("tab") != "menu";
|
||||
let q = d.str("query").to_lowercase();
|
||||
let selected = d.str("__selected");
|
||||
let rec = d.str("__recording");
|
||||
let scroll_to = d.fields.remove("__scrollTo").and_then(|v| v.as_str().map(str::to_string));
|
||||
egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized([360.0, 16.0], egui::Label::new(egui::RichText::new(if tools { "Tool" } else { "Command" }).color(t.text_dim).size(11.0)));
|
||||
ui.label(egui::RichText::new("Shortcut").color(t.text_dim).size(11.0));
|
||||
});
|
||||
egui::ScrollArea::vertical().max_height(360.0).auto_shrink([false, false]).show(ui, |ui| {
|
||||
for e in entries().iter().filter(|e| e.is_tool == tools) {
|
||||
let sc = effective_in(&ov, &e.key);
|
||||
if !q.is_empty() && !(e.label.to_lowercase().contains(&q) || e.group.to_lowercase().contains(&q) || sc.as_deref().is_some_and(|s| s.to_lowercase().contains(&q))) {
|
||||
continue;
|
||||
}
|
||||
let row = ui.horizontal(|ui| {
|
||||
let name = if e.is_tool || e.group.is_empty() { e.label.clone() } else { format!("{} › {}", e.group, e.label) };
|
||||
let r = ui.add_sized([360.0, 20.0], egui::Button::selectable(selected == e.key, egui::RichText::new(name).color(t.text)).truncate());
|
||||
if r.clicked() {
|
||||
d.fields.insert("__selected".into(), json!(e.key));
|
||||
}
|
||||
let label = if rec == e.key {
|
||||
"Press keys…".to_string()
|
||||
} else {
|
||||
sc.as_deref().map(menus::pretty_shortcut).unwrap_or_else(|| "—".into())
|
||||
};
|
||||
let changed = ov.contains_key(&e.key);
|
||||
let txt = egui::RichText::new(label).color(if changed { t.accent } else { t.text });
|
||||
let b = ui.add_sized([150.0, 20.0], egui::Button::new(txt).selected(rec == e.key)).on_hover_text("Click, then press the new shortcut (Esc cancels)");
|
||||
if b.clicked() {
|
||||
d.fields.insert("__recording".into(), json!(e.key));
|
||||
d.fields.insert("__selected".into(), json!(e.key));
|
||||
}
|
||||
});
|
||||
if scroll_to.as_deref() == Some(e.key.as_str()) {
|
||||
row.response.scroll_to_me(Some(egui::Align::Center));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
// Selected-row actions + message.
|
||||
ui.horizontal(|ui| {
|
||||
let sel = d.str("__selected");
|
||||
ui.add_enabled_ui(!sel.is_empty(), |ui| {
|
||||
if ui.button("Clear").on_hover_text("Remove the shortcut").clicked() {
|
||||
let _ = assign(&mut ov, &sel, None, true);
|
||||
d.fields.insert("set".into(), json!(CUSTOM));
|
||||
}
|
||||
if ui.button("Use Default").clicked() {
|
||||
reset_one(&mut ov, &sel);
|
||||
}
|
||||
});
|
||||
let conflict = d.str("__conflict");
|
||||
if !conflict.is_empty() && ui.button("Go to Conflict").clicked() {
|
||||
let tab = if conflict.starts_with("tool:") { "tools" } else { "menu" };
|
||||
d.fields.insert("tab".into(), json!(tab));
|
||||
d.fields.insert("query".into(), json!(""));
|
||||
d.fields.insert("__selected".into(), json!(conflict));
|
||||
d.fields.insert("__scrollTo".into(), json!(conflict));
|
||||
d.fields.insert("__conflict".into(), json!(""));
|
||||
}
|
||||
});
|
||||
let msg = d.str("__message");
|
||||
if !msg.is_empty() {
|
||||
ui.label(egui::RichText::new(msg).color(t.accent_strong).size(11.5));
|
||||
}
|
||||
let n = all_conflicts(&ov).len();
|
||||
if n > 0 {
|
||||
ui.label(egui::RichText::new(format!("{n} shortcut(s) are assigned more than once")).color(t.text_dim).size(11.0));
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if widgets::primary_button(ui, "OK").clicked() {
|
||||
ok = true;
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if widgets::secondary_button(ui, "Cancel").clicked() {
|
||||
cancel = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
d.fields.insert("overrides".into(), serde_json::to_value(&ov).unwrap_or(json!({})));
|
||||
let import = d.fields.remove("__import").is_some();
|
||||
let export = d.fields.remove("__export").is_some();
|
||||
app.ui.dialog = Some(d);
|
||||
if import {
|
||||
// Import replaces the working set (committed on OK like other edits).
|
||||
let saved = (app.ui.shortcut_overrides.clone(), app.ui.shortcut_set.clone());
|
||||
match run_command(app, "shortcuts.import", &json!({})) {
|
||||
Some(Ok(_)) => {
|
||||
let (o, s) = (app.ui.shortcut_overrides.clone(), app.ui.shortcut_set.clone());
|
||||
(app.ui.shortcut_overrides, app.ui.shortcut_set) = saved;
|
||||
sync(&app.ui);
|
||||
if let Some(d) = app.ui.dialog.as_mut() {
|
||||
d.fields.insert("overrides".into(), serde_json::to_value(&o).unwrap_or(json!({})));
|
||||
d.fields.insert("set".into(), json!(s));
|
||||
d.fields.insert("__message".into(), json!("Imported. Press OK to keep the imported set."));
|
||||
}
|
||||
}
|
||||
Some(Err(e)) if e != "cancelled" => app.status(e),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if export {
|
||||
let (ov, set) = app.ui.dialog.as_ref().map(|d| (dialog_overrides(d), d.str("set"))).unwrap_or_default();
|
||||
let saved = std::mem::replace(&mut app.ui.shortcut_overrides, ov);
|
||||
let saved_set = std::mem::replace(&mut app.ui.shortcut_set, set);
|
||||
if let Some(Err(e)) = run_command(app, "shortcuts.export", &json!({})) {
|
||||
app.status(e);
|
||||
}
|
||||
app.ui.shortcut_overrides = saved;
|
||||
app.ui.shortcut_set = saved_set;
|
||||
}
|
||||
if cancel {
|
||||
app.ui.dialog = None;
|
||||
} else if ok && let Err(e) = confirm(app) {
|
||||
app.status(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a recorded chord to `key` in the working set, warning about (and resolving) conflicts.
|
||||
fn record(d: &mut Dialog, ov: &mut BTreeMap<String, String>, key: &str, chord: &str) {
|
||||
d.fields.insert("__recording".into(), json!(""));
|
||||
match assign(ov, key, Some(chord), true) {
|
||||
Ok(removed) => {
|
||||
d.fields.insert("set".into(), json!(CUSTOM));
|
||||
if let Some(first) = removed.first() {
|
||||
let names: Vec<String> = removed.iter().map(|k| entry(k).map(|e| e.label.clone()).unwrap_or(k.clone())).collect();
|
||||
d.fields.insert(
|
||||
"__message".into(),
|
||||
json!(format!("⚠ {} was already used by {} — it has been removed there.", menus::pretty_shortcut(chord), names.join(", "))),
|
||||
);
|
||||
d.fields.insert("__conflict".into(), json!(first));
|
||||
} else {
|
||||
d.fields.insert("__message".into(), json!(""));
|
||||
d.fields.insert("__conflict".into(), json!(""));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
d.fields.insert("__message".into(), json!(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn format_parse_round_trip_for_every_default() {
|
||||
for e in entries() {
|
||||
if let Some(s) = default_of(&e.key)
|
||||
&& let Some(sc) = crate::shortcuts::parse(s)
|
||||
{
|
||||
let f = format(&sc);
|
||||
assert_eq!(crate::shortcuts::parse(&f), Some(sc), "{} `{s}` → `{f}`", e.key);
|
||||
assert_eq!(normalize(&f).as_deref(), Some(f.as_str()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_is_order_insensitive() {
|
||||
assert_eq!(normalize("Shift+Cmd+Alt+K"), normalize("Cmd+Alt+Shift+K"));
|
||||
assert_eq!(normalize("Cmd+Shift+]").as_deref(), Some("Cmd+Shift+]"));
|
||||
assert_eq!(normalize("Cmd+="), Some("Cmd+=".into()));
|
||||
assert!(normalize("Hyper+K").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chord_from_recorded_event() {
|
||||
assert_eq!(chord_from_event(Key::K, Modifiers::COMMAND | Modifiers::SHIFT).as_deref(), Some("Cmd+Shift+K"));
|
||||
assert_eq!(chord_from_event(Key::P, Modifiers::NONE).as_deref(), Some("P"));
|
||||
assert_eq!(chord_from_event(Key::Escape, Modifiers::NONE), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_beats_default_and_empty_removes() {
|
||||
let mut ov = BTreeMap::new();
|
||||
assert_eq!(effective_in(&ov, "edit.preferences").as_deref(), Some("Cmd+K"));
|
||||
ov.insert("edit.preferences".to_string(), "Cmd+Alt+Shift+9".to_string());
|
||||
assert_eq!(effective_in(&ov, "edit.preferences").as_deref(), Some("Cmd+Alt+Shift+9"));
|
||||
ov.insert("edit.preferences".to_string(), String::new());
|
||||
assert_eq!(effective_in(&ov, "edit.preferences"), None);
|
||||
assert_eq!(effective_in(&BTreeMap::new(), "tool:pen").as_deref(), Some("P"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_override_reaches_shortcut_of_and_tools() {
|
||||
let mut ui = UiState::default();
|
||||
ui.shortcut_overrides.insert("test.nonexistent".into(), "Cmd+Alt+Shift+F9".into());
|
||||
ui.shortcut_overrides.insert("tool:measure".into(), "Shift+9".into());
|
||||
let g = GENERATION.load(Ordering::Relaxed);
|
||||
sync(&ui);
|
||||
assert!(GENERATION.load(Ordering::Relaxed) > g);
|
||||
assert_eq!(menus::shortcut_of("test.nonexistent"), Some("Cmd+Alt+Shift+F9"));
|
||||
assert_eq!(tool_shortcut("measure"), Some("Shift+9"));
|
||||
assert_eq!(tool_for_key("Shift+9"), Some("measure"));
|
||||
sync(&UiState::default());
|
||||
assert_eq!(menus::shortcut_of("test.nonexistent"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assign_detects_conflicts() {
|
||||
let mut ov = BTreeMap::new();
|
||||
// ⌘K is Preferences.
|
||||
let err = assign(&mut ov, "file.revert", Some("Cmd+K"), false).unwrap_err();
|
||||
assert!(err.contains("Preferences"), "{err}");
|
||||
assert!(ov.is_empty());
|
||||
assert_eq!(conflicts_with(&ov, "Cmd+K", "file.revert"), vec!["edit.preferences".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forced_assign_moves_the_chord() {
|
||||
let mut ov = BTreeMap::new();
|
||||
let removed = assign(&mut ov, "file.revert", Some("Cmd+K"), true).unwrap();
|
||||
assert_eq!(removed, vec!["edit.preferences".to_string()]);
|
||||
assert_eq!(effective_in(&ov, "file.revert").as_deref(), Some("Cmd+K"));
|
||||
assert_eq!(effective_in(&ov, "edit.preferences"), None);
|
||||
assert!(conflicts_with(&ov, "Cmd+K", "file.revert").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assigning_the_default_drops_the_override() {
|
||||
let mut ov = BTreeMap::new();
|
||||
assign(&mut ov, "tool:pen", Some("Shift+9"), false).unwrap();
|
||||
assert_eq!(ov.len(), 1);
|
||||
assign(&mut ov, "tool:pen", Some("P"), false).unwrap();
|
||||
assert!(ov.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_shortcuts_must_be_single_keys() {
|
||||
let mut ov = BTreeMap::new();
|
||||
assert!(assign(&mut ov, "tool:pen", Some("Cmd+Alt+Shift+F9"), true).is_err());
|
||||
assert!(assign(&mut ov, "tool:bogus", Some("Q"), true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_import_round_trip() {
|
||||
let mut ov = BTreeMap::new();
|
||||
ov.insert("tool:pen".to_string(), "Shift+9".to_string());
|
||||
ov.insert("edit.preferences".to_string(), String::new());
|
||||
let v = export_json("Mine", &ov);
|
||||
let (set, back) = import_json(&v).unwrap();
|
||||
assert_eq!(set, "Mine");
|
||||
assert_eq!(back, ov);
|
||||
assert!(import_json(&json!({"overrides": {"x": "Cmd+Nope+Q"}})).is_err());
|
||||
assert!(import_json(&json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_are_the_defaults() {
|
||||
for p in PRESETS {
|
||||
assert_eq!(preset(p), Some(BTreeMap::new()));
|
||||
}
|
||||
assert!(preset("Nope").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_cover_tools_and_menu_commands() {
|
||||
assert!(entries().iter().any(|e| e.key == "tool:selection"));
|
||||
assert!(entries().iter().any(|e| e.key == "edit.preferences"));
|
||||
assert!(entries().iter().any(|e| e.key == "edit.undo"));
|
||||
let mut seen = HashSet::new();
|
||||
assert!(entries().iter().all(|e| seen.insert(e.key.clone())), "duplicate entries");
|
||||
}
|
||||
}
|
||||
|
|
@ -43,15 +43,16 @@ pub fn parse(s: &str) -> Option<KeyboardShortcut> {
|
|||
Some(KeyboardShortcut::new(m, key))
|
||||
}
|
||||
|
||||
/// Every command shortcut in effect (user overrides from Edit → Keyboard Shortcuts win).
|
||||
fn all_shortcuts() -> Vec<(KeyboardShortcut, &'static str)> {
|
||||
let mut v: Vec<(KeyboardShortcut, &'static str)> = vec![];
|
||||
for c in drawcraft_engine::command_specs() {
|
||||
if let Some(sc) = c.shortcut.and_then(parse) {
|
||||
if let Some(sc) = crate::menus::shortcut_of(c.id).and_then(parse) {
|
||||
v.push((sc, c.id));
|
||||
}
|
||||
}
|
||||
for c in crate::menus::UI_COMMANDS {
|
||||
if let Some(sc) = parse(c.2) {
|
||||
if let Some(sc) = crate::menus::shortcut_of(c.0).and_then(parse) {
|
||||
v.push((sc, c.0));
|
||||
}
|
||||
}
|
||||
|
|
@ -64,6 +65,9 @@ fn all_shortcuts() -> Vec<(KeyboardShortcut, &'static str)> {
|
|||
|
||||
pub fn handle(app: &mut DrawcraftApp, ctx: &egui::Context) {
|
||||
if app.ui.dialog.is_some() || app.ui.palette_open {
|
||||
if crate::shortcut_editor::is_recording(app) {
|
||||
return;
|
||||
}
|
||||
if ctx.input(|i| i.key_pressed(Key::Escape)) {
|
||||
app.ui.dialog = None;
|
||||
app.ui.palette_open = false;
|
||||
|
|
@ -165,39 +169,13 @@ pub fn handle(app: &mut DrawcraftApp, ctx: &egui::Context) {
|
|||
}
|
||||
let upper = text.to_uppercase();
|
||||
let key = if m.shift && text.chars().all(|c| c.is_alphabetic()) { format!("Shift+{upper}") } else { upper.clone() };
|
||||
match key.as_str() {
|
||||
"X" => {
|
||||
let _ = app.run("paint.toggleActive", json!({}));
|
||||
continue;
|
||||
}
|
||||
"Shift+X" => {
|
||||
let _ = app.run("paint.swap", json!({}));
|
||||
continue;
|
||||
}
|
||||
"D" => {
|
||||
let _ = app.run("paint.default", json!({}));
|
||||
continue;
|
||||
}
|
||||
"/" => {
|
||||
let _ = app.run("paint.none", json!({}));
|
||||
continue;
|
||||
}
|
||||
"Shift+D" => {
|
||||
let _ = app.run("view.drawMode", json!({}));
|
||||
continue;
|
||||
}
|
||||
"F" => {
|
||||
let _ = app.run("view.screenMode", json!({}));
|
||||
continue;
|
||||
}
|
||||
"Shift+F" => {
|
||||
let _ = app.run("view.presentation", json!({}));
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
// Single-key command shortcuts (X, Shift+X, D, /, Shift+D, F, Shift+F by default).
|
||||
if let Some(id) = crate::shortcut_editor::command_for_key(&key).or_else(|| crate::shortcut_editor::command_for_key(&text)) {
|
||||
let _ = app.run(id, json!({}));
|
||||
continue;
|
||||
}
|
||||
if let Some(t) = drawcraft_tools::catalog::tool_for_shortcut(&key).or_else(|| drawcraft_tools::catalog::tool_for_shortcut(&text)) {
|
||||
app.select_tool(t.id);
|
||||
if let Some(t) = crate::shortcut_editor::tool_for_key(&key).or_else(|| crate::shortcut_editor::tool_for_key(&text)) {
|
||||
app.select_tool(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -198,6 +198,21 @@ pub struct UiState {
|
|||
/// Recording in progress: (set index, action name, journal length when recording started).
|
||||
#[serde(skip)]
|
||||
pub recording: Option<(usize, String, usize)>,
|
||||
/// Keyboard shortcut overrides: command id or `tool:<id>` → chord ("" = none).
|
||||
#[serde(default)]
|
||||
pub shortcut_overrides: std::collections::BTreeMap<String, String>,
|
||||
/// Name of the shortcut set (preset or "Custom").
|
||||
#[serde(default = "default_shortcut_set")]
|
||||
pub shortcut_set: String,
|
||||
/// Current workspace (Window → Workspace).
|
||||
#[serde(default = "default_workspace")]
|
||||
pub workspace: String,
|
||||
/// User-saved workspaces (New Workspace…).
|
||||
#[serde(default)]
|
||||
pub custom_workspaces: Vec<crate::workspaces::Workspace>,
|
||||
/// Engine preferences (Edit → Preferences), persisted alongside the UI state.
|
||||
#[serde(default)]
|
||||
pub engine_prefs: Value,
|
||||
}
|
||||
|
||||
impl UiState {
|
||||
|
|
@ -242,6 +257,11 @@ impl Default for UiState {
|
|||
about: false,
|
||||
action_sets: crate::panels::actions::default_sets(),
|
||||
recording: None,
|
||||
shortcut_overrides: Default::default(),
|
||||
shortcut_set: default_shortcut_set(),
|
||||
workspace: default_workspace(),
|
||||
custom_workspaces: vec![],
|
||||
engine_prefs: Value::Null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -250,6 +270,14 @@ fn yes() -> bool {
|
|||
true
|
||||
}
|
||||
|
||||
fn default_shortcut_set() -> String {
|
||||
crate::shortcut_editor::PRESETS[0].to_string()
|
||||
}
|
||||
|
||||
fn default_workspace() -> String {
|
||||
crate::workspaces::ESSENTIALS.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,463 @@
|
|||
//! Window → Workspace: saved panel/bar layouts (Essentials, Essentials Classic, Painting, …),
|
||||
//! Reset, New Workspace…, Manage Workspaces…. A workspace is a snapshot of `UiState` layout
|
||||
//! flags; UI brightness and everything else is untouched when switching.
|
||||
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::{OnceLock, RwLock};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::state::{Dialog, DockTab, UiState};
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{DrawcraftApp, widgets};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Workspace {
|
||||
pub name: String,
|
||||
pub control_bar: bool,
|
||||
pub rulers: bool,
|
||||
pub toolbar: bool,
|
||||
pub toolbar_double: bool,
|
||||
pub toolbar_advanced: bool,
|
||||
pub task_bar: bool,
|
||||
pub dock: bool,
|
||||
pub dock_tab: DockTab,
|
||||
pub open_panel: Option<String>,
|
||||
pub status_bar: bool,
|
||||
}
|
||||
|
||||
impl Default for Workspace {
|
||||
fn default() -> Self {
|
||||
let ui = UiState::default();
|
||||
capture(&ui, "Essentials")
|
||||
}
|
||||
}
|
||||
|
||||
pub const ESSENTIALS: &str = "Essentials";
|
||||
|
||||
/// Built-in workspaces, in Illustrator's menu order (Essentials first).
|
||||
pub fn builtins() -> Vec<Workspace> {
|
||||
let base = Workspace::default();
|
||||
let w = |name: &str, f: &dyn Fn(&mut Workspace)| {
|
||||
let mut x = Workspace { name: name.into(), ..base.clone() };
|
||||
f(&mut x);
|
||||
x
|
||||
};
|
||||
vec![
|
||||
w(ESSENTIALS, &|_| {}),
|
||||
w("Essentials Classic", &|x| {
|
||||
x.control_bar = true;
|
||||
x.rulers = true;
|
||||
x.toolbar_advanced = true;
|
||||
x.task_bar = false;
|
||||
x.open_panel = Some("color".into());
|
||||
}),
|
||||
w("Automation", &|x| {
|
||||
x.control_bar = true;
|
||||
x.toolbar_advanced = true;
|
||||
x.dock_tab = DockTab::Layers;
|
||||
x.open_panel = Some("actions".into());
|
||||
}),
|
||||
w("Layout", &|x| {
|
||||
x.control_bar = true;
|
||||
x.rulers = true;
|
||||
x.toolbar_advanced = true;
|
||||
x.dock_tab = DockTab::Layers;
|
||||
x.open_panel = Some("align".into());
|
||||
}),
|
||||
w("Painting", &|x| {
|
||||
x.control_bar = true;
|
||||
x.toolbar_advanced = true;
|
||||
x.toolbar_double = true;
|
||||
x.open_panel = Some("brushes".into());
|
||||
}),
|
||||
w("Printing and Proofing", &|x| {
|
||||
x.control_bar = true;
|
||||
x.rulers = true;
|
||||
x.toolbar_advanced = true;
|
||||
x.open_panel = Some("info".into());
|
||||
}),
|
||||
w("Tracing", &|x| {
|
||||
x.control_bar = true;
|
||||
x.toolbar_advanced = true;
|
||||
x.dock_tab = DockTab::Layers;
|
||||
x.task_bar = false;
|
||||
}),
|
||||
w("Typography", &|x| {
|
||||
x.control_bar = true;
|
||||
x.rulers = true;
|
||||
x.toolbar_advanced = true;
|
||||
x.open_panel = Some("character".into());
|
||||
}),
|
||||
w("Web", &|x| {
|
||||
x.control_bar = true;
|
||||
x.rulers = true;
|
||||
x.toolbar_advanced = true;
|
||||
x.open_panel = Some("symbols".into());
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn is_builtin(name: &str) -> bool {
|
||||
builtins().iter().any(|w| w.name == name)
|
||||
}
|
||||
|
||||
/// Snapshot the layout flags of `ui` as a workspace called `name`.
|
||||
pub fn capture(ui: &UiState, name: &str) -> Workspace {
|
||||
Workspace {
|
||||
name: name.into(),
|
||||
control_bar: ui.control_bar,
|
||||
rulers: ui.view.rulers,
|
||||
toolbar: ui.toolbar,
|
||||
toolbar_double: ui.toolbar_double,
|
||||
toolbar_advanced: ui.toolbar_advanced,
|
||||
task_bar: ui.task_bar,
|
||||
dock: ui.dock,
|
||||
dock_tab: ui.dock_tab,
|
||||
open_panel: ui.open_panel.clone(),
|
||||
status_bar: ui.status_bar,
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a workspace's layout flags (brightness, prefs, shortcuts etc. untouched).
|
||||
pub fn apply(ui: &mut UiState, w: &Workspace) {
|
||||
ui.control_bar = w.control_bar;
|
||||
ui.view.rulers = w.rulers;
|
||||
ui.toolbar = w.toolbar;
|
||||
ui.toolbar_double = w.toolbar_double;
|
||||
ui.toolbar_advanced = w.toolbar_advanced;
|
||||
ui.task_bar = w.task_bar;
|
||||
ui.dock = w.dock;
|
||||
ui.dock_tab = w.dock_tab;
|
||||
ui.open_panel = w.open_panel.clone();
|
||||
ui.status_bar = w.status_bar;
|
||||
ui.flyout = None;
|
||||
ui.workspace = w.name.clone();
|
||||
}
|
||||
|
||||
/// A workspace by name (user workspaces shadow nothing: names are unique across both lists).
|
||||
pub fn find(ui: &UiState, name: &str) -> Option<Workspace> {
|
||||
ui.custom_workspaces.iter().find(|w| w.name.eq_ignore_ascii_case(name)).cloned().or_else(|| builtins().into_iter().find(|w| w.name.eq_ignore_ascii_case(name)))
|
||||
}
|
||||
|
||||
/// Every workspace name: built-ins then user workspaces.
|
||||
pub fn names(ui: &UiState) -> Vec<String> {
|
||||
builtins().into_iter().map(|w| w.name).chain(ui.custom_workspaces.iter().map(|w| w.name.clone())).collect()
|
||||
}
|
||||
|
||||
/// Save the current layout as `name` (replacing a user workspace of that name).
|
||||
pub fn save_as(ui: &mut UiState, name: &str) -> Result<(), String> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err("workspace name can't be empty".into());
|
||||
}
|
||||
if is_builtin(name) {
|
||||
return Err(format!("`{name}` is a built-in workspace; choose another name"));
|
||||
}
|
||||
let w = capture(ui, name);
|
||||
match ui.custom_workspaces.iter_mut().find(|x| x.name == name) {
|
||||
Some(x) => *x = w,
|
||||
None => ui.custom_workspaces.push(w),
|
||||
}
|
||||
ui.workspace = name.into();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete(ui: &mut UiState, name: &str) -> Result<(), String> {
|
||||
let before = ui.custom_workspaces.len();
|
||||
ui.custom_workspaces.retain(|w| w.name != name);
|
||||
if ui.custom_workspaces.len() == before {
|
||||
return Err(if is_builtin(name) { format!("`{name}` is built in and can't be deleted") } else { format!("no workspace `{name}`") });
|
||||
}
|
||||
if ui.workspace == name {
|
||||
ui.workspace = ESSENTIALS.into();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn rename(ui: &mut UiState, from: &str, to: &str) -> Result<(), String> {
|
||||
let to = to.trim();
|
||||
if to.is_empty() || is_builtin(to) || ui.custom_workspaces.iter().any(|w| w.name == to) {
|
||||
return Err(format!("`{to}` is not an available name"));
|
||||
}
|
||||
let w = ui.custom_workspaces.iter_mut().find(|w| w.name == from).ok_or_else(|| format!("no user workspace `{from}`"))?;
|
||||
w.name = to.into();
|
||||
if ui.workspace == from {
|
||||
ui.workspace = to.into();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------- menu support (menu_tree has no app access) ----------
|
||||
|
||||
fn names_store() -> &'static RwLock<Vec<&'static str>> {
|
||||
static S: OnceLock<RwLock<Vec<&'static str>>> = OnceLock::new();
|
||||
S.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
/// Mirror the user workspace names for the (static) menu tree.
|
||||
pub fn sync(ui: &UiState) {
|
||||
let same = names_store().read().map(|v| v.len() == ui.custom_workspaces.len() && v.iter().zip(&ui.custom_workspaces).all(|(a, b)| *a == b.name)).unwrap_or(true);
|
||||
if !same {
|
||||
if let Ok(mut v) = names_store().write() {
|
||||
*v = ui.custom_workspaces.iter().map(|w| crate::shortcut_editor::intern(&w.name)).collect();
|
||||
}
|
||||
crate::shortcut_editor::GENERATION.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Window → Workspace submenu items.
|
||||
pub fn menu_items() -> Vec<crate::menus::Item> {
|
||||
use crate::menus::Item;
|
||||
let mut v: Vec<Item> = builtins().into_iter().map(|w| Item::Cmd(crate::shortcut_editor::intern(&w.name), "window.workspace", json!({"name": w.name}))).collect();
|
||||
let custom = names_store().read().map(|v| v.clone()).unwrap_or_default();
|
||||
if !custom.is_empty() {
|
||||
v.push(Item::Sep);
|
||||
v.extend(custom.into_iter().map(|n| Item::Cmd(n, "window.workspace", json!({"name": n}))));
|
||||
}
|
||||
v.push(Item::Sep);
|
||||
v.push(Item::Cmd("Reset Essentials", "window.workspace.reset", Value::Null));
|
||||
v.push(Item::Cmd("New Workspace…", "window.workspace.new", Value::Null));
|
||||
v.push(Item::Cmd("Manage Workspaces…", "window.workspace.manage", Value::Null));
|
||||
v
|
||||
}
|
||||
|
||||
/// The app-bar workspace switcher's menu.
|
||||
pub fn popup(app: &mut DrawcraftApp, ui: &mut egui::Ui) {
|
||||
ui.set_min_width(210.0);
|
||||
let mut clicked: Option<(&'static str, Value)> = None;
|
||||
for it in menu_items() {
|
||||
match it {
|
||||
crate::menus::Item::Sep => {
|
||||
ui.separator();
|
||||
}
|
||||
crate::menus::Item::Cmd(label, id, p) => {
|
||||
let label = crate::menus::dynamic_label(app, id, label);
|
||||
let text = match crate::menus::checked(app, id, &p) {
|
||||
Some(true) => format!("✓ {label}"),
|
||||
Some(false) => format!(" {label}"),
|
||||
None => format!(" {label}"),
|
||||
};
|
||||
if ui.add(egui::Button::new(text).min_size(egui::vec2(ui.available_width(), 0.0))).clicked() {
|
||||
clicked = Some((id, if p.is_null() { json!({}) } else { p }));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some((id, p)) = clicked {
|
||||
crate::menus::invoke(app, id, p);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- commands ----------
|
||||
|
||||
pub fn run_command(app: &mut DrawcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
|
||||
let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string);
|
||||
let r = match id {
|
||||
"window.workspace" => match s("name").and_then(|n| find(&app.ui, &n)) {
|
||||
Some(w) => {
|
||||
apply(&mut app.ui, &w);
|
||||
Ok(json!({"workspace": w.name}))
|
||||
}
|
||||
None => Err(format!("unknown workspace (one of {})", names(&app.ui).join(", "))),
|
||||
},
|
||||
"window.workspace.reset" => {
|
||||
let w = find(&app.ui, &app.ui.workspace.clone()).unwrap_or_default();
|
||||
apply(&mut app.ui, &w);
|
||||
Ok(json!({"workspace": w.name}))
|
||||
}
|
||||
"window.workspace.new" => match s("name") {
|
||||
Some(n) => save_as(&mut app.ui, &n).map(|_| json!({"workspace": n})),
|
||||
None => {
|
||||
let n = (1..).map(|i| format!("Workspace {i}")).find(|n| find(&app.ui, n).is_none()).unwrap_or_default();
|
||||
app.ui.dialog = Some(Dialog::new("newWorkspace", json!({"name": n})));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
},
|
||||
"window.workspace.manage" => {
|
||||
app.ui.dialog = Some(Dialog::new("manageWorkspaces", json!({"selected": "", "name": ""})));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"window.workspace.delete" => delete(&mut app.ui, &s("name").unwrap_or_default()).map(|_| Value::Null),
|
||||
"window.workspace.rename" => rename(&mut app.ui, &s("name").unwrap_or_default(), &s("to").unwrap_or_default()).map(|_| Value::Null),
|
||||
"window.workspace.list" => Ok(json!({"current": app.ui.workspace, "workspaces": names(&app.ui), "custom": app.ui.custom_workspaces})),
|
||||
_ => return None,
|
||||
};
|
||||
Some(r)
|
||||
}
|
||||
|
||||
/// OK in New Workspace / Manage Workspaces.
|
||||
pub fn confirm(app: &mut DrawcraftApp) -> Result<Value, String> {
|
||||
let Some(d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) };
|
||||
let r = match d.kind.as_str() {
|
||||
"newWorkspace" => save_as(&mut app.ui, &d.str("name")).map(|_| Value::Null),
|
||||
_ => Ok(Value::Null),
|
||||
};
|
||||
if r.is_ok() {
|
||||
app.ui.dialog = None;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) {
|
||||
let Some(mut d) = app.ui.dialog.clone() else { return };
|
||||
let t = Tokens::get(ctx);
|
||||
let (mut ok, mut cancel) = (false, false);
|
||||
let mut action: Option<(&str, Value)> = None;
|
||||
egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| {
|
||||
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
|
||||
});
|
||||
let manage = d.kind == "manageWorkspaces";
|
||||
egui::Window::new("Workspace")
|
||||
.id(egui::Id::new("dialog-workspace"))
|
||||
.order(egui::Order::Foreground)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.title_bar(false)
|
||||
.anchor(egui::Align2::CENTER_CENTER, [0.0, -40.0])
|
||||
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(22)))
|
||||
.show(ctx, |ui| {
|
||||
ui.set_width(340.0);
|
||||
ui.label(egui::RichText::new(if manage { "Manage Workspaces" } else { "New Workspace" }).font(theme::semibold(16.0)).color(t.text));
|
||||
ui.add_space(12.0);
|
||||
if manage {
|
||||
egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| {
|
||||
ui.set_min_height(140.0);
|
||||
ui.set_width(ui.available_width());
|
||||
for n in names(&app.ui) {
|
||||
let builtin = is_builtin(&n);
|
||||
let text = if builtin { egui::RichText::new(&n).color(t.text_dim) } else { egui::RichText::new(&n).color(t.text) };
|
||||
if ui.selectable_label(d.str("selected") == n, text).clicked() && !builtin {
|
||||
d.fields.insert("selected".into(), json!(n));
|
||||
d.fields.insert("name".into(), json!(n));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
let sel = d.str("selected");
|
||||
ui.horizontal(|ui| {
|
||||
let mut name = d.str("name");
|
||||
if ui.add(egui::TextEdit::singleline(&mut name).desired_width(180.0)).changed() {
|
||||
d.fields.insert("name".into(), json!(name));
|
||||
}
|
||||
if ui.button("New").on_hover_text("Save the current layout under this name").clicked() {
|
||||
action = Some(("window.workspace.new", json!({"name": d.str("name")})));
|
||||
}
|
||||
ui.add_enabled_ui(!sel.is_empty(), |ui| {
|
||||
if ui.button("Rename").clicked() {
|
||||
action = Some(("window.workspace.rename", json!({"name": sel, "to": d.str("name")})));
|
||||
}
|
||||
if ui.button("Delete").clicked() {
|
||||
action = Some(("window.workspace.delete", json!({"name": sel})));
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("Name:").color(t.text_dim));
|
||||
let mut name = d.str("name");
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(240.0));
|
||||
if r.changed() {
|
||||
d.fields.insert("name".into(), json!(name));
|
||||
}
|
||||
});
|
||||
ui.label(egui::RichText::new("Saves the current bars, toolbar and panel layout.").color(t.text_dim).size(11.0));
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if widgets::primary_button(ui, "OK").clicked() {
|
||||
ok = true;
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if !manage && widgets::secondary_button(ui, "Cancel").clicked() {
|
||||
cancel = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
if !manage && ctx.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
ok = true;
|
||||
}
|
||||
app.ui.dialog = Some(d);
|
||||
if let Some((id, p)) = action {
|
||||
match run_command(app, id, &p) {
|
||||
Some(Err(e)) => app.status(e),
|
||||
_ => {
|
||||
if let Some(d) = app.ui.dialog.as_mut() {
|
||||
d.fields.insert("selected".into(), json!(""));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if cancel {
|
||||
app.ui.dialog = None;
|
||||
} else if ok && let Err(e) = confirm(app) {
|
||||
app.status(e);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn essentials_is_the_default_layout() {
|
||||
let ui = UiState::default();
|
||||
assert_eq!(capture(&ui, ESSENTIALS), builtins()[0]);
|
||||
assert_eq!(ui.workspace, ESSENTIALS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_classic_then_reset_to_essentials_keeps_brightness() {
|
||||
let mut ui = UiState { brightness: crate::theme::Brightness::Light, ..Default::default() };
|
||||
apply(&mut ui, &find(&ui, "Essentials Classic").unwrap());
|
||||
assert!(ui.control_bar && ui.view.rulers && ui.toolbar_advanced);
|
||||
assert_eq!(ui.open_panel.as_deref(), Some("color"));
|
||||
assert_eq!(ui.workspace, "Essentials Classic");
|
||||
apply(&mut ui, &find(&ui, "essentials").unwrap());
|
||||
assert!(!ui.control_bar && !ui.view.rulers);
|
||||
assert_eq!(ui.brightness, crate::theme::Brightness::Light);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_apply_round_trip() {
|
||||
let mut ui = UiState::default();
|
||||
ui.control_bar = true;
|
||||
ui.dock_tab = DockTab::Layers;
|
||||
ui.open_panel = Some("stroke".into());
|
||||
save_as(&mut ui, "Mine").unwrap();
|
||||
apply(&mut ui, &find(&ui, ESSENTIALS).unwrap());
|
||||
assert!(!ui.control_bar);
|
||||
apply(&mut ui, &find(&ui, "Mine").unwrap());
|
||||
assert!(ui.control_bar && ui.dock_tab == DockTab::Layers && ui.open_panel.as_deref() == Some("stroke"));
|
||||
// Survives the UiState JSON round trip.
|
||||
let back: UiState = serde_json::from_value(serde_json::to_value(&ui).unwrap()).unwrap();
|
||||
assert_eq!(back.custom_workspaces, ui.custom_workspaces);
|
||||
assert_eq!(back.workspace, "Mine");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_names_are_protected() {
|
||||
let mut ui = UiState::default();
|
||||
assert!(save_as(&mut ui, "Painting").is_err());
|
||||
assert!(save_as(&mut ui, " ").is_err());
|
||||
assert!(delete(&mut ui, "Essentials").is_err());
|
||||
save_as(&mut ui, "A").unwrap();
|
||||
assert!(rename(&mut ui, "A", "Web").is_err());
|
||||
rename(&mut ui, "A", "B").unwrap();
|
||||
assert_eq!(ui.workspace, "B");
|
||||
delete(&mut ui, "B").unwrap();
|
||||
assert_eq!(ui.workspace, ESSENTIALS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_requested_workspaces_exist() {
|
||||
let ui = UiState::default();
|
||||
for n in ["Essentials", "Essentials Classic", "Painting", "Typography", "Layout", "Tracing", "Web", "Automation"] {
|
||||
assert!(find(&ui, n).is_some(), "{n}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue