mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 00:12:24 +00:00
- Pins show as soon as the tool has art selected: automatic ones at the centre and the middle of each limb's end (at least three), until pins are placed. They stay across tool switches while the selection is unchanged and a new selection starts afresh. - The pins live in the document (Document.puppet, never saved) with the rest shape they started from: every warp starts from it, so warps don't stack and dragging a pin back restores the original; Undo/Redo take the pins back with the art. Adding and deleting pins are undo steps too. - object.puppetWarp gains rest: true (the tool's pins); the new query object.puppetWarp.pins reads them. A click off the mesh adds nothing and says so (not-allowed cursor, canvas label); the command refuses such pins. - The mesh grid covers the expanded art, so tiny art with a wide Expand no longer allocates millions of cells. - Tests on groups, compound paths and open strokes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
812 lines
35 KiB
Rust
812 lines
35 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, PuppetPin, WidthProfile};
|
||
use vectorcraft_geom::{Affine, Homography, Point};
|
||
use vectorcraft_tools::distort::liquify::{Dabber, LiquifyParams, PathStroke, Sample};
|
||
use vectorcraft_tools::distort::perspective::{self as persp, PerspectiveGrid, Plane};
|
||
use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, warp_from_rest, warp_node_with};
|
||
|
||
use super::edit::{duplicate_in, 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, rest?: bool} as-rigid-as-possible mesh warp of anchors and handles. rest: true = the Puppet Warp tool's pins: `pins` are points on the shape the pins started from (object.puppetWarp.pins), each must be on its mesh, the warp replaces the previous one instead of adding to it, and the pins are kept with the document for Undo (an empty list removes them all, leaving the art as it is)",
|
||
has_doc,
|
||
puppet_warp
|
||
),
|
||
cmd!(
|
||
"object.puppetWarp.pins",
|
||
"Puppet Warp Pins",
|
||
[],
|
||
None,
|
||
"{id?|ids? (default: selection), expand?: pt} → {ids, pins, moved, angles, expand, rest: true, auto} the Puppet Warp pins on the art: pins = where each sits on the shape they started from, moved = where it is now, angles = the turn it holds (deg, null = free); auto: none placed yet (the tool's automatic pins: the centre and the end of each limb). Change moved/angles (or add/remove pins) and pass it back to object.puppetWarp",
|
||
has_doc,
|
||
puppet_pins
|
||
),
|
||
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 (its normal view) | name: a preset (see perspective.presets.list)} reset the grid to the preset fitted to the first artboard (and show it; attached objects stay attached)",
|
||
has_doc,
|
||
super::perspgrid::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?, copy?: bool (move copies, as Alt-dragging does), perpendicular?: bool (move along the plane's normal instead, as pressing 5 while dragging does)} slide objects within their plane (unattached objects attach to `plane`/the active plane first); Transform Again repeats it",
|
||
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
|
||
),
|
||
cmd!(
|
||
"perspective.transform",
|
||
"Transform in Perspective",
|
||
[],
|
||
None,
|
||
"{ids?, matrix: [a,b,c,d,e,f] (affine map in plane coordinates, points: u along the plane, v up or away), depth?: pt to move along the plane's normal (default 0), copy?: bool, plane?} transform objects within their own planes (Perspective Selection tool handles; unattached objects attach to `plane`/the active plane first); Transform Again repeats it → {ids}",
|
||
has_doc,
|
||
persp_transform
|
||
),
|
||
cmd!(
|
||
"perspective.nudge",
|
||
"Nudge in Perspective",
|
||
[],
|
||
None,
|
||
"{dx, dy: arrow direction (-1, 0 or 1), big?: bool (×10), copy?: bool} move the selection in perspective by the keyboard increment, as the arrow keys do with the Perspective Selection tool → {ids}",
|
||
has_selection,
|
||
persp_nudge
|
||
),
|
||
]
|
||
}
|
||
|
||
// ---------- 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 ----------
|
||
|
||
/// The objects a Puppet Warp command works on: `id`/`ids`, else `default`.
|
||
fn puppet_ids(p: &Value, default: impl FnOnce() -> Result<Vec<NodeId>>) -> Result<Vec<NodeId>> {
|
||
let ids = match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) {
|
||
Some(v) => v,
|
||
None => default()?,
|
||
};
|
||
if ids.is_empty() {
|
||
return Err(EngineError::Other("select the artwork to warp".into()));
|
||
}
|
||
Ok(ids)
|
||
}
|
||
|
||
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]…]"))?;
|
||
let rest = bool_or(p, "rest", false);
|
||
if (pins.is_empty() && !rest) || 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 = if rest { puppet_ids(p, || Ok(s.doc()?.selection.objects.clone()))? } else { puppet_ids(p, || selected_roots(s))? };
|
||
let expand = f64_or(p, "expand", 3.0).clamp(0.0, 1000.0);
|
||
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 ids_json = json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() });
|
||
if rest {
|
||
let pins: Vec<PuppetPin> = pins.iter().zip(&moved).zip(angles).map(|((r, a), angle)| PuppetPin { rest: *r, at: *a, angle }).collect();
|
||
s.edit("Puppet Warp", |d, _| warp_from_rest(d, &ids, pins, expand).map_err(|e| bad(C, e)))?;
|
||
return Ok(ids_json);
|
||
}
|
||
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).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(ids_json)
|
||
}
|
||
|
||
/// Forget the Puppet Warp pins: not an undo step, and a saved document stays saved (pins are
|
||
/// never saved).
|
||
pub(crate) fn drop_puppet_pins(st: &mut crate::DocState) {
|
||
let clean = !st.is_dirty();
|
||
Arc::make_mut(&mut st.doc).puppet = None;
|
||
if clean {
|
||
st.mark_saved();
|
||
}
|
||
}
|
||
|
||
fn puppet_pins(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let ids = puppet_ids(p, || Ok(s.doc()?.selection.objects.clone()))?;
|
||
let expand = f64_or(p, "expand", 3.0).clamp(0.0, 1000.0);
|
||
let set = PinSet::of(&s.doc()?.doc, &ids, expand).ok_or_else(|| EngineError::Other("nothing to warp".into()))?;
|
||
let mut v = set.params(&set.pins, expand);
|
||
v["auto"] = json!(set.auto);
|
||
Ok(v)
|
||
}
|
||
|
||
// ---------- 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_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)?;
|
||
persp::set_attachment(d.node_mut(id).ok_or(EngineError::NoNode(id))?, plane, 0.0);
|
||
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());
|
||
if let Some(n) = d.node_mut(*id) {
|
||
n.perspective = None;
|
||
}
|
||
}
|
||
store_grid(d, &g);
|
||
Ok(())
|
||
})?;
|
||
ok()
|
||
}
|
||
|
||
/// One object's share of a perspective edit: the page map for an object on `plane` at `depth`, and
|
||
/// the depth it ends at.
|
||
type PerspStep<'a> = &'a dyn Fn(&PerspectiveGrid, Plane, f64) -> Result<(Homography, f64)>;
|
||
|
||
/// Where an object is in perspective: its plane and depth.
|
||
type Attached = (Plane, f64);
|
||
|
||
fn beyond_horizon() -> EngineError {
|
||
EngineError::Other("the pointer is beyond the plane's horizon".into())
|
||
}
|
||
|
||
/// Edit objects in perspective (one undo step `label`): with `copy` their duplicates (which stay
|
||
/// attached), each mapped by `step` for its own plane and depth; objects not in perspective attach
|
||
/// to `fallback` first. → the edited objects and where the first one was (plane, depth).
|
||
fn persp_edit(
|
||
s: &mut Session,
|
||
label: &str,
|
||
ids: &[NodeId],
|
||
fallback: Option<Plane>,
|
||
copy: bool,
|
||
step: PerspStep,
|
||
) -> Result<(Vec<NodeId>, Option<Attached>)> {
|
||
if ids.is_empty() {
|
||
return Err(EngineError::Other("select the objects to transform in perspective".into()));
|
||
}
|
||
let mut g = grid_of(&s.doc()?.doc);
|
||
s.edit(label, |d, sel| {
|
||
g.adopt_stored_attachments(d);
|
||
let targets = if copy { duplicate_in(d, sel, ids, Affine::IDENTITY)? } else { ids.to_vec() };
|
||
let mut first = None;
|
||
for id in &targets {
|
||
let (plane, depth) = match g.attachment_of(d, *id) {
|
||
Some(a) => a,
|
||
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, 0.0)
|
||
}
|
||
};
|
||
first.get_or_insert((plane, depth));
|
||
let (h, depth) = step(&g, plane, depth)?;
|
||
warp_checked(d, *id, &|q| h.apply(q))?;
|
||
persp::set_attachment(d.node_mut(*id).ok_or(EngineError::NoNode(*id))?, plane, depth);
|
||
}
|
||
store_grid(d, &g);
|
||
Ok((targets, first))
|
||
})
|
||
}
|
||
|
||
/// Remember `again` (`perspective.transform` params) for Object › Transform › Transform Again, once
|
||
/// the drag that made it is committed.
|
||
fn record_again(s: &mut Session, again: Value) -> Result<()> {
|
||
let st = s.doc_mut()?;
|
||
match &mut st.interaction {
|
||
Some(it) => it.perspective_again = Some(again),
|
||
None => st.last_perspective = Some(again),
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn ids_json(ids: &[NodeId]) -> Value {
|
||
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
|
||
}
|
||
|
||
/// A plane-space affine map as `perspective.transform`'s `matrix` param.
|
||
fn matrix_json(m: Affine) -> Value {
|
||
json!(m.as_coeffs())
|
||
}
|
||
|
||
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"))?;
|
||
if ![from.x, from.y, to.x, to.y].iter().all(|v| v.is_finite()) {
|
||
return Err(bad(C, "from and to must be finite"));
|
||
}
|
||
let fallback = plane_param(&grid_of(&s.doc()?.doc), p, C).ok();
|
||
let (perp, copy) = (bool_or(p, "perpendicular", false), bool_or(p, "copy", false));
|
||
// In-plane: the plane-space offset under the pointer; perpendicular: the depth it reaches.
|
||
let step = |g: &PerspectiveGrid, pl: Plane, depth: f64| -> Result<(Homography, f64)> {
|
||
if perp {
|
||
let to_depth = g.depth_at(pl, depth, from, to).ok_or_else(beyond_horizon)?;
|
||
Ok((g.transform_map(pl, depth, Affine::IDENTITY, to_depth - depth).ok_or_else(beyond_horizon)?, to_depth))
|
||
} else {
|
||
let dv = g.plane_delta(pl, depth, from, to).ok_or_else(beyond_horizon)?;
|
||
Ok((g.transform_map(pl, depth, Affine::translate(dv), 0.0).ok_or_else(beyond_horizon)?, depth))
|
||
}
|
||
};
|
||
let label = if copy { "Copy in Perspective" } else { "Move in Perspective" };
|
||
let (targets, first) = persp_edit(s, label, &ids, fallback, copy, &step)?;
|
||
// Transform Again repeats the first object's move.
|
||
if let Some((pl, depth)) = first {
|
||
let g = grid_of(&s.doc()?.doc);
|
||
let again = match perp {
|
||
true => g.depth_at(pl, depth, from, to).map(|d| json!({"matrix": matrix_json(Affine::IDENTITY), "depth": d - depth, "copy": copy})),
|
||
false => g.plane_delta(pl, depth, from, to).map(|dv| json!({"matrix": matrix_json(Affine::translate(dv)), "copy": copy})),
|
||
};
|
||
if let Some(a) = again {
|
||
record_again(s, a)?;
|
||
}
|
||
}
|
||
Ok(ids_json(&targets))
|
||
}
|
||
|
||
fn persp_transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const C: &str = "perspective.transform";
|
||
let m = matrix_param(p, "matrix").ok_or_else(|| bad(C, "missing matrix [a,b,c,d,e,f]"))?;
|
||
let dz = f64_or(p, "depth", 0.0);
|
||
if !m.as_coeffs().iter().all(|v| v.is_finite()) || m.determinant().abs() < 1e-12 || !dz.is_finite() {
|
||
return Err(bad(C, "matrix must be finite and invertible, depth finite"));
|
||
}
|
||
let ids = roots(s, p)?;
|
||
let fallback = plane_param(&grid_of(&s.doc()?.doc), p, C).ok();
|
||
let copy = bool_or(p, "copy", false);
|
||
let step = |g: &PerspectiveGrid, pl: Plane, depth: f64| -> Result<(Homography, f64)> {
|
||
let to = depth + dz;
|
||
if to.abs() > persp::MAX_DEPTH {
|
||
return Err(EngineError::Other("the objects would leave the grid".into()));
|
||
}
|
||
Ok((g.transform_map(pl, depth, m, dz).ok_or_else(beyond_horizon)?, to))
|
||
};
|
||
let (targets, _) = persp_edit(s, if copy { "Copy in Perspective" } else { "Transform in Perspective" }, &ids, fallback, copy, &step)?;
|
||
record_again(s, json!({"matrix": matrix_json(m), "depth": dz, "copy": copy}))?;
|
||
Ok(ids_json(&targets))
|
||
}
|
||
|
||
/// Object › Transform › Transform Again after a perspective move or scale: the same plane-space
|
||
/// transform on the selection.
|
||
pub(crate) fn transform_again(s: &mut Session, again: &Value) -> Result<Value> {
|
||
let mut p = again.clone();
|
||
if let Some(o) = p.as_object_mut() {
|
||
o.remove("ids");
|
||
}
|
||
persp_transform(s, &p)
|
||
}
|
||
|
||
fn persp_nudge(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const C: &str = "perspective.nudge";
|
||
let (dx, dy) = (f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
|
||
if !(dx.is_finite() && dy.is_finite()) || (dx == 0.0 && dy == 0.0) {
|
||
return Err(bad(C, "dx and dy give the arrow's direction"));
|
||
}
|
||
let k = s.prefs.keyboard_increment * if bool_or(p, "big", false) { 10.0 } else { 1.0 };
|
||
let ids = selected_roots(s)?;
|
||
// The step is measured on the page at the centre of the selection's perspective box.
|
||
let st = s.doc()?;
|
||
let g = grid_of(&st.doc);
|
||
let (plane, depth, rect) = g.plane_bounds(&st.doc, &ids).ok_or_else(|| EngineError::Other("the selection isn't in perspective".into()))?;
|
||
let from = g.homography_at(plane, depth).and_then(|h| h.apply(rect.center())).ok_or_else(beyond_horizon)?;
|
||
let to = from + vectorcraft_geom::Vec2::new(dx.clamp(-1.0, 1.0), dy.clamp(-1.0, 1.0)) * k;
|
||
persp_move(
|
||
s,
|
||
&json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "from": [from.x, from.y], "to": [to.x, to.y], "copy": bool_or(p, "copy", false)}),
|
||
)
|
||
}
|
||
|
||
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, ¶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})))
|
||
}
|