//! Commands behind the distortion tools: width points, Liquify, Puppet Warp and the Perspective //! Grid. The geometry kernels live in `vectorcraft_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 serde_json::{Value, json}; use vectorcraft_doc::{Document, NodeId, NodeKind, WidthProfile}; use vectorcraft_geom::Point; use vectorcraft_tools::distort::liquify::{LiquifyParams, apply_stroke, dabs}; use vectorcraft_tools::distort::perspective::{PerspectiveGrid, Plane}; use vectorcraft_tools::distort::{arap, collect_points, mesh_for, warp_node_with}; use super::edit::selected_roots; use super::*; use crate::EngineError; pub fn specs() -> Vec { 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 edit or move, adjustAdjoining?: bool (with index: the nearest points either side change their widths in proportion)} → {index}. Without index a point already at t is replaced; a point moved onto another one's t joins it as a discontinuous point (on the side it came from), so the width steps there. Creates a uniform profile first if needed", has_doc, width_point_set ), cmd!( "stroke.widthPoint.remove", "Delete Width Point", [], None, "{id, index | indices: [..]} remove width points (removing the last one leaves a uniform stroke)", has_doc, width_point_remove ), cmd!( "stroke.widthPoint.copy", "Copy Width Point", [], None, "{id, index: the width point to copy, t: 0..1 where the copy goes} → {index} (Alt-drag with the Width tool). Landing on another point makes a discontinuous point, as a stroke.widthPoint.set move does", has_doc, width_point_copy ), 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)] } } /// Width points closer than this (in t) are at the same place: a discontinuous point. const SAME_T: f64 = 1e-6; /// The weighted stroke of `id` that width point commands edit. fn weighted_stroke(d: &mut Document, id: NodeId) -> Result<&mut vectorcraft_doc::StrokeLayer> { 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())); } Ok(st) } /// The `index` param, checked against the points of `prof`. fn point_index(p: &Value, prof: &WidthProfile, cmd: &str) -> Result> { match p.get("index").and_then(Value::as_u64).map(|v| v as usize) { Some(i) if i >= prof.points.len() => Err(bad(cmd, "no such width point")), i => Ok(i), } } /// Insert width point `pt` in order. Landing on another point's position makes a discontinuous /// point: `pt` snaps to it and takes the side it came from (`from_below`: before it), replacing /// that side of a discontinuous point already there. → where it landed. fn place(points: &mut Vec<(f64, f64, f64)>, mut pt: (f64, f64, f64), from_below: bool) -> usize { let at: Vec = (0..points.len()).filter(|i| (points[*i].0 - pt.0).abs() <= SAME_T).collect(); let (Some(&first), Some(&last)) = (at.first(), at.last()) else { let pos = points.iter().position(|q| q.0 > pt.0).unwrap_or(points.len()); points.insert(pos, pt); return pos; }; pt.0 = points[first].0; let pair = at.len() > 1; let pos = match (from_below, pair) { (true, _) => first, (false, true) => last, (false, false) => last + 1, }; if pair { points.remove(pos); } points.insert(pos, pt); pos } /// Adjust Adjoining Width Points: point `i` changes from `old` to `new` widths, and the nearest /// points at other positions either side change in proportion (by the same amount from zero). fn adjust_adjoining(points: &mut [(f64, f64, f64)], i: usize, old: (f64, f64, f64), new: (f64, f64, f64)) { let t = points[i].0; let scale = |v: f64, o: f64, n: f64| if o > 1e-9 { v * n / o } else { v + n - o }.max(0.0); let before = (0..i).rev().find(|j| (points[*j].0 - t).abs() > SAME_T); let after = (i + 1..points.len()).find(|j| (points[*j].0 - t).abs() > SAME_T); for j in before.into_iter().chain(after) { let q = &mut points[j]; (q.1, q.2) = (scale(q.1, old.1, new.1), scale(q.2, old.2, new.2)); } } fn width_point_set(s: &mut Session, p: &Value) -> Result { 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 adjust = bool_or(p, "adjustAdjoining", false); let pos = s.edit("Width Point", |d, _| { let st = weighted_stroke(d, id)?; let half = st.width / 2.0; let mut prof = st.profile.take().unwrap_or_else(uniform); let pt = (t, left / half, right / half); let pos = match point_index(p, &prof, C)? { Some(i) => { let old = prof.points[i]; if adjust { adjust_adjoining(&mut prof.points, i, old, pt); } if (old.0 - t).abs() <= SAME_T { // Widths only: it keeps its place (and its side of a discontinuous point). prof.points[i] = (old.0, pt.1, pt.2); i } else { prof.points.remove(i); place(&mut prof.points, pt, old.0 < t) } } None => { prof.points.retain(|q| (q.0 - t).abs() > SAME_T); place(&mut prof.points, pt, true) } }; st.profile = Some(prof); Ok(pos) })?; Ok(json!({ "index": pos })) } fn width_point_copy(s: &mut Session, p: &Value) -> Result { const C: &str = "stroke.widthPoint.copy"; 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 pos = s.edit("Copy Width Point", |d, _| { let st = weighted_stroke(d, id)?; let mut prof = st.profile.take().unwrap_or_else(uniform); let i = point_index(p, &prof, C)?.ok_or_else(|| bad(C, "missing index"))?; let (from, l, r) = prof.points[i]; let pos = place(&mut prof.points, (t, l, r), from < t); st.profile = Some(prof); Ok(pos) })?; Ok(json!({ "index": pos })) } fn width_point_remove(s: &mut Session, p: &Value) -> Result { const C: &str = "stroke.widthPoint.remove"; let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; let mut indices: Vec = match (p.get("indices").and_then(Value::as_array), p.get("index").and_then(Value::as_u64)) { (Some(a), _) => a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect(), (None, Some(i)) => vec![i as usize], (None, None) => return Err(bad(C, "missing index")), }; indices.sort_unstable(); indices.dedup(); 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 fits = |pr: &&mut WidthProfile| indices.last().is_some_and(|i| *i < pr.points.len()); let prof = st.profile.as_mut().filter(fits).ok_or_else(|| bad(C, "no such width point"))?; for i in indices.iter().rev() { prof.points.remove(*i); } if prof.points.is_empty() { st.profile = None; } Ok(()) })?; ok() } fn width_profile_set(s: &mut Session, p: &Value) -> Result { 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 = 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> { 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 { 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 { 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 { 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 = 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::>() })) } // ---------- 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 { 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 { 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 { 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 { 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 { 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 { 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> { 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) -> 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 { 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::>(), "plane": plane.id() })) } fn release(s: &mut Session, p: &Value) -> Result { 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 { 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 { 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, ¶ms)?; 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}))) }