This commit is contained in:
Brandon Thomas
2026-09-30 11:37:31 -04:00
parent 6dd0d2dcff
commit c9f38685b4
33 changed files with 3982 additions and 86 deletions
+508
View File
@@ -0,0 +1,508 @@
//! Colour management: Edit → Color Settings / Assign Profile, document colour mode conversion
//! through the CMS, gamut checks, View → Proof Setup / Proof Colors / Overprint Preview, the
//! Separations Preview panel, Overprint Black and spot swatches.
//!
//! Colour settings and the proof view are process-wide (`drawcraft_color::cms::active`,
//! `drawcraft_render::proof::view`). A document's assigned profiles are stored in
//! `Document.unknown["colorProfiles"]` (`{rgb?, cmyk?}`) and become active when assigned.
use std::collections::BTreeMap;
use drawcraft_color::cms::{self, ColorSettings, Intent, Model, ProofTarget};
use drawcraft_color::{Color, Paint};
use drawcraft_doc::ColorMode;
use drawcraft_render::proof::{self, OVERPRINT_KEY};
use serde_json::{Value, json};
use super::*;
/// `Document.unknown` key for the assigned profiles.
pub const PROFILES_KEY: &str = "colorProfiles";
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"edit.colorSettings",
"Color Settings…",
["Edit"],
Some("Cmd+Shift+K"),
"{rgb?: profile, cmyk?: profile, intent?: \"perceptual\"|\"relative\"|\"saturation\"|\"absolute\", bpc?: bool} set the working spaces → {rgb, cmyk, intent, bpc, profiles: [{name, kind, builtin}]}",
always,
color_settings
),
cmd!(
"color.loadProfile",
"Load Color Profile…",
[],
None,
"{path: .icc/.icm file} register an ICC profile (native only) → {name, kind}",
always,
load_profile
),
cmd!(
"edit.assignProfile",
"Assign Profile…",
["Edit"],
None,
"{rgb?: profile|null, cmyk?: profile|null} tag the document with profiles (colour numbers are kept) → {rgb, cmyk}",
has_doc,
assign_profile
),
cmd!(
"object.convertDocumentColorMode",
"Convert Document Color Mode",
["File", "Document Color Mode"],
None,
"{mode: \"cmyk\"|\"rgb\", intent?} set the document colour mode and convert every colour (art, symbols, swatches; swatch links kept) through the colour settings → {changed}",
has_doc,
convert_mode
),
cmd!(
query "color.convert",
"Convert Color",
[],
None,
"{color, to: \"rgb\"|\"cmyk\"|\"gray\"|\"lab\", intent?} → {model, values, hex, lab: [L,a,b], outOfGamut, deltaE}",
always,
convert_color
),
cmd!(
query "color.gamutCheck",
"Gamut Warning",
[],
None,
"{ids?, colors?: [color]} colours that can't be printed in the working CMYK (selection, or the whole document when nothing is selected) → {checked, outOfGamut: [{hex, deltaE, count}]}",
always,
gamut_check
),
cmd!(
query "view.proofSetup",
"Proof Setup",
["View", "Proof Setup"],
None,
"{target?: \"workingCmyk\"|\"cmyk:<profile>\"|\"legacyMacRgb\"|\"srgb\"|\"monitorRgb\"|\"protanopia\"|\"deuteranopia\", intent?, simulatePaper?, proof?: bool (also turn Proof Colors on)} → proof state",
always,
proof_setup
),
cmd!(query "view.proofColors", "Proof Colors", ["View"], None, "{on?: bool (default: toggle)} → proof state", always, |s, p| {
toggle_view(s, p, |v, on| v.proof_colors = on.unwrap_or(!v.proof_colors))
}),
cmd!(
query "view.overprintPreview",
"Overprint Preview",
["View"],
Some("Cmd+Alt+Shift+Y"),
"{on?: bool (default: toggle)} → proof state",
always,
|s, p| toggle_view(s, p, |v, on| v.overprint = on.unwrap_or(!v.overprint))
),
cmd!(
query "view.separationsPreview",
"Separations Preview",
[],
None,
"{on?: bool, plates?: [names], toggle?: plate, only?: plate} Separations Preview: show the chosen plates (one plate = greyscale ink coverage) → {on, plates: [{name, spot, visible, rgb}]}",
always,
separations
),
cmd!(query "color.plates", "Plates", [], None, "{} → {on, plates: [{name, spot, visible, rgb}]}", always, |s, _| Ok(plates_json(s))),
cmd!(
"edit.colors.overprintBlack",
"Overprint Black…",
["Edit", "Edit Colors"],
None,
"{remove?: false, percentage?: 100, ids?} mark (or unmark) objects whose fill or stroke is black-only ink ≥ percentage as overprinting (Overprint Preview, separations) → {changed}",
has_doc,
overprint_black
),
cmd!(
"swatch.setSpot",
"Spot Color",
[],
None,
"{name, spot?: true} make a swatch a spot colour (prints on its own plate; spot swatches are global) → {name, spot}",
has_doc,
set_spot
),
]
}
fn intent_param(p: &Value, cmd: &str) -> Result<Option<Intent>> {
match str_param(p, "intent") {
None => Ok(None),
Some(i) => Intent::parse(i).map(Some).ok_or_else(|| bad(cmd, format!("unknown intent `{i}`"))),
}
}
fn settings_json(st: &ColorSettings) -> Value {
json!({
"rgb": st.rgb,
"cmyk": st.cmyk,
"intent": st.intent.id(),
"bpc": st.bpc,
"profiles": cms::profiles().iter().map(|p| json!({"name": p.name, "kind": p.kind, "builtin": p.builtin})).collect::<Vec<_>>(),
})
}
/// Colour settings/proof changes don't edit the document, but the canvas must redraw: bump the
/// revisions without marking documents dirty.
fn touch_all(s: &mut Session) {
for d in &mut s.docs {
let clean = d.revision == d.saved_revision;
d.revision += 1;
if clean {
d.saved_revision = d.revision;
}
}
}
fn color_settings(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "edit.colorSettings";
let mut st = cms::active_settings();
let before = st.clone();
if let Some(v) = str_param(p, "rgb") {
st.rgb = v.to_string();
}
if let Some(v) = str_param(p, "cmyk") {
st.cmyk = v.to_string();
}
if let Some(i) = intent_param(p, C)? {
st.intent = i;
}
st.bpc = bool_or(p, "bpc", st.bpc);
if st != before {
cms::set_active(&st).map_err(|e| bad(C, e.to_string()))?;
touch_all(s);
}
Ok(settings_json(&st))
}
#[cfg(not(target_arch = "wasm32"))]
fn load_profile(_: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "color.loadProfile";
let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?;
let info = cms::load_icc_file(std::path::Path::new(path)).map_err(|e| bad(C, e.to_string()))?;
Ok(json!({"name": info.name, "kind": info.kind}))
}
#[cfg(target_arch = "wasm32")]
fn load_profile(_: &mut Session, _: &Value) -> Result<Value> {
Err(bad("color.loadProfile", "loading profiles from disk isn't available in the browser"))
}
/// The document's assigned profiles (`None` = working space).
pub fn doc_profiles(d: &drawcraft_doc::Document) -> (Option<String>, Option<String>) {
let o = d.unknown.get(PROFILES_KEY);
let get = |k: &str| o.and_then(|o| o.get(k)).and_then(Value::as_str).map(str::to_string);
(get("rgb"), get("cmyk"))
}
fn assign_profile(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "edit.assignProfile";
let (mut rgb, mut cmyk) = doc_profiles(&s.doc()?.doc);
let known = cms::profiles();
let pick = |key: &str, cur: &mut Option<String>, kind: cms::ProfileKind| -> Result<bool> {
match p.get(key) {
None => Ok(false),
Some(Value::Null) => Ok(cur.take().is_some()),
Some(Value::String(n)) => {
if !known.iter().any(|k| &k.name == n && k.kind == kind) {
return Err(bad(C, format!("unknown {key} profile `{n}`")));
}
let changed = cur.as_deref() != Some(n.as_str());
*cur = Some(n.clone());
Ok(changed)
}
Some(_) => Err(bad(C, format!("`{key}` must be a profile name or null"))),
}
};
let changed = pick("rgb", &mut rgb, cms::ProfileKind::Rgb)? | pick("cmyk", &mut cmyk, cms::ProfileKind::Cmyk)?;
if changed {
let (r, c) = (rgb.clone(), cmyk.clone());
s.edit("Assign Profile", |d, _| {
if r.is_none() && c.is_none() {
d.unknown.remove(PROFILES_KEY);
} else {
d.unknown.insert(PROFILES_KEY.into(), json!({"rgb": r, "cmyk": c}));
}
Ok(())
})?;
let mut st = cms::active_settings();
if let Some(r) = &rgb {
st.rgb = r.clone();
}
if let Some(c) = &cmyk {
st.cmyk = c.clone();
}
cms::set_active(&st).map_err(|e| bad(C, e.to_string()))?;
touch_all(s);
}
Ok(json!({"rgb": rgb, "cmyk": cmyk}))
}
fn convert_mode(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.convertDocumentColorMode";
let mode = match str_param(p, "mode").map(str::to_ascii_lowercase).as_deref() {
Some("cmyk") => ColorMode::Cmyk,
Some("rgb") => ColorMode::Rgb,
_ => return Err(bad(C, "mode must be \"cmyk\" or \"rgb\"")),
};
let c = cms::active();
let intent = intent_param(p, C)?.unwrap_or(c.settings().intent);
let model = if mode == ColorMode::Cmyk { Model::Cmyk } else { Model::Rgb };
// Greys stay greys (they print on the black plate in either mode).
let conv = |col: &Color| if matches!(col, Color::Gray { .. }) { *col } else { c.convert(col, model, intent) };
let mut changed = 0usize;
s.edit("Document Color Mode", |d, _| {
d.color_mode = mode;
proof::map_document_colors(d, &mut |col, _| {
let n = conv(col);
if n != *col {
changed += 1;
}
n
});
for sw in &mut d.swatches {
match &mut sw.paint {
Paint::Solid { color, .. } => *color = conv(color),
Paint::Gradient(g) => {
for st in &mut g.gradient.stops {
st.color = conv(&st.color);
}
}
_ => {}
}
}
Ok(())
})?;
Ok(json!({ "changed": changed }))
}
fn values(c: &Color) -> Vec<f32> {
match *c {
Color::Rgb { r, g, b } => vec![r, g, b],
Color::Cmyk { c, m, y, k } => vec![c, m, y, k],
Color::Gray { k } => vec![k],
}
}
fn convert_color(_: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "color.convert";
let col = p.get("color").and_then(color_value).ok_or_else(|| bad(C, "missing or invalid `color`"))?;
let c = cms::active();
let intent = intent_param(p, C)?.unwrap_or(c.settings().intent);
let lab = c.lab(&col);
let (model, vals) = match str_param(p, "to").unwrap_or("rgb") {
"rgb" => ("rgb", values(&c.convert(&col, Model::Rgb, intent))),
"cmyk" => ("cmyk", values(&c.convert(&col, Model::Cmyk, intent))),
"gray" | "grayscale" => ("gray", values(&c.convert(&col, Model::Gray, intent))),
"lab" => ("lab", vec![lab.l, lab.a, lab.b]),
other => return Err(bad(C, format!("unknown model `{other}`"))),
};
let de = c.gamut_error(&col);
Ok(json!({
"model": model,
"values": vals,
"hex": col.to_hex(),
"lab": [lab.l, lab.a, lab.b],
"outOfGamut": de > cms::GAMUT_THRESHOLD,
"deltaE": de,
}))
}
fn gamut_check(s: &mut Session, p: &Value) -> Result<Value> {
let mut cols: Vec<Color> = vec![];
if let Some(a) = p.get("colors").and_then(Value::as_array) {
cols.extend(a.iter().filter_map(color_value));
} else {
let st = s.doc()?;
let ids = targets(s, p)?;
let mut collect = |n: &drawcraft_doc::Node| {
let mut n = n.clone();
proof::map_node_colors(&mut n, &mut |c, _| {
cols.push(*c);
*c
});
};
if ids.is_empty() {
for l in &st.doc.layers {
collect(l);
}
} else {
for id in ids {
if let Some(n) = st.doc.node(id) {
collect(n);
}
}
}
}
let c = cms::active();
let mut out: BTreeMap<String, (f32, usize)> = BTreeMap::new();
for col in &cols {
let de = c.gamut_error(col);
if de > cms::GAMUT_THRESHOLD {
out.entry(col.to_hex()).or_insert((de, 0)).1 += 1;
}
}
Ok(json!({
"checked": cols.len(),
"outOfGamut": out.iter().map(|(h, (de, n))| json!({"hex": h, "deltaE": de, "count": n})).collect::<Vec<_>>(),
}))
}
fn view_json(v: &proof::ProofView) -> Value {
json!({
"target": v.setup.target.id(),
"intent": v.setup.intent.id(),
"simulatePaper": v.setup.simulate_paper,
"proofColors": v.proof_colors,
"overprintPreview": v.overprint,
"separations": v.separations,
})
}
fn proof_setup(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "view.proofSetup";
let mut v = proof::view();
if let Some(t) = str_param(p, "target") {
let t = ProofTarget::parse(t).ok_or_else(|| bad(C, format!("unknown proof target `{t}`")))?;
if let ProofTarget::Cmyk(name) = &t
&& !cms::profiles().iter().any(|k| &k.name == name && k.kind == cms::ProfileKind::Cmyk)
{
return Err(bad(C, format!("unknown CMYK profile `{name}`")));
}
v.setup.target = t;
}
if let Some(i) = intent_param(p, C)? {
v.setup.intent = i;
}
v.setup.simulate_paper = bool_or(p, "simulatePaper", v.setup.simulate_paper);
if let Some(on) = p.get("proof").and_then(Value::as_bool) {
v.proof_colors = on;
}
proof::set_view(v.clone());
touch_all(s);
Ok(view_json(&v))
}
fn toggle_view(s: &mut Session, p: &Value, f: impl FnOnce(&mut proof::ProofView, Option<bool>)) -> Result<Value> {
let mut v = proof::view();
f(&mut v, p.get("on").and_then(Value::as_bool));
proof::set_view(v.clone());
touch_all(s);
Ok(view_json(&v))
}
fn plates_json(s: &Session) -> Value {
let v = proof::view();
let plates = s.active().map(|d| proof::plates(&d.doc)).unwrap_or_default();
json!({
"on": v.separations.is_some(),
"plates": plates.iter().map(|pl| json!({
"name": pl.name,
"spot": pl.spot,
"visible": v.separations.as_ref().is_none_or(|vis| vis.contains(&pl.name)),
"rgb": pl.rgb,
})).collect::<Vec<_>>(),
})
}
fn separations(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "view.separationsPreview";
let mut v = proof::view();
let all: Vec<String> = s.active().map(|d| proof::plates(&d.doc).into_iter().map(|p| p.name).collect()).unwrap_or_else(|| cms::PROCESS_PLATES.iter().map(|s| s.to_string()).collect());
let check = |n: &str| if all.iter().any(|a| a == n) { Ok(n.to_string()) } else { Err(bad(C, format!("unknown plate `{n}`"))) };
if let Some(on) = p.get("on").and_then(Value::as_bool) {
v.separations = if on { Some(v.separations.take().unwrap_or_else(|| all.clone())) } else { None };
}
if let Some(a) = p.get("plates").and_then(Value::as_array) {
v.separations = Some(a.iter().filter_map(Value::as_str).map(check).collect::<Result<Vec<_>>>()?);
}
if let Some(n) = str_param(p, "only") {
v.separations = Some(vec![check(n)?]);
}
if let Some(n) = str_param(p, "toggle") {
let n = check(n)?;
let vis = v.separations.get_or_insert_with(|| all.clone());
if let Some(i) = vis.iter().position(|x| *x == n) {
vis.remove(i);
} else {
vis.push(n);
}
}
proof::set_view(v);
touch_all(s);
Ok(plates_json(s))
}
fn overprint_black(s: &mut Session, p: &Value) -> Result<Value> {
let remove = bool_or(p, "remove", false);
let pct = (f64_or(p, "percentage", 100.0) / 100.0).clamp(0.0, 1.0) as f32;
let ids = targets(s, p)?;
let doc = &s.doc()?.doc;
let black_only = |c: &Color| match *c {
Color::Cmyk { c, m, y, k } => k >= pct - 0.005 && c <= 0.005 && m <= 0.005 && y <= 0.005,
Color::Gray { k } => k >= pct - 0.005,
Color::Rgb { .. } => false,
};
let mut hits: Vec<u64> = vec![];
for id in &ids {
if let Some(n) = doc.node(*id) {
n.walk(&mut |m: &drawcraft_doc::Node| {
let bl = m.appearance.items.iter().any(|it| match it {
drawcraft_doc::AppearanceItem::Fill(l) => l.paint.color().is_some_and(|c| black_only(&c)),
drawcraft_doc::AppearanceItem::Stroke(l) => l.paint.color().is_some_and(|c| black_only(&c)),
});
if bl {
hits.push(m.id.0);
}
});
}
}
let mut list: Vec<u64> = proof::overprint_ids(doc).into_iter().map(|i| i.0).collect();
let before = list.clone();
if remove {
list.retain(|i| !hits.contains(i));
} else {
for h in &hits {
if !list.contains(h) {
list.push(*h);
}
}
}
let changed = if remove { before.len() - list.len() } else { list.len() - before.len() };
if changed > 0 {
s.edit("Overprint Black", |d, _| {
if list.is_empty() {
d.unknown.remove(OVERPRINT_KEY);
} else {
d.unknown.insert(OVERPRINT_KEY.into(), json!(list));
}
Ok(())
})?;
}
Ok(json!({ "changed": changed }))
}
fn set_spot(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "swatch.setSpot";
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?.to_string();
let spot = bool_or(p, "spot", true);
let sw = s.doc()?.doc.swatches.iter().find(|w| w.name == name).ok_or_else(|| bad(C, format!("no swatch `{name}`")))?;
if !matches!(sw.paint, Paint::Solid { .. }) {
return Err(bad(C, "only solid-colour swatches can be spot colours"));
}
if sw.spot != spot {
let n = name.clone();
s.edit("Swatch Options", |d, _| {
if let Some(w) = d.swatches.iter_mut().find(|w| w.name == n) {
w.spot = spot;
if spot {
w.global = true;
}
}
Ok(())
})?;
}
Ok(json!({"name": name, "spot": spot}))
}
+496
View File
@@ -0,0 +1,496 @@
//! Commands behind the distortion tools: width points, Liquify, Puppet Warp and the Perspective
//! Grid. The geometry kernels live in `drawcraft_tools::distort` (shared with the tools).
//!
//! The perspective grid definition is document data stored under
//! `Document.unknown["perspectiveGrid"]` (read/written by [`grid_of`] / [`store_grid`]).
use std::sync::Arc;
use drawcraft_doc::{Document, NodeId, NodeKind, WidthProfile};
use drawcraft_geom::Point;
use drawcraft_tools::distort::liquify::{LiquifyParams, apply_stroke, dabs};
use drawcraft_tools::distort::perspective::{PerspectiveGrid, Plane};
use drawcraft_tools::distort::{arap, collect_points, mesh_for, warp_node_with};
use serde_json::{Value, json};
use super::edit::selected_roots;
use super::*;
use crate::EngineError;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"stroke.widthPoint.set",
"Width Point",
[],
None,
"{id, t: 0..1 (fraction of the path length), left, right: side widths in pt, index?: width point to move/replace} → {index}. Creates a uniform profile first if needed",
has_doc,
width_point_set
),
cmd!("stroke.widthPoint.remove", "Delete Width Point", [], None, "{id, index}", has_doc, width_point_remove),
cmd!(
"stroke.widthProfile.set",
"Width Profile",
[],
None,
"{ids?|id?, points: [[t, left, right]…] (width factors, 1 = the stroke weight) | null = uniform}",
has_doc,
width_profile_set
),
cmd!(
"object.liquify",
"Liquify",
[],
None,
"{tool: warp|twirl|pucker|bloat|scallop|crystallize|wrinkle, points: [[x,y]…] (the brush stroke), diameter?|width?, height? (pt, default 100), angle?, intensity? (0..1 or %), detail? (1..10), simplify? (0..100), rate? (twirl °), complexity?, horizontal?, vertical?, affectAnchors?, affectIn?, affectOut?, ids? (default: selection, else every path the brush touches)}",
has_doc,
liquify
),
cmd!(
"object.puppetWarp",
"Puppet Warp",
[],
None,
"{id?|ids? (default: selection), pins: [[x,y]…] (current pin positions), moved: [[x,y]…] (targets, same length), expand?: pt} as-rigid-as-possible mesh warp of anchors and handles",
has_doc,
puppet_warp
),
cmd!(
"perspective.grid.set",
"Define Perspective Grid",
[],
None,
"{kind?: 1|2|3, origin?: [x,y], horizon?, vpLeft?, vpRight?, vpVertical?: [x,y], distance?, cell?, extent?, height?, visible?, plane?} merge into the document's grid",
has_doc,
grid_set
),
cmd!(
"perspective.grid.preset",
"Perspective Grid Preset",
[],
None,
"{kind: 1|2|3} reset the grid to the one/two/three-point preset for the first artboard (and show it)",
has_doc,
grid_preset
),
cmd!(
"perspective.grid.show",
"Show Perspective Grid",
[],
Some("Cmd+Shift+I"),
"{visible?: bool} (no param toggles). View state: not an undo step",
has_doc,
grid_show
),
cmd!(
"perspective.plane.set",
"Active Perspective Plane",
[],
None,
"{plane: left|right|ground|none} Plane Switching widget. View state: not an undo step",
has_doc,
plane_set
),
cmd!(
"perspective.attach",
"Attach to Active Plane",
[],
None,
"{ids?, plane?: left|right|ground (default: the active plane)} project the objects onto the plane",
has_doc,
attach
),
cmd!("perspective.release", "Release with Perspective", [], None, "{ids?} detach from the grid (geometry unchanged)", has_doc, release),
cmd!(
"perspective.move",
"Move in Perspective",
[],
None,
"{ids?, from: [x,y], to: [x,y], plane?} slide objects within their plane (unattached objects attach to `plane`/the active plane first)",
has_doc,
persp_move
),
cmd!(
"perspective.draw",
"Draw in Perspective",
[],
None,
"{command: shape.* id, params, plane?} run a shape command and attach the result to the plane",
has_doc,
persp_draw
),
]
}
// ---------- width points ----------
fn uniform() -> WidthProfile {
WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] }
}
fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "stroke.widthPoint.set";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let t = f64_req(p, "t", C)?.clamp(0.0, 1.0);
let (left, right) = (f64_req(p, "left", C)?, f64_req(p, "right", C)?);
if !(left.is_finite() && right.is_finite()) || left < 0.0 || right < 0.0 || left.max(right) > 1.0e5 {
return Err(bad(C, "left/right must be non-negative widths"));
}
let index = p.get("index").and_then(Value::as_u64).map(|v| v as usize);
let pos = s.edit("Width Point", |d, _| {
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
let st = n.appearance.stroke_mut().ok_or_else(|| EngineError::Other("the object has no stroke".into()))?;
if st.width <= 0.0 {
return Err(EngineError::Other("the stroke has no weight".into()));
}
let half = st.width / 2.0;
let mut prof = st.profile.take().unwrap_or_else(uniform);
if let Some(i) = index {
if i >= prof.points.len() {
return Err(bad(C, "no such width point"));
}
prof.points.remove(i);
}
prof.points.retain(|q| (q.0 - t).abs() > 1e-6);
let pos = prof.points.iter().position(|q| q.0 > t).unwrap_or(prof.points.len());
prof.points.insert(pos, (t, left / half, right / half));
st.profile = Some(prof);
Ok(pos)
})?;
Ok(json!({ "index": pos }))
}
fn width_point_remove(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "stroke.widthPoint.remove";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let index = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing index"))? as usize;
s.edit("Delete Width Point", |d, _| {
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
let st = n.appearance.stroke_mut().ok_or_else(|| EngineError::Other("the object has no stroke".into()))?;
let prof = st.profile.as_mut().filter(|pr| index < pr.points.len()).ok_or_else(|| bad(C, "no such width point"))?;
prof.points.remove(index);
if prof.points.is_empty() {
st.profile = None;
}
Ok(())
})?;
ok()
}
fn width_profile_set(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "stroke.widthProfile.set";
let profile = match p.get("points") {
None => return Err(bad(C, "missing points")),
Some(Value::Null) => None,
Some(Value::Array(a)) if a.is_empty() => None,
Some(Value::Array(a)) => {
let mut pts = vec![];
for q in a {
let v: Vec<f64> = q.as_array().map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
if v.len() != 3 || v.iter().any(|x| !x.is_finite()) || v[1] < 0.0 || v[2] < 0.0 {
return Err(bad(C, "points must be [t, left, right] with non-negative widths"));
}
pts.push((v[0].clamp(0.0, 1.0), v[1], v[2]));
}
pts.sort_by(|a, b| a.0.total_cmp(&b.0));
Some(WidthProfile { points: pts })
}
_ => return Err(bad(C, "points must be an array or null")),
};
let ids = targets(s, p)?;
s.edit("Width Profile", |d, _| {
for id in &ids {
let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?;
if let Some(st) = n.appearance.stroke_mut() {
st.profile = profile.clone();
}
}
Ok(())
})?;
ok()
}
// ---------- liquify ----------
fn points_of(p: &Value, key: &str) -> Option<Vec<Point>> {
p.get(key)?.as_array()?.iter().map(|q| Some(Point::new(q.get(0)?.as_f64()?, q.get(1)?.as_f64()?))).collect()
}
/// Leaf paths under `roots` that can be edited.
fn leaf_paths(d: &Document, roots: &[NodeId]) -> Vec<NodeId> {
let mut out = vec![];
for r in roots {
if let Some(n) = d.node(*r) {
n.walk(&mut |c| {
if matches!(c.kind, NodeKind::Path { .. }) && !out.contains(&c.id) {
out.push(c.id);
}
});
}
}
out.retain(|id| d.is_editable(*id));
out
}
fn liquify(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.liquify";
let prm = LiquifyParams::from_json(p).ok_or_else(|| bad(C, "missing or unknown `tool`"))?;
let pts = points_of(p, "points").filter(|v| !v.is_empty()).ok_or_else(|| bad(C, "points must be a non-empty [[x,y]…] list"))?;
if pts.iter().any(|q| !q.x.is_finite() || !q.y.is_finite()) {
return Err(bad(C, "points must be finite"));
}
let dab_pts = dabs(&pts, prm.dab_spacing());
let roots = match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) {
Some(v) => v,
None => s.doc()?.selection.objects.clone(),
};
let doc = &s.doc()?.doc;
let leaves = if roots.is_empty() {
// Nothing selected: everything the brush sweeps over.
let sweep = dab_pts.iter().map(|c| prm.brush_bounds(*c)).reduce(|a, b| a.union(b)).unwrap();
let mut all = vec![];
doc.walk(|n| {
if matches!(n.kind, NodeKind::Path { guide: false, .. }) && n.geometric_bounds().is_some_and(|b| b.inflate(1e-6, 1e-6).intersect(sweep).area() > 0.0) {
all.push(n.id);
}
});
all.retain(|id| doc.is_editable(*id) && doc.is_visible(*id));
all
} else {
leaf_paths(doc, &roots)
};
let changed = s.edit(&format!("{} Tool", prm.kind.label()), |d, _| {
let mut changed = vec![];
for id in &leaves {
let Some(n) = d.node_mut(*id) else { continue };
if let NodeKind::Path { path, live, .. } = &mut n.kind
&& apply_stroke(path, &dab_pts, &prm, id.0)
{
*live = None;
changed.push(id.0);
}
}
Ok(changed)
})?;
Ok(json!({ "ids": changed }))
}
// ---------- puppet warp ----------
fn puppet_warp(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.puppetWarp";
let pins = points_of(p, "pins").ok_or_else(|| bad(C, "pins must be [[x,y]…]"))?;
let moved = points_of(p, "moved").ok_or_else(|| bad(C, "moved must be [[x,y]…]"))?;
if pins.is_empty() || pins.len() != moved.len() {
return Err(bad(C, "pins and moved must be non-empty lists of the same length"));
}
if pins.iter().chain(&moved).any(|q| !q.x.is_finite() || !q.y.is_finite()) {
return Err(bad(C, "pins must be finite"));
}
let ids = match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) {
Some(v) => v,
None => selected_roots(s)?,
};
if ids.is_empty() {
return Err(EngineError::Other("select the artwork to warp".into()));
}
let expand = f64_or(p, "expand", 3.0).clamp(0.0, 1000.0);
let mesh = mesh_for(&s.doc()?.doc, &ids, expand).ok_or_else(|| EngineError::Other("nothing to warp".into()))?;
let pins: Vec<arap::Pin> = pins.iter().zip(&moved).map(|(a, b)| arap::Pin { rest: *a, target: *b }).collect();
let deformed = arap::deform(&mesh, &pins);
let f = |q: Point| mesh.map(&deformed, q);
s.edit("Puppet Warp", |d, _| {
for id in &ids {
let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?;
warp_node_with(n, &f);
}
Ok(())
})?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
// ---------- perspective grid ----------
/// The document's perspective grid (the default preset when none was defined).
pub(crate) fn grid_of(d: &Document) -> PerspectiveGrid {
PerspectiveGrid::effective(d)
}
/// Write the grid into the document (`Document.unknown["perspectiveGrid"]`).
pub(crate) fn store_grid(d: &mut Document, g: &PerspectiveGrid) {
g.store(d);
}
/// Change view state of the grid without an undo step.
fn silent(s: &mut Session, f: impl FnOnce(&mut PerspectiveGrid)) -> Result<PerspectiveGrid> {
let st = s.doc_mut()?;
let mut g = grid_of(&st.doc);
f(&mut g);
store_grid(Arc::make_mut(&mut st.doc), &g);
if let Some(it) = &mut st.interaction {
store_grid(Arc::make_mut(&mut it.doc), &g);
}
st.revision += 1;
Ok(g)
}
fn grid_set(s: &mut Session, p: &Value) -> Result<Value> {
let g = grid_of(&s.doc()?.doc).merged(p).map_err(|e| bad("perspective.grid.set", e))?;
s.edit("Define Perspective Grid", |d, _| {
store_grid(d, &g);
Ok(())
})?;
Ok(g.definition_json())
}
fn grid_preset(s: &mut Session, p: &Value) -> Result<Value> {
let kind = p.get("kind").and_then(Value::as_u64).filter(|k| (1..=3).contains(k)).ok_or_else(|| bad("perspective.grid.preset", "kind must be 1, 2 or 3"))? as u8;
let old = grid_of(&s.doc()?.doc);
let ab = s.doc()?.doc.artboards.first().map(|a| a.rect).ok_or_else(|| EngineError::Other("no artboard".into()))?;
let g = PerspectiveGrid { attached: old.attached, ..PerspectiveGrid::preset(kind, ab) };
s.edit("Perspective Grid Preset", |d, _| {
store_grid(d, &g);
Ok(())
})?;
Ok(g.definition_json())
}
fn grid_show(s: &mut Session, p: &Value) -> Result<Value> {
let want = p.get("visible").and_then(Value::as_bool);
let g = silent(s, |g| g.visible = want.unwrap_or(!g.visible))?;
Ok(json!({ "visible": g.visible }))
}
fn plane_set(s: &mut Session, p: &Value) -> Result<Value> {
let plane = str_param(p, "plane").and_then(Plane::parse).ok_or_else(|| bad("perspective.plane.set", "plane must be left|right|ground|none"))?;
silent(s, |g| g.plane = plane)?;
Ok(json!({ "plane": plane.id() }))
}
fn plane_param(g: &PerspectiveGrid, p: &Value, cmd: &str) -> Result<Plane> {
let plane = match str_param(p, "plane") {
Some(v) => Plane::parse(v).ok_or_else(|| bad(cmd, "plane must be left|right|ground"))?,
None => g.plane,
};
if plane == Plane::None {
return Err(EngineError::Other("no active perspective plane".into()));
}
Ok(plane)
}
fn roots(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) {
Some(v) => Ok(v),
None => selected_roots(s),
}
}
/// Map `n` with `f`, failing if any point would leave the plane's visible side.
fn warp_checked(d: &mut Document, id: NodeId, f: &dyn Fn(Point) -> Option<Point>) -> Result<()> {
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
let mut pts = vec![];
collect_points(n, &mut pts);
if pts.iter().any(|q| f(*q).is_none()) {
return Err(EngineError::Other("the object would cross the horizon of the perspective plane".into()));
}
warp_node_with(n, &|q| f(q).unwrap_or(q));
Ok(())
}
fn attach_in(d: &mut Document, g: &mut PerspectiveGrid, id: NodeId, plane: Plane) -> Result<()> {
let b = d.node(id).ok_or(EngineError::NoNode(id))?.geometric_bounds().ok_or_else(|| EngineError::Other("the object has no geometry".into()))?;
let m = g.attach_map(plane, b).ok_or_else(|| EngineError::Other("the object is beyond the plane's horizon".into()))?;
warp_checked(d, id, &m)?;
g.attached.insert(id.0.to_string(), plane);
Ok(())
}
fn attach(s: &mut Session, p: &Value) -> Result<Value> {
let ids = roots(s, p)?;
let mut g = grid_of(&s.doc()?.doc);
let plane = plane_param(&g, p, "perspective.attach")?;
s.edit("Attach to Active Plane", |d, _| {
for id in &ids {
attach_in(d, &mut g, *id, plane)?;
}
store_grid(d, &g);
Ok(())
})?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "plane": plane.id() }))
}
fn release(s: &mut Session, p: &Value) -> Result<Value> {
let ids = roots(s, p)?;
let mut g = grid_of(&s.doc()?.doc);
s.edit("Release with Perspective", |d, _| {
for id in &ids {
g.attached.remove(&id.0.to_string());
}
store_grid(d, &g);
Ok(())
})?;
ok()
}
fn persp_move(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.move";
let ids = roots(s, p)?;
let from = point_param(p, "from").ok_or_else(|| bad(C, "missing from"))?;
let to = point_param(p, "to").ok_or_else(|| bad(C, "missing to"))?;
let mut g = grid_of(&s.doc()?.doc);
let fallback = plane_param(&g, p, C).ok();
s.edit("Move in Perspective", |d, _| {
for id in &ids {
let plane = match g.attached_plane(*id) {
Some(pl) => pl,
None => {
let pl = fallback.ok_or_else(|| EngineError::Other("the object isn't on a perspective plane".into()))?;
attach_in(d, &mut g, *id, pl)?;
pl
}
};
let m = g.move_map(plane, from, to).ok_or_else(|| EngineError::Other("the pointer is beyond the plane's horizon".into()))?;
warp_checked(d, *id, &m)?;
}
store_grid(d, &g);
Ok(())
})?;
ok()
}
fn persp_draw(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.draw";
let command = str_param(p, "command").filter(|c| c.starts_with("shape.")).ok_or_else(|| bad(C, "command must be a shape.* command"))?.to_string();
let params = p.get("params").cloned().unwrap_or_else(|| json!({}));
let mut g = grid_of(&s.doc()?.doc);
let plane = plane_param(&g, p, C)?;
let undo_before = s.doc()?.history.undo.len();
let r = s.execute(&command, &params)?;
let id = r.get("id").and_then(Value::as_u64).map(NodeId).ok_or_else(|| EngineError::Other(format!("{command} didn't create an object")))?;
let res = s.edit("Draw in Perspective", |d, _| {
attach_in(d, &mut g, id, plane)?;
store_grid(d, &g);
Ok(())
});
// One undo step for the whole gesture.
let st = s.doc_mut()?;
if st.interaction.is_none() && st.history.undo.len() > undo_before + 1 {
st.history.undo.truncate(undo_before + 1);
if let Some(e) = st.history.undo.last_mut() {
e.label = "Draw in Perspective".into();
}
}
res?;
Ok(json!({ "id": id.0, "plane": plane.id() }))
}
/// Shape tool previews become `perspective.draw` while the grid is shown with an active plane.
pub(crate) fn perspective_rewrite(s: &Session, cmd: &str, params: &Value) -> Option<(String, Value)> {
const SHAPES: &[&str] = &["shape.rectangle", "shape.ellipse", "shape.polygon", "shape.star", "shape.line", "shape.rectangularGrid", "shape.arc"];
if !SHAPES.contains(&cmd) {
return None;
}
let g = PerspectiveGrid::from_doc(&s.active()?.doc)?;
(g.visible && g.plane != Plane::None).then(|| ("perspective.draw".to_string(), json!({"command": cmd, "params": params})))
}
+6
View File
@@ -3,7 +3,9 @@
mod brushsym;
mod buildcmds;
mod colorcmds;
pub mod colormgmt;
mod create;
pub(crate) mod distortcmds;
mod docmenu;
mod draw2;
mod edit;
@@ -14,6 +16,7 @@ mod live;
pub(crate) mod menucmds;
mod object;
mod paint;
mod patterncmds;
mod panelcmds;
mod path;
mod pathops;
@@ -138,13 +141,16 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(menucmds::specs());
v.extend(live::specs());
v.extend(colorcmds::specs());
v.extend(colormgmt::specs());
v.extend(typemenu::specs());
v.extend(textedit::specs());
v.extend(docmenu::specs());
v.extend(panelcmds::specs());
v.extend(buildcmds::specs());
v.extend(brushsym::specs());
v.extend(patterncmds::specs());
v.extend(prefscmds::specs());
v.extend(distortcmds::specs());
v
})
}
+4
View File
@@ -76,6 +76,10 @@ pub(crate) fn paint_from(s: &Session, p: &Value) -> Result<Option<Paint>> {
}
if let Some(name) = str_param(p, "swatch") {
let st = s.doc()?;
// A pattern definition works as its swatch even without a swatch entry.
if st.doc.swatch(name).is_none() && st.doc.pattern(name).is_some() {
return Ok(Some(drawcraft_doc::pattern::pattern_paint(name)));
}
let sw = st.doc.swatch(name).ok_or_else(|| EngineError::Other(format!("no swatch `{name}`")))?;
let mut paint = sw.paint.clone();
if sw.global
+804
View File
@@ -0,0 +1,804 @@
//! Pattern swatches (Object → Pattern, Pattern Options panel) and Repeat (Object → Repeat).
//!
//! **Pattern editing mode** works like isolation: `object.pattern.make` / `object.pattern.edit`
//! put a copy of the tile art into a temporary top layer (`Document::pattern_edit` records it),
//! which the renderer draws alone with dimmed copies around it; hit testing only reaches that
//! layer. `object.pattern.done` stores the layer's art as the pattern definition,
//! `object.pattern.cancel` restores the previous definition. Both remove the layer.
//!
//! **Repeat** objects are live nodes (`NodeKind::Repeat`) holding the source art and the
//! arrangement; `drawcraft_doc::pattern` evaluates the instances.
use std::sync::Arc;
use drawcraft_color::Paint;
use drawcraft_doc::pattern::{self, Overlap, PATTERN_EDIT_LAYER, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType, pattern_paint};
use drawcraft_doc::{Document, Node, NodeId, NodeKind, Selection};
use drawcraft_geom::Rect;
use serde_json::{Value, json};
use super::edit::selected_roots;
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
// ---------- Pattern ----------
cmd!(
"object.pattern.make",
"Make",
["Object", "Pattern"],
None,
"{ids?, name?, tileType?: grid|brickByRow|brickByColumn|hexByColumn|hexByRow, brickOffset?: 0..1 (0.5), width?, height?, edit?: bool (true)} make a pattern swatch from the selection and enter pattern editing mode → {name}",
has_selection,
pattern_make
),
cmd!(
"object.pattern.edit",
"Edit Pattern",
["Object", "Pattern"],
Some("Cmd+Shift+F8"),
"{name?} edit a pattern (default: the selected object's fill pattern) in pattern editing mode",
can_edit_pattern,
pattern_edit
),
cmd!(
"object.pattern.done",
"Done",
[],
None,
"{} leave pattern editing mode, saving the tile art into the pattern",
in_pattern_edit,
pattern_done
),
cmd!("object.pattern.cancel", "Cancel", [], None, "{} leave pattern editing mode, discarding changes", in_pattern_edit, pattern_cancel),
cmd!(
"object.pattern.saveCopy",
"Save a Copy",
[],
None,
"{name?} while editing, save the current tile as a new pattern swatch → {name}",
in_pattern_edit,
pattern_save_copy
),
cmd!(
"pattern.options",
"Pattern Options",
["Window", "Pattern Options"],
None,
"{name? (default: the pattern being edited), newName?, tileType?, brickOffset?, width?, height?, hSpacing?, vSpacing?, sizeTileToArt?: bool, overlap?: {h: left|right, v: top|bottom}, copies?: 3|5|7|9, dimCopies?: %, showTileEdge?: bool}",
has_doc,
pattern_options
),
cmd!(
"pattern.delete",
"Delete Pattern",
[],
None,
"{name} delete a pattern swatch (objects painted with it lose that paint)",
has_doc,
pattern_delete
),
cmd!(query "pattern.list", "List Patterns", [], None, "{} → {patterns: [{name, tileType, width, height, ...}], editing?}", has_doc, pattern_list),
cmd!(
"pattern.transform",
"Transform Patterns",
[],
None,
"{ids?, matrix: [a,b,c,d,e,f]} transform only the pattern placement of the objects' pattern paints (Transform Patterns)",
has_selection_or_ids,
pattern_transform
),
// ---------- Repeat ----------
cmd!(
"object.repeat.radial",
"Radial",
["Object", "Repeat"],
None,
"{ids?, instances?: n (8), radius?: pt} radial repeat of the selection (or switch the selected repeat to radial) → {id}",
has_selection,
repeat_radial
),
cmd!(
"object.repeat.grid",
"Grid",
["Object", "Repeat"],
None,
"{ids?, hSpacing?: pt, vSpacing?: pt (¼ of the art size), rows?: n (3), cols?: n (3)} grid repeat of the selection → {id}",
has_selection,
repeat_grid
),
cmd!(
"object.repeat.mirror",
"Mirror",
["Object", "Repeat"],
None,
"{ids?, angle?: deg (90 = vertical axis), offset?: pt (10)} mirror repeat of the selection → {id}",
has_selection,
repeat_mirror
),
cmd!(
"object.repeat.release",
"Release",
["Object", "Repeat"],
None,
"{} release the selected repeats back to their source art → {ids}",
has_repeat,
repeat_release
),
cmd!(
"object.repeat.options",
"Options…",
["Object", "Repeat"],
None,
"{instances?, radius?, reverseOverlap?, startAngle?, endAngle?, center?: [x,y] | hSpacing?, vSpacing?, rows?, cols?, gridType?, flipRows?, flipCols? | angle?} set the options of the selected repeats",
has_repeat,
repeat_options
),
cmd!(
"object.repeat.expand",
"Expand Repeat",
[],
None,
"{} replace the selected repeats by groups of their instances → {ids}",
has_repeat,
repeat_expand
),
]
}
// ---------- predicates ----------
fn editing(s: &Session) -> Option<&PatternEdit> {
s.active()?.doc.pattern_edit.as_ref()
}
fn in_pattern_edit(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
editing(s).map(|_| ()).ok_or_else(|| "not in pattern editing mode".into())
}
fn can_edit_pattern(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if editing(s).is_some() {
return Err("already editing a pattern".into());
}
if s.active().is_some_and(|st| st.doc.patterns.is_empty()) { Err("the document has no patterns".into()) } else { Ok(()) }
}
fn has_selection_or_ids(s: &Session) -> std::result::Result<(), String> {
has_doc(s)
}
fn selected_repeats(s: &Session) -> Vec<NodeId> {
let Some(st) = s.active() else { return vec![] };
let mut out = vec![];
for id in &st.selection.objects {
for a in st.doc.ancestry(*id).unwrap_or_default().into_iter().rev() {
if st.doc.node(a).is_some_and(pattern::is_repeat) {
if !out.contains(&a) {
out.push(a);
}
break;
}
}
}
out
}
fn has_repeat(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
if selected_repeats(s).is_empty() { Err("select a repeat".into()) } else { Ok(()) }
}
// ---------- helpers ----------
fn ids_json(ids: &[NodeId]) -> Value {
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
}
fn roots(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
match ids_param(p, "ids") {
Some(ids) => {
let st = s.doc()?;
let mut sel = Selection::default();
sel.set(ids.iter().copied().filter(|id| st.doc.node(*id).is_some_and(|n| !n.is_layer())));
Ok(sel.in_paint_order(&st.doc))
}
None => selected_roots(s),
}
}
fn unique_pattern_name(d: &Document, base: &str) -> String {
if d.pattern(base).is_none() && d.swatch(base).is_none() {
return base.to_string();
}
(2..).map(|i| format!("{base} {i}")).find(|n| d.pattern(n).is_none() && d.swatch(n).is_none()).unwrap()
}
fn next_pattern_name(d: &Document) -> String {
(1..).map(|i| format!("New Pattern {i}")).find(|n| d.pattern(n).is_none() && d.swatch(n).is_none()).unwrap()
}
/// Fresh-id deep copies of `nodes`.
fn copies(d: &mut Document, nodes: &[Arc<Node>]) -> Vec<Node> {
nodes.iter().map(|n| d.reid(n)).collect()
}
/// Enter pattern editing mode for `name`: a top layer with copies of the tile art.
fn enter_edit(d: &mut Document, sel: &mut Selection, name: &str, original: Option<PatternDef>) -> Result<NodeId> {
let art = d.pattern(name).map(|p| p.art.clone()).ok_or_else(|| EngineError::Other(format!("no pattern `{name}`")))?;
let layer = d.add_layer(Some(PATTERN_EDIT_LAYER));
let nodes = copies(d, &art);
let mut ids = vec![];
for n in nodes {
ids.push(n.id);
d.insert(Some(layer), usize::MAX, n)?;
}
d.pattern_edit = Some(PatternEdit { pattern: name.to_string(), layer, original });
sel.set(ids);
Ok(layer)
}
/// Leave editing mode: drop the layer, return the edit record.
fn leave_edit(d: &mut Document, sel: &mut Selection) -> Result<(PatternEdit, Vec<Arc<Node>>)> {
let pe = d.pattern_edit.take().ok_or_else(|| EngineError::Other("not in pattern editing mode".into()))?;
let art = match d.remove(pe.layer) {
Ok(l) => l.children().cloned().unwrap_or_default(),
Err(_) => vec![],
};
sel.clear();
Ok((pe, art))
}
fn after_edit_mode(s: &mut Session, layer: Option<NodeId>) -> Result<()> {
let st = s.doc_mut()?;
st.isolation = layer;
st.active_layer = layer.or_else(|| st.doc.default_layer());
st.revision += 1;
Ok(())
}
fn tile_type_param(p: &Value, cmd: &str, current: TileType) -> Result<TileType> {
let off = p.get("brickOffset").and_then(Value::as_f64).map(|o| if o > 1.0 { o / 100.0 } else { o });
match str_param(p, "tileType") {
Some(t) => TileType::parse(t, off).ok_or_else(|| bad(cmd, format!("unknown tileType `{t}` ({})", TileType::IDS.join("|")))),
None => Ok(match (current, off) {
(TileType::BrickByRow { .. }, Some(o)) => TileType::BrickByRow { offset: o },
(TileType::BrickByColumn { .. }, Some(o)) => TileType::BrickByColumn { offset: o },
(c, _) => c,
}),
}
}
// ---------- Pattern commands ----------
fn pattern_make(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.pattern.make";
if editing(s).is_some() {
return Err(bad(C, "already in pattern editing mode"));
}
let roots = roots(s, p)?;
if roots.is_empty() {
return Err(bad(C, "select the art for the pattern tile"));
}
let tt = tile_type_param(p, C, TileType::Grid)?;
let (w, h) = (p.get("width").and_then(Value::as_f64), p.get("height").and_then(Value::as_f64));
if w.is_some_and(|v| !(v.is_finite() && v > 0.0)) || h.is_some_and(|v| !(v.is_finite() && v > 0.0)) {
return Err(bad(C, "width/height must be > 0"));
}
let edit = bool_or(p, "edit", true);
let wanted = str_param(p, "name").map(str::to_string);
let name = s.edit("Make Pattern", |d, sel| {
let name = match &wanted {
Some(n) => unique_pattern_name(d, n),
None => next_pattern_name(d),
};
let src: Vec<Arc<Node>> = roots.iter().filter_map(|id| d.node(*id).cloned()).map(Arc::new).collect();
let art: Vec<Arc<Node>> = copies(d, &src).into_iter().map(Arc::new).collect();
let mut def = PatternDef::new(&name, art);
def.tile_type = tt;
let c = def.tile.center();
def.tile = Rect::from_center_size(c, (w.unwrap_or(def.tile.width()).max(1e-3), h.unwrap_or(def.tile.height()).max(1e-3)));
d.patterns.push(def);
d.swatches.push(drawcraft_color::Swatch { name: name.clone(), paint: pattern_paint(&name), global: false, spot: false });
if edit {
enter_edit(d, sel, &name, None)?;
}
Ok(name)
})?;
if edit {
let layer = editing(s).map(|e| e.layer);
after_edit_mode(s, layer)?;
}
Ok(json!({ "name": name }))
}
fn pattern_edit(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.pattern.edit";
let name = match str_param(p, "name") {
Some(n) => n.to_string(),
None => {
// The selected object's fill (or stroke) pattern.
let st = s.doc()?;
st.selection
.objects
.iter()
.filter_map(|id| st.doc.node(*id))
.find_map(|n| {
n.appearance.items.iter().find_map(|it| match it {
drawcraft_doc::AppearanceItem::Fill(f) => pattern_name(&f.paint),
drawcraft_doc::AppearanceItem::Stroke(sk) => pattern_name(&sk.paint),
})
})
.ok_or_else(|| bad(C, "give a pattern name or select an object painted with a pattern"))?
}
};
let orig = s.doc()?.doc.pattern(&name).cloned().ok_or_else(|| bad(C, format!("no pattern `{name}`")))?;
let layer = s.edit("Edit Pattern", |d, sel| enter_edit(d, sel, &name, Some(orig)))?;
after_edit_mode(s, Some(layer))?;
Ok(json!({ "name": name }))
}
fn pattern_name(p: &Paint) -> Option<String> {
match p {
Paint::Pattern { pattern, .. } => Some(pattern.clone()),
_ => None,
}
}
fn pattern_done(s: &mut Session, _: &Value) -> Result<Value> {
let name = s.edit("Done Editing Pattern", |d, sel| {
let (pe, art) = leave_edit(d, sel)?;
if let Some(def) = d.pattern_mut(&pe.pattern) {
def.art = art;
if def.size_tile_to_art {
def.fit_tile_to_art();
}
}
Ok(pe.pattern)
})?;
after_edit_mode(s, None)?;
Ok(json!({ "name": name }))
}
fn pattern_cancel(s: &mut Session, _: &Value) -> Result<Value> {
s.edit("Cancel Pattern Editing", |d, sel| {
let (pe, _) = leave_edit(d, sel)?;
match pe.original {
Some(orig) => {
if let Some(def) = d.pattern_mut(&pe.pattern) {
*def = orig;
}
}
None => {
d.patterns.retain(|p| p.name != pe.pattern);
d.swatches.retain(|sw| !matches!(&sw.paint, Paint::Pattern { pattern, .. } if *pattern == pe.pattern));
}
}
Ok(())
})?;
after_edit_mode(s, None)?;
ok()
}
fn pattern_save_copy(s: &mut Session, p: &Value) -> Result<Value> {
let wanted = str_param(p, "name").map(str::to_string);
let name = s.edit("Save a Copy", |d, _| {
let pe = d.pattern_edit.clone().ok_or_else(|| EngineError::Other("not in pattern editing mode".into()))?;
let mut def = d.pattern(&pe.pattern).cloned().ok_or_else(|| EngineError::Other("pattern missing".into()))?;
let art: Vec<Arc<Node>> = d.node(pe.layer).and_then(|l| l.children().cloned()).unwrap_or_default();
let name = unique_pattern_name(d, wanted.as_deref().unwrap_or(&format!("{} copy", pe.pattern)));
def.name = name.clone();
def.art = copies(d, &art).into_iter().map(Arc::new).collect();
if def.size_tile_to_art {
def.fit_tile_to_art();
}
d.patterns.push(def);
d.swatches.push(drawcraft_color::Swatch { name: name.clone(), paint: pattern_paint(&name), global: false, spot: false });
Ok(name)
})?;
Ok(json!({ "name": name }))
}
fn pattern_options(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "pattern.options";
let name = match str_param(p, "name") {
Some(n) => n.to_string(),
None => editing(s).map(|e| e.pattern.clone()).ok_or_else(|| bad(C, "give `name` (or edit a pattern)"))?,
};
let cur = s.doc()?.doc.pattern(&name).cloned().ok_or_else(|| bad(C, format!("no pattern `{name}`")))?;
let tt = tile_type_param(p, C, cur.tile_type)?;
let num = |k: &str| p.get(k).and_then(Value::as_f64);
for k in ["width", "height"] {
if num(k).is_some_and(|v| !(v.is_finite() && v > 0.0)) {
return Err(bad(C, format!("{k} must be > 0")));
}
}
let new_name = str_param(p, "newName").map(str::to_string).filter(|n| !n.is_empty() && *n != name);
if let Some(n) = &new_name
&& s.doc()?.doc.pattern(n).is_some()
{
return Err(bad(C, format!("a pattern named `{n}` exists")));
}
let overlap = match p.get("overlap") {
Some(o) => Overlap {
right_in_front: str_param(o, "h").map_or(cur.overlap.right_in_front, |v| v == "right"),
bottom_in_front: str_param(o, "v").map_or(cur.overlap.bottom_in_front, |v| v == "bottom"),
},
None => cur.overlap,
};
let edit_layer = editing(s).filter(|e| e.pattern == name).map(|e| e.layer);
let out_name = new_name.clone().unwrap_or(name.clone());
s.edit("Pattern Options", |d, _| {
// While editing, the tile art is the edit layer's.
let live_art: Option<Vec<Arc<Node>>> = edit_layer.and_then(|l| d.node(l)).and_then(|l| l.children().cloned());
let def = d.pattern_mut(&name).ok_or_else(|| EngineError::Other("pattern missing".into()))?;
def.tile_type = tt;
def.overlap = overlap;
if let Some(v) = num("hSpacing") {
def.h_spacing = v;
}
if let Some(v) = num("vSpacing") {
def.v_spacing = v;
}
if let Some(v) = p.get("sizeTileToArt").and_then(Value::as_bool) {
def.size_tile_to_art = v;
}
if let Some(v) = num("copies") {
def.copies = (v as u32).clamp(1, 15) | 1;
}
if let Some(v) = num("dimCopies") {
def.dim_copies = v.clamp(0.0, 100.0) as f32;
}
if let Some(v) = p.get("showTileEdge").and_then(Value::as_bool) {
def.show_tile_edge = v;
}
if def.size_tile_to_art {
let saved = std::mem::take(&mut def.art);
def.art = live_art.clone().unwrap_or_else(|| saved.clone());
def.fit_tile_to_art();
def.art = saved;
} else if num("width").is_some() || num("height").is_some() {
let c = def.tile.center();
def.tile = Rect::from_center_size(c, (num("width").unwrap_or(def.tile.width()), num("height").unwrap_or(def.tile.height())));
}
if let Some(nn) = &new_name {
def.name = nn.clone();
rename_pattern_uses(d, &name, nn);
}
Ok(())
})?;
Ok(json!({ "name": out_name }))
}
/// Rename a pattern in swatches, paints and the edit record.
fn rename_pattern_uses(d: &mut Document, old: &str, new: &str) {
let fix = |p: &mut Paint| {
if let Paint::Pattern { pattern, .. } = p
&& pattern == old
{
*pattern = new.to_string();
}
};
for sw in d.swatches.iter_mut().chain(d.swatch_groups.iter_mut().flat_map(|g| g.swatches.iter_mut())) {
if sw.name == old {
sw.name = new.to_string();
}
fix(&mut sw.paint);
}
if let Some(pe) = &mut d.pattern_edit
&& pe.pattern == old
{
pe.pattern = new.to_string();
}
let ids = nodes_using(d, Some(old));
for id in ids {
if let Some(n) = d.node_mut(id) {
for it in n.appearance.items.iter_mut() {
match it {
drawcraft_doc::AppearanceItem::Fill(f) => fix(&mut f.paint),
drawcraft_doc::AppearanceItem::Stroke(s) => fix(&mut s.paint),
}
}
}
}
}
fn nodes_using(d: &Document, name: Option<&str>) -> Vec<NodeId> {
let mut ids = vec![];
d.walk(|n| {
if pattern::uses_pattern(n, name) {
ids.push(n.id);
}
});
ids
}
fn pattern_delete(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "pattern.delete";
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing name"))?.to_string();
if s.doc()?.doc.pattern(&name).is_none() {
return Err(bad(C, format!("no pattern `{name}`")));
}
if editing(s).is_some_and(|e| e.pattern == name) {
return Err(bad(C, "finish editing the pattern first"));
}
let n = s.edit("Delete Pattern", |d, _| {
d.patterns.retain(|p| p.name != name);
let is_it = |sw: &drawcraft_color::Swatch| matches!(&sw.paint, Paint::Pattern { pattern, .. } if *pattern == name);
d.swatches.retain(|sw| !is_it(sw));
for g in &mut d.swatch_groups {
g.swatches.retain(|sw| !is_it(sw));
}
let ids = nodes_using(d, Some(&name));
for id in &ids {
if let Some(n) = d.node_mut(*id) {
for it in n.appearance.items.iter_mut() {
let p = match it {
drawcraft_doc::AppearanceItem::Fill(f) => &mut f.paint,
drawcraft_doc::AppearanceItem::Stroke(s) => &mut s.paint,
};
if matches!(p, Paint::Pattern { pattern, .. } if *pattern == name) {
*p = Paint::None;
}
}
}
}
Ok(ids.len())
})?;
Ok(json!({ "unpainted": n }))
}
fn pattern_list(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc()?;
let pats: Vec<Value> = st
.doc
.patterns
.iter()
.map(|p| {
json!({
"name": p.name,
"tileType": p.tile_type.id(),
"brickOffset": match p.tile_type { TileType::BrickByRow { offset } | TileType::BrickByColumn { offset } => offset, _ => 0.5 },
"tile": [p.tile.x0, p.tile.y0, p.tile.width(), p.tile.height()],
"width": p.tile.width(),
"height": p.tile.height(),
"hSpacing": p.h_spacing,
"vSpacing": p.v_spacing,
"sizeTileToArt": p.size_tile_to_art,
"overlap": {"h": if p.overlap.right_in_front { "right" } else { "left" }, "v": if p.overlap.bottom_in_front { "bottom" } else { "top" }},
"copies": p.copies,
"dimCopies": p.dim_copies,
"showTileEdge": p.show_tile_edge,
"artCount": p.art.len(),
})
})
.collect();
let editing = st.doc.pattern_edit.as_ref().map(|e| json!({"name": e.pattern, "layer": e.layer.0}));
Ok(json!({ "patterns": pats, "editing": editing }))
}
fn pattern_transform(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "pattern.transform";
let m = matrix_param(p, "matrix").ok_or_else(|| bad(C, "missing `matrix` [a,b,c,d,e,f]"))?;
let ids = targets(s, p)?;
if ids.is_empty() {
return Err(bad(C, "nothing selected"));
}
let n = s.edit("Transform Patterns", |d, _| {
let mut all = vec![];
for id in &ids {
if let Some(n) = d.node(*id) {
n.walk(&mut |c| {
if pattern::uses_pattern(c, None) {
all.push(c.id);
}
});
}
}
for id in &all {
if let Some(n) = d.node_mut(*id) {
pattern::transform_pattern_paints(n, m);
}
}
Ok(all.len())
})?;
Ok(json!({ "count": n }))
}
// ---------- Repeat ----------
/// Make a repeat from the selection, or switch the selected repeat's kind.
fn make_repeat(s: &mut Session, p: &Value, label: &str, make: impl Fn(Vec<Arc<Node>>) -> RepeatSpec) -> Result<Value> {
let existing = selected_repeats(s);
if ids_param(p, "ids").is_none()
&& let [r] = existing.as_slice()
{
let r = *r;
s.edit(label, |d, _| {
let n = d.node_mut(r).ok_or(EngineError::NoNode(r))?;
if let NodeKind::Repeat(spec) = &mut n.kind {
spec.kind = make(spec.source.clone()).kind;
}
Ok(())
})?;
return Ok(json!({ "id": r.0 }));
}
let roots = roots(s, p)?;
if roots.is_empty() {
return Err(EngineError::Other("select the art to repeat".into()));
}
let id = s.edit(label, |d, sel| {
let top = *roots.last().unwrap();
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
let nodes: Vec<Arc<Node>> = roots.iter().filter_map(|id| d.node(*id).cloned()).map(Arc::new).collect();
let id = d.alloc_id();
d.insert(par, idx + 1, Node::new(id, NodeKind::Repeat(make(nodes))))?;
for r in &roots {
d.remove(*r)?;
}
sel.set([id]);
Ok(id)
})?;
Ok(json!({ "id": id.0 }))
}
fn repeat_radial(s: &mut Session, p: &Value) -> Result<Value> {
let n = f64_or(p, "instances", 8.0);
if !(1.0..=1000.0).contains(&n) {
return Err(bad("object.repeat.radial", "instances must be 1..1000"));
}
let radius = p.get("radius").and_then(Value::as_f64).filter(|r| r.is_finite() && *r >= 0.0);
make_repeat(s, p, "Radial Repeat", |src| RepeatSpec::radial(src, n as u32, radius))
}
fn repeat_grid(s: &mut Session, p: &Value) -> Result<Value> {
let (hs, vs) = (p.get("hSpacing").and_then(Value::as_f64), p.get("vSpacing").and_then(Value::as_f64));
let rows = p.get("rows").and_then(Value::as_u64).map(|v| v.clamp(1, 500) as u32);
let cols = p.get("cols").and_then(Value::as_u64).map(|v| v.clamp(1, 500) as u32);
make_repeat(s, p, "Grid Repeat", |src| {
let b = drawcraft_doc::live::nodes_bounds(&src).unwrap_or(Rect::new(0.0, 0.0, 10.0, 10.0));
let mut r = RepeatSpec::grid(src, hs.unwrap_or(b.width() / 4.0), vs.unwrap_or(b.height() / 4.0));
if let RepeatKind::Grid { rows: rr, cols: cc, .. } = &mut r.kind {
*rr = rows.unwrap_or(*rr);
*cc = cols.unwrap_or(*cc);
}
r
})
}
fn repeat_mirror(s: &mut Session, p: &Value) -> Result<Value> {
let angle = f64_or(p, "angle", 90.0);
let offset = f64_or(p, "offset", 10.0);
make_repeat(s, p, "Mirror Repeat", |src| RepeatSpec::mirror(src, angle, offset))
}
fn repeat_release(s: &mut Session, _: &Value) -> Result<Value> {
let reps = selected_repeats(s);
let ids = s.edit("Release Repeat", |d, sel| {
let mut out = vec![];
for r in &reps {
let Some(n) = d.node(*r).cloned() else { continue };
let (par, idx, _) = d.position(*r).ok_or(EngineError::NoNode(*r))?;
d.remove(*r)?;
for (k, c) in n.children().into_iter().flatten().enumerate() {
out.push(c.id);
d.insert(par, idx + k, (**c).clone())?;
}
}
sel.set(out.iter().copied());
Ok(out)
})?;
Ok(ids_json(&ids))
}
fn fix_ids(d: &mut Document, n: &mut Node) {
if n.id == NodeId(0) {
n.id = d.alloc_id();
}
if let Some(ch) = n.children_mut() {
for c in ch.iter_mut() {
fix_ids(d, Arc::make_mut(c));
}
}
}
fn repeat_expand(s: &mut Session, _: &Value) -> Result<Value> {
let reps = selected_repeats(s);
let ids = s.edit("Expand Repeat", |d, sel| {
let mut out = vec![];
for r in &reps {
let Some(n) = d.node(*r).cloned() else { continue };
let mut g = drawcraft_doc::live::expanded_group(&n, None);
// The group keeps the repeat's id; every generated instance node gets a fresh one.
if let Some(ch) = g.children_mut() {
for c in ch.iter_mut() {
fix_ids(d, Arc::make_mut(c));
}
}
let (par, idx, _) = d.position(*r).ok_or(EngineError::NoNode(*r))?;
d.remove(*r)?;
d.insert(par, idx, g)?;
out.push(*r);
}
sel.set(out.iter().copied());
Ok(out)
})?;
Ok(ids_json(&ids))
}
fn repeat_options(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.repeat.options";
let reps = selected_repeats(s);
let num = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite());
let flag = |k: &str| p.get(k).and_then(Value::as_bool);
if num("instances").is_some_and(|n| !(1.0..=1000.0).contains(&n)) {
return Err(bad(C, "instances must be 1..1000"));
}
let grid_type = match str_param(p, "gridType") {
Some(t) => Some(TileType::parse(t, num("brickOffset")).ok_or_else(|| bad(C, format!("unknown gridType `{t}`")))?),
None => None,
};
let center = point_param(p, "center");
s.edit("Repeat Options", |d, _| {
for r in &reps {
let Some(NodeKind::Repeat(spec)) = d.node_mut(*r).map(|n| &mut n.kind) else { continue };
match &mut spec.kind {
RepeatKind::Radial { instances, radius, center: c, reverse_overlap, start_angle, end_angle } => {
if let Some(v) = num("instances") {
*instances = v as u32;
}
if let Some(v) = num("radius") {
*radius = v.max(0.0);
}
if let Some(v) = flag("reverseOverlap") {
*reverse_overlap = v;
}
if let Some(v) = num("startAngle") {
*start_angle = v;
}
if let Some(v) = num("endAngle") {
*end_angle = v;
}
if let Some(v) = center {
*c = v;
}
}
RepeatKind::Grid { h_spacing, v_spacing, rows, cols, grid_type: gt, flip_rows, flip_cols } => {
if let Some(v) = num("hSpacing") {
*h_spacing = v;
}
if let Some(v) = num("vSpacing") {
*v_spacing = v;
}
if let Some(v) = num("rows") {
*rows = (v as u32).clamp(1, 500);
}
if let Some(v) = num("cols") {
*cols = (v as u32).clamp(1, 500);
}
if let Some(t) = grid_type {
*gt = t;
}
if let Some(v) = flag("flipRows") {
*flip_rows = v;
}
if let Some(v) = flag("flipCols") {
*flip_cols = v;
}
}
RepeatKind::Mirror { angle, center: c } => {
if let Some(v) = num("angle") {
*angle = v;
}
if let Some(v) = center {
*c = v;
}
}
}
}
Ok(())
})?;
ok()
}
+39 -7
View File
@@ -13,9 +13,16 @@ use crate::Prefs;
pub enum PrefKind {
Bool,
/// A number in `min..=max` shown with `unit` ("pt", "px", "°", "%", "min", "").
Num { min: f64, max: f64, unit: &'static str },
Num {
min: f64,
max: f64,
unit: &'static str,
},
/// A whole number in `min..=max`.
Int { min: i64, max: i64 },
Int {
min: i64,
max: i64,
},
/// One of (value, label).
Choice(&'static [(&'static str, &'static str)]),
/// `#rrggbb`.
@@ -119,7 +126,13 @@ pub const PREF_SPECS: &[PrefSpec] = &[
"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!(
"highlightAnchorsOnHover",
"Selection & Anchor Display",
"Anchor Points, Handle, and Bounding Box Display",
"Highlight anchors on mouse over",
bool
),
p!(
"showHandlesMultipleAnchors",
"Selection & Anchor Display",
@@ -127,7 +140,13 @@ pub const PREF_SPECS: &[PrefSpec] = &[
"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!(
"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
@@ -251,8 +270,20 @@ pub const PREF_SPECS: &[PrefSpec] = &[
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")])),
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)),
@@ -269,7 +300,8 @@ pub fn spec(key: &str) -> Option<&'static PrefSpec> {
/// "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()));
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)),