vectorcraft/crates/engine/src/cmd/distortcmds.rs

634 lines
26 KiB
Rust

//! 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::{Dabber, LiquifyParams, PathStroke, Sample};
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<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 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,pressure?]…] (the brush stroke; pen pressure 0..1, default 1), diameter?|width?, height? (pt, default 100), angle?, intensity? (0..1 or %), usePressure? (each point's pressure is the intensity there), detail? (1..10), simplify? (0..100) and simplifyOn? (default true; warp, twirl, pucker, bloat), rate? (twirl °), complexity? (0..15), horizontal?, vertical? (wrinkle, 0..1 or %), affectAnchors?, affectIn?, affectOut? (scallop, crystallize, wrinkle; default true), 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), angles?: [deg|null…] (same length: the turn the art takes around each pin, as Alt-dragging around a pin does; null leaves it free), 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<Option<usize>> {
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<usize> = (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<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 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<Value> {
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<Value> {
const C: &str = "stroke.widthPoint.remove";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let mut indices: Vec<usize> = 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<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()
}
/// A stroke's `[[x, y, pressure?]…]` samples (pressure 0..1, default 1).
fn samples_of(p: &Value, key: &str) -> Option<Vec<Sample>> {
p.get(key)?
.as_array()?
.iter()
.map(|q| Some((Point::new(q.get(0)?.as_f64()?, q.get(1)?.as_f64()?), q.get(2).map_or(Some(1.0), Value::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 = samples_of(p, "points").filter(|v| !v.is_empty()).ok_or_else(|| bad(C, "points must be a non-empty [[x,y,pressure?]…] list"))?;
if pts.iter().any(|(q, f)| !q.x.is_finite() || !q.y.is_finite() || !f.is_finite()) {
return Err(bad(C, "points must be finite"));
}
let mut stroke = Dabber::new(&prm);
for s in pts {
stroke.push(s);
}
let tail = stroke.tail();
let dab_pts: Vec<Point> = stroke.dabs.iter().chain(tail.as_ref()).map(|d| d.c).collect();
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.
// No dabs sweep nothing.
let sweep = dab_pts.iter().map(|c| prm.brush_bounds(*c)).reduce(|a, b| a.union(b)).unwrap_or(vectorcraft_geom::Rect::ZERO);
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 {
let mut ps = PathStroke::new(path.clone(), id.0);
ps.advance(&stroke.dabs, &prm);
if let Some(done) = ps.finish(&stroke.dabs, tail, &prm) {
*path = done;
*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 angles: Vec<Option<f64>> = match p.get("angles") {
None | Some(Value::Null) => vec![None; pins.len()],
Some(Value::Array(a)) if a.len() == pins.len() => a.iter().map(|v| v.as_f64().filter(|d| d.is_finite()).map(f64::to_radians)).collect(),
Some(_) => return Err(bad(C, "angles must be a list as long as pins (degrees or null)")),
};
let pins: Vec<arap::Pin> = pins.iter().zip(&moved).zip(angles).map(|((a, b), angle)| arap::Pin { angle, ..arap::Pin::new(*a, *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})))
}