mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 10:17:02 -06:00
Selecting other art drops Puppet Warp pins, ending Edit Text clears the flat view, and a font scan lays type out again. None of these makes an undo step or counts as an edit to save, but each one replaced the document. A background save that finished afterwards compared the document with the one it wrote by pointer, so the document stayed marked modified. Data Recovery also wrote a new copy of a document whose pins had been dropped. DocState now records the documents that these changes replaced, and DocState::same_content counts them as having the same content. The modified flag, the end of a background save and Data Recovery use it. After Edit Text ends, an undo state that was the saved one stays the saved one.
1360 lines
60 KiB
Rust
1360 lines
60 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, Node, NodeId, NodeKind, PuppetPin, WidthProfile};
|
||
use vectorcraft_geom::{Affine, Homography, PathData, Point, Rect};
|
||
use vectorcraft_tools::distort::liquify::{Dabber, LiquifyParams, PathStroke, Sample, reach_bounds};
|
||
use vectorcraft_tools::distort::perspective::{self as persp, PerspectiveGrid, Plane};
|
||
use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, project_node, stroke_owner, warp_from_rest, warp_node_with};
|
||
|
||
use super::edit::{duplicate_in, outermost, 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 (the nearest points either side change their widths in proportion, using the sampled widths for a new point)} → {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, simplifyOn?: bool (default true; both for warp, twirl, pucker, bloat), rate? (twirl °), complexity? (0..15), horizontal?, vertical? (wrinkle, 0..1 or %), affectAnchors?, affectIn?, affectOut? (scallop, crystallize, wrinkle; default true), ids?|id? (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, auto?: bool (as object.puppetWarp.pins reports; ignored)} 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?, leftOffset?, rightOffset?, groundOffset?: pt (planes moved along their normals), reproject?: bool, …the other fields of the grid as its reply lists them} merge into the document's grid. Objects in perspective stay where they are, as in the reference app; with reproject they move with the grid (each keeps its place on its plane)",
|
||
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?|id?, 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?|id?} detach from the grid (geometry unchanged)", has_doc, release),
|
||
cmd!(
|
||
"perspective.move",
|
||
"Move in Perspective",
|
||
[],
|
||
None,
|
||
"{ids?|id?, 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), snap?: bool (default: View › Perspective Grid › Snap to Grid)} slide objects within their plane (unattached objects attach to `plane`/the active plane first); snapping lands the nearer edge of the objects' joint bounds on a gridline within a quarter cell; Transform Again repeats it",
|
||
has_doc,
|
||
persp_move
|
||
),
|
||
cmd!(
|
||
"perspective.draw",
|
||
"Draw in Perspective",
|
||
[],
|
||
None,
|
||
"{command: shape.* id, params, plane?, snap?: bool (default: Snap to Grid), at?: [x, y] (a point of the plane the shape lies on, as the tool gives it)} run a shape command and attach the result to the plane (snapping its corners to gridlines within a quarter cell)",
|
||
has_doc,
|
||
persp_draw
|
||
),
|
||
cmd!(
|
||
"perspective.transform",
|
||
"Transform in Perspective",
|
||
[],
|
||
None,
|
||
"{ids?|id?, 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
|
||
),
|
||
cmd!(
|
||
"perspective.plane.move",
|
||
"Move Plane",
|
||
[],
|
||
None,
|
||
"{plane?: left|right|ground (default: the active plane), offset?: pt (where along its normal; 0 is its place in the grid's definition) | by?: pt, objects?: none|move|copy (default none: the objects on the plane stay; move: they move with it, as Shift-dragging a plane widget does; copy: copies of them do, as Alt-dragging does)} → {plane, offset, ids}",
|
||
has_doc,
|
||
plane_move
|
||
),
|
||
cmd!(
|
||
"perspective.plane.matchObject",
|
||
"Move Plane to Match Object",
|
||
[],
|
||
None,
|
||
"{id? (default: the first selected object)} move the plane the object is attached to onto the object and make it the active plane → {plane, offset}",
|
||
has_doc,
|
||
plane_match
|
||
),
|
||
cmd!(
|
||
"perspective.editText",
|
||
"Edit Text",
|
||
[],
|
||
None,
|
||
"{id? (default: the selected type)} Object › Perspective › Edit Text: show type in perspective flat where it is drawn, in isolation mode, to edit it (text.* commands, the Type tool); exiting isolation (object.exitIsolation, Esc) projects it again → {id}",
|
||
has_doc,
|
||
edit_text
|
||
),
|
||
]
|
||
}
|
||
|
||
// ---------- 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 stroke of `id` that width point commands edit (a compound path's for its members).
|
||
fn stroke_mut(d: &mut Document, id: NodeId) -> Result<&mut vectorcraft_doc::StrokeLayer> {
|
||
let id = stroke_owner(d, id);
|
||
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
|
||
n.appearance.stroke_mut().ok_or_else(|| EngineError::Other("the object has no stroke".into()))
|
||
}
|
||
|
||
/// [`stroke_mut`], which must have a weight.
|
||
fn weighted_stroke(d: &mut Document, id: NodeId) -> Result<&mut vectorcraft_doc::StrokeLayer> {
|
||
let st = stroke_mut(d, id)?;
|
||
if st.width <= 0.0 {
|
||
return Err(EngineError::Other("the stroke has no weight".into()));
|
||
}
|
||
Ok(st)
|
||
}
|
||
|
||
/// The `index` param (none when absent or null), checked against the points of `prof`: a malformed
|
||
/// one is refused like one out of range, never wrapped round or taken as missing.
|
||
fn point_index(p: &Value, prof: &WidthProfile, cmd: &str) -> Result<Option<usize>> {
|
||
let Some(v) = p.get("index").filter(|v| !v.is_null()) else { return Ok(None) };
|
||
match point_indices(&json!([v])).and_then(|i| i.first().copied()) {
|
||
Some(i) if i < prof.points.len() => Ok(Some(i)),
|
||
_ => Err(bad(cmd, "no such width point")),
|
||
}
|
||
}
|
||
|
||
/// 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| {
|
||
let width = if o > 1e-9 {
|
||
let product = v * n;
|
||
if product.is_finite() {
|
||
// Keep the existing rounding, including small products that n/o would lose.
|
||
product / o
|
||
} else {
|
||
// With finite nonnegative factors, an overflowing product has both factors
|
||
// above one. Divide the larger first so this fallback cannot underflow.
|
||
(v.max(n) / o) * v.min(n)
|
||
}
|
||
} else {
|
||
v + n - o
|
||
};
|
||
width.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));
|
||
}
|
||
}
|
||
|
||
/// Whether `a` and `b` hold the same width points, each place and width within a rounding error
|
||
/// (1e-9).
|
||
fn same_points(a: Option<&WidthProfile>, b: Option<&WidthProfile>) -> bool {
|
||
let near = |p: &(f64, f64, f64), q: &(f64, f64, f64)| (p.0 - q.0).abs() <= 1e-9 && (p.1 - q.1).abs() <= 1e-9 && (p.2 - q.2).abs() <= 1e-9;
|
||
match (a, b) {
|
||
(None, None) => true,
|
||
(Some(a), Some(b)) => a.points.len() == b.points.len() && a.points.iter().zip(&b.points).all(|(p, q)| near(p, q)),
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
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);
|
||
// When the profile comes out as it was, within a rounding error (1e-9), the document and its undo
|
||
// history stay as they are.
|
||
let mut pos = 0;
|
||
super::overprint::edit_counted(s, "Width Point", |d| {
|
||
let st = weighted_stroke(d, id)?;
|
||
let before = st.profile.clone();
|
||
let half = st.width / 2.0;
|
||
let pt = (t, left / half, right / half);
|
||
if !half.is_finite() || half <= 0.0 || !pt.1.is_finite() || !pt.2.is_finite() {
|
||
return Err(bad(C, "width factors must be finite"));
|
||
}
|
||
let mut prof = st.profile.take().unwrap_or_else(uniform);
|
||
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 => {
|
||
let old = adjust.then(|| prof.at(t));
|
||
prof.points.retain(|q| (q.0 - t).abs() > SAME_T);
|
||
let i = place(&mut prof.points, pt, true);
|
||
if let Some((left, right)) = old {
|
||
adjust_adjoining(&mut prof.points, i, (t, left, right), pt);
|
||
}
|
||
i
|
||
}
|
||
};
|
||
// Validate on edit_counted's copy: a failed adjoining adjustment must not change
|
||
// the document, its dirty state or either side of its history.
|
||
if prof.points.iter().any(|p| !p.1.is_finite() || !p.2.is_finite()) {
|
||
return Err(bad(C, "width factors must be finite"));
|
||
}
|
||
st.profile = Some(prof);
|
||
Ok(usize::from(!same_points(st.profile.as_ref(), before.as_ref())))
|
||
})?;
|
||
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);
|
||
// When the profile comes out as it was (the copy replaces a side of a discontinuous point that has
|
||
// its widths already), the document and its undo history stay as they are.
|
||
let mut pos = 0;
|
||
super::overprint::edit_counted(s, "Copy Width Point", |d| {
|
||
let st = weighted_stroke(d, id)?;
|
||
let before = st.profile.clone();
|
||
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];
|
||
pos = place(&mut prof.points, (t, l, r), from < t);
|
||
st.profile = Some(prof);
|
||
Ok(usize::from(st.profile != before))
|
||
})?;
|
||
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 indices = match (p.get("indices"), p.get("index")) {
|
||
(Some(a), _) => point_indices(a),
|
||
(None, Some(i)) => point_indices(&json!([i])),
|
||
(None, None) => return Err(bad(C, "missing index")),
|
||
};
|
||
// A malformed index fails the whole delete instead of being dropped while the others go.
|
||
let mut indices = indices.ok_or_else(|| bad(C, "no such width point"))?;
|
||
indices.sort_unstable();
|
||
indices.dedup();
|
||
s.edit("Delete Width Point", |d, _| {
|
||
let st = stroke_mut(d, id)?;
|
||
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 doc = &s.doc()?.doc;
|
||
let mut ids: Vec<NodeId> = targets(s, p)?.into_iter().map(|id| stroke_owner(doc, id)).collect();
|
||
ids.sort_unstable();
|
||
ids.dedup();
|
||
// When every stroke has the profile already, or there is no stroke, the document and its undo
|
||
// history stay as they are.
|
||
if ids.iter().all(|id| doc.node(*id).is_some_and(|n| n.appearance.stroke().is_none_or(|st| st.profile == profile))) {
|
||
return ok();
|
||
}
|
||
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()
|
||
}
|
||
|
||
/// What a liquify stroke may reach: the paths it can edit, each with the box no dab outside
|
||
/// changes it ([`reach_bounds`]), and the objects it leaves as they are (type, symbols, images,
|
||
/// graphs, meshes, and envelopes, repeats and blends with their contents), each with its bounds
|
||
/// and what it is. Hidden and locked objects, and guides, are neither.
|
||
#[derive(Default)]
|
||
struct Targets {
|
||
paths: Vec<(NodeId, Rect)>,
|
||
blocked: Vec<(NodeId, Rect, &'static str)>,
|
||
/// Every object listed (selected roots may hold each other).
|
||
seen: std::collections::HashSet<NodeId>,
|
||
}
|
||
|
||
/// What `n` is when Liquify can't distort it (and doesn't look inside it).
|
||
fn not_liquified(n: &Node) -> Option<&'static str> {
|
||
Some(match &n.kind {
|
||
NodeKind::Group { .. } if n.extras().graph.is_some() => "graphs",
|
||
NodeKind::Text(_) => "type",
|
||
NodeKind::SymbolInstance { .. } => "symbols",
|
||
NodeKind::Image(_) => "images",
|
||
NodeKind::Mesh(_) => "meshes",
|
||
NodeKind::Envelope { .. } => "envelopes",
|
||
NodeKind::Repeat(_) => "repeats",
|
||
NodeKind::PlacedDocument(_) => "placed documents",
|
||
NodeKind::Blend { .. } => "blends",
|
||
_ => return None,
|
||
})
|
||
}
|
||
|
||
impl Targets {
|
||
/// The targets under `roots` (the selection), or with none every visible object.
|
||
fn of(d: &Document, roots: &[NodeId]) -> Self {
|
||
let mut t = Self::default();
|
||
if roots.is_empty() {
|
||
d.layers.iter().for_each(|l| t.visit(l));
|
||
return t;
|
||
}
|
||
for r in roots {
|
||
let (Some(n), Some(chain)) = (d.node(*r), d.ancestry(*r)) else { continue };
|
||
if !d.is_editable(*r) {
|
||
continue;
|
||
}
|
||
// Inside a graph, an envelope, a repeat or a blend: that object is what is left alone.
|
||
match chain.iter().filter_map(|a| d.node(*a)).find_map(|a| Some((a, not_liquified(a)?))) {
|
||
Some((a, what)) => t.block(a, what),
|
||
None => t.visit(n),
|
||
}
|
||
}
|
||
t
|
||
}
|
||
|
||
fn block(&mut self, n: &Node, what: &'static str) {
|
||
if let Some(b) = n.geometric_bounds()
|
||
&& self.seen.insert(n.id)
|
||
{
|
||
self.blocked.push((n.id, b, what));
|
||
}
|
||
}
|
||
|
||
fn visit(&mut self, n: &Node) {
|
||
if !n.visible || n.locked {
|
||
return;
|
||
}
|
||
if let Some(what) = not_liquified(n) {
|
||
return self.block(n, what);
|
||
}
|
||
match &n.kind {
|
||
NodeKind::Path { guide: false, path, .. } => {
|
||
if let Some(b) = reach_bounds(path)
|
||
&& self.seen.insert(n.id)
|
||
{
|
||
self.paths.push((n.id, b));
|
||
}
|
||
}
|
||
_ => n.children().into_iter().flatten().for_each(|c| self.visit(c)),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Does a dab with box `bb` reach `b`?
|
||
fn reaches(bb: Rect, b: Rect) -> bool {
|
||
b.inflate(1e-6, 1e-6).intersect(bb).area() > 0.0
|
||
}
|
||
|
||
/// A liquify stroke being applied: its dabs and the paths they reached so far, so a longer stroke
|
||
/// (the next sample of a drag) applies only the dabs it adds. It belongs to one document snapshot
|
||
/// (`base`, held so the pointer can't be reused) and one set of other parameters (`key`).
|
||
pub(crate) struct LiquifyStroke {
|
||
base: Arc<Document>,
|
||
key: Value,
|
||
prm: LiquifyParams,
|
||
dabs: Dabber,
|
||
targets: Targets,
|
||
/// The paths reached, by their index in `targets.paths`.
|
||
paths: Vec<(usize, PathStroke)>,
|
||
/// The objects left alone that the brush passed over, by their index in `targets.blocked`.
|
||
skipped: Vec<usize>,
|
||
/// Which of `targets.paths` and `targets.blocked` the brush reached.
|
||
reached: (Vec<bool>, Vec<bool>),
|
||
}
|
||
|
||
impl LiquifyStroke {
|
||
fn new(base: Arc<Document>, key: Value, prm: LiquifyParams, roots: &[NodeId]) -> Self {
|
||
let targets = Targets::of(&base, roots);
|
||
let reached = (vec![false; targets.paths.len()], vec![false; targets.blocked.len()]);
|
||
Self { base, key, prm, dabs: Dabber::new(&prm), targets, paths: vec![], skipped: vec![], reached }
|
||
}
|
||
|
||
/// Start the targets dab box `bb` reaches.
|
||
fn reach(&mut self, bb: Rect) {
|
||
for (i, (id, b)) in self.targets.paths.iter().enumerate() {
|
||
if let Some(r) = self.reached.0.get_mut(i).filter(|r| !**r && reaches(bb, *b)) {
|
||
*r = true;
|
||
let Some(path) = self.base.node(*id).and_then(|n| n.path_data()) else { continue };
|
||
self.paths.push((i, PathStroke::new(path.clone(), id.0)));
|
||
}
|
||
}
|
||
for (i, (_, b, _)) in self.targets.blocked.iter().enumerate() {
|
||
if let Some(r) = self.reached.1.get_mut(i).filter(|r| !**r && reaches(bb, *b)) {
|
||
*r = true;
|
||
self.skipped.push(i);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Add `samples` to the stroke and apply the dabs they make.
|
||
fn extend(&mut self, samples: &[Sample]) {
|
||
let from = self.dabs.dabs.len();
|
||
for s in samples {
|
||
self.dabs.push(*s);
|
||
}
|
||
for i in from..self.dabs.dabs.len() {
|
||
if let Some(c) = self.dabs.dabs.get(i).map(|d| d.c) {
|
||
self.reach(self.prm.brush_bounds(c));
|
||
}
|
||
}
|
||
if let Some(t) = self.dabs.tail() {
|
||
self.reach(self.prm.brush_bounds(t.c));
|
||
}
|
||
for (_, ps) in &mut self.paths {
|
||
ps.advance(&self.dabs.dabs, &self.prm);
|
||
}
|
||
}
|
||
|
||
/// The paths the stroke changed (in document order) and their new geometry.
|
||
fn results(&self) -> Vec<(NodeId, PathData)> {
|
||
let tail = self.dabs.tail();
|
||
let mut out: Vec<(usize, NodeId, PathData)> =
|
||
self.paths.iter().filter_map(|(i, ps)| Some((*i, self.targets.paths.get(*i)?.0, ps.finish(&self.dabs.dabs, tail, &self.prm)?))).collect();
|
||
out.sort_by_key(|(i, ..)| *i);
|
||
out.into_iter().map(|(_, id, p)| (id, p)).collect()
|
||
}
|
||
|
||
/// The objects under the brush left as they are, and the status message about them.
|
||
fn skipped(&self) -> (Vec<u64>, Option<String>) {
|
||
let mut ids = vec![];
|
||
let mut kinds: Vec<&str> = vec![];
|
||
for (id, _, what) in self.skipped.iter().filter_map(|i| self.targets.blocked.get(*i)) {
|
||
ids.push(id.0);
|
||
if !kinds.contains(what) {
|
||
kinds.push(what);
|
||
}
|
||
}
|
||
let msg = (!ids.is_empty()).then(|| {
|
||
let n = ids.len();
|
||
format!(
|
||
"{} left {n} object{} under the brush as {} ({}): it reshapes paths only",
|
||
self.prm.kind.label(),
|
||
if n == 1 { "" } else { "s" },
|
||
if n == 1 { "it was" } else { "they were" },
|
||
kinds.join(", ")
|
||
)
|
||
});
|
||
(ids, msg)
|
||
}
|
||
}
|
||
|
||
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 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 base = s.doc()?.doc.clone();
|
||
let mut key = p.clone();
|
||
if let Some(o) = key.as_object_mut() {
|
||
o.remove("points");
|
||
o.insert("__roots".into(), json!(roots.iter().map(|r| r.0).collect::<Vec<_>>()));
|
||
}
|
||
// A live drag: the same stroke as the last preview with more samples goes on from there.
|
||
let mut stroke = match s.liquify_stroke.take() {
|
||
Some(st) if Arc::ptr_eq(&st.base, &base) && st.key == key && pts.starts_with(st.dabs.samples()) => st,
|
||
_ => Box::new(LiquifyStroke::new(base, key, prm, &roots)),
|
||
};
|
||
let done = stroke.dabs.samples().len();
|
||
stroke.extend(pts.get(done..).unwrap_or_default());
|
||
let results = stroke.results();
|
||
let (skipped, warning) = stroke.skipped();
|
||
// When the stroke reshapes no path, the document and its undo history stay as they are.
|
||
let changed = if results.is_empty() {
|
||
vec![]
|
||
} else {
|
||
s.edit(&format!("{} Tool", prm.kind.label()), |d, _| {
|
||
let mut changed = vec![];
|
||
for (id, new) in results {
|
||
let Some(n) = d.node_mut(id) else { continue };
|
||
if let NodeKind::Path { path, live, .. } = &mut n.kind {
|
||
*path = new;
|
||
*live = None;
|
||
changed.push(id.0);
|
||
}
|
||
}
|
||
Ok(changed)
|
||
})?
|
||
};
|
||
if s.in_interaction() {
|
||
s.liquify_stroke = Some(stroke);
|
||
}
|
||
let mut out = json!({ "ids": changed, "skipped": skipped });
|
||
if let Some(w) = warning {
|
||
out["warning"] = json!(w);
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
// ---------- 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 { outermost(&s.doc()?.doc, 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();
|
||
// When the art and its pins come out as they were, the document and its undo history stay
|
||
// as they are.
|
||
let before = s.doc()?.doc.clone();
|
||
super::overprint::edit_counted(s, "Puppet Warp", |d| {
|
||
warp_from_rest(d, &ids, pins, expand).map_err(|e| bad(C, e))?;
|
||
Ok(usize::from(d.puppet != before.puppet || ids.iter().any(|id| d.node(*id) != before.node(*id))))
|
||
})?;
|
||
return Ok(ids_json);
|
||
}
|
||
let mesh = mesh_for(&s.doc()?.doc, &ids, expand).ok_or_else(|| EngineError::Other("nothing to warp".into()))?;
|
||
// When every pin stays where it is and turns the art by 0° or is free, the document and its undo
|
||
// history stay as they are.
|
||
if pins == moved && angles.iter().all(|a| a.is_none_or(|a| a == 0.0)) {
|
||
return Ok(ids_json);
|
||
}
|
||
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, [`crate::DocState::change_unsaved`]).
|
||
pub(crate) fn drop_puppet_pins(st: &mut crate::DocState) {
|
||
st.change_unsaved(|d| Arc::make_mut(d).puppet = None);
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
/// Whether `d` holds `g` as [`store_grid`] writes it (with `attached` cleared): then storing `g`
|
||
/// leaves `d` as it is.
|
||
fn grid_kept(d: &Document, g: &PerspectiveGrid) -> bool {
|
||
let mut g = g.clone();
|
||
g.attached.clear();
|
||
serde_json::to_value(&g).is_ok_and(|v| d.unknown.get(persp::DOC_KEY) == Some(&v))
|
||
}
|
||
|
||
/// Change view state of the grid without an undo step.
|
||
pub(crate) 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 old = grid_of(&s.doc()?.doc);
|
||
let g = old.merged(p).map_err(|e| bad("perspective.grid.set", e))?;
|
||
let reproject = bool_or(p, "reproject", false);
|
||
// When the document holds this grid already, no object moves, and the document and its undo
|
||
// history stay as they are.
|
||
if grid_kept(&s.doc()?.doc, &g) {
|
||
return Ok(g.definition_json());
|
||
}
|
||
s.edit("Define Perspective Grid", |d, _| {
|
||
if reproject {
|
||
old.adopt_stored_attachments(d);
|
||
for (id, plane, depth) in persp::attached_roots(d) {
|
||
let h = old.homography_at(plane, depth).and_then(|h| h.inverse()).zip(g.homography_at(plane, depth));
|
||
let (hi, h) = h.ok_or_else(|| EngineError::Other("an object in perspective would leave the edited grid".into()))?;
|
||
let m = h.then_after(&hi);
|
||
warp_checked(d, id, &m)?;
|
||
}
|
||
}
|
||
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(outermost(&s.doc()?.doc, v)),
|
||
None => selected_roots(s),
|
||
}
|
||
}
|
||
|
||
/// Map `n` with `f`, failing if any point would leave the plane's visible side.
|
||
/// Map `n` with `h` (type and symbols in perspective keep their art and are drawn through it),
|
||
/// failing if any point would leave the plane's visible side.
|
||
fn warp_checked(d: &mut Document, id: NodeId, h: &Homography) -> 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| h.apply(*q).is_none()) {
|
||
return Err(EngineError::Other("the object would cross the horizon of the perspective plane".into()));
|
||
}
|
||
project_node(n, h);
|
||
Ok(())
|
||
}
|
||
|
||
/// Attach `id` to `plane`: projected by `map`, else by the map that keeps its bounds' corners
|
||
/// (on the nearest gridlines with `snap`).
|
||
fn attach_in(d: &mut Document, g: &mut PerspectiveGrid, id: NodeId, plane: Plane, snap: bool, map: Option<Homography>) -> 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 =
|
||
map.or_else(|| g.attach_homography(plane, b, snap)).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, g.offset(plane));
|
||
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")?;
|
||
// With no object to attach, when the document holds the grid already, the document and its undo
|
||
// history stay as they are.
|
||
if ids.is_empty() && grid_kept(&s.doc()?.doc, &g) {
|
||
return Ok(json!({ "ids": [], "plane": plane.id() }));
|
||
}
|
||
s.edit("Attach to Active Plane", |d, _| {
|
||
for id in &ids {
|
||
attach_in(d, &mut g, *id, plane, false, None)?;
|
||
}
|
||
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);
|
||
// When releasing leaves every object as it is (none is in perspective) and the document holds
|
||
// the grid already, the document and its undo history stay as they are.
|
||
let d = &s.doc()?.doc;
|
||
let unchanged = |id: &NodeId| {
|
||
d.node(*id).is_none_or(|n| {
|
||
let mut released = n.clone();
|
||
persp::release(&mut released);
|
||
released == *n
|
||
})
|
||
};
|
||
if ids.iter().all(unchanged) && grid_kept(d, &g) {
|
||
return ok();
|
||
}
|
||
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) {
|
||
persp::release(n);
|
||
}
|
||
}
|
||
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())
|
||
}
|
||
|
||
/// Whether the grid stored in `d` lists attachments of its own, as grids stored before attachments
|
||
/// were kept on the objects do ([`PerspectiveGrid::adopt_stored_attachments`]).
|
||
fn stores_attachments(d: &Document) -> bool {
|
||
!PerspectiveGrid::current(d).attached.is_empty()
|
||
}
|
||
|
||
/// 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). In an
|
||
/// interaction whose document is still its snapshot, the duplicates are the copies the interaction
|
||
/// keeps ([`super::object::preview_copies`]), unless the snapshot's grid lists attachments of its
|
||
/// own ([`stores_attachments`]).
|
||
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);
|
||
let kept = if copy && !stores_attachments(&s.doc()?.doc) { super::object::preview_copies(s, ids, false)? } else { None };
|
||
s.edit(label, |d, sel| {
|
||
g.adopt_stored_attachments(d);
|
||
let targets = match &kept {
|
||
// The document becomes the snapshot with the kept copies, not yet mapped, and the loop
|
||
// below maps only the copies.
|
||
Some(k) => {
|
||
*d = Document::clone(&k.doc);
|
||
sel.clone_from(&k.selection);
|
||
k.ids.clone()
|
||
}
|
||
None if copy => duplicate_in(d, sel, ids, Affine::IDENTITY)?,
|
||
None => 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, false, None)?;
|
||
(pl, g.offset(pl))
|
||
}
|
||
};
|
||
first.get_or_insert((plane, depth));
|
||
let (h, depth) = step(&g, plane, depth)?;
|
||
warp_checked(d, *id, &h)?;
|
||
persp::set_attachment(d.node_mut(*id).ok_or(EngineError::NoNode(*id))?, plane, depth);
|
||
}
|
||
store_grid(d, &g);
|
||
Ok((targets, first))
|
||
})
|
||
}
|
||
|
||
/// Where the first of `ids` is in perspective (its plane and depth) when `ids` is not empty, every one
|
||
/// of them is in perspective already, `still` holds for each one's plane and depth, and the document
|
||
/// holds the grid already; None otherwise.
|
||
fn unmoved(s: &Session, ids: &[NodeId], still: impl Fn(&PerspectiveGrid, Plane, f64) -> bool) -> Result<Option<Attached>> {
|
||
let d = &s.doc()?.doc;
|
||
let g = grid_of(d);
|
||
if ids.is_empty() || !grid_kept(d, &g) {
|
||
return Ok(None);
|
||
}
|
||
let at: Option<Vec<Attached>> = ids.iter().map(|id| g.attachment_of(d, *id).filter(|(pl, depth)| still(&g, *pl, *depth))).collect();
|
||
Ok(at.and_then(|at| at.first().copied()))
|
||
}
|
||
|
||
/// 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));
|
||
// Snap to Grid: a correction for the objects on the first one's plane and depth.
|
||
let fix = if perp { None } else { super::perspgrid::snap_fix(&s.doc()?.doc, &ids, p, (from, to)) };
|
||
let snapped = move |pl: Plane, depth: f64, dv: vectorcraft_geom::Vec2| {
|
||
dv + fix.filter(|f| f.0 == pl && f.1 == depth).map_or(vectorcraft_geom::Vec2::ZERO, |f| f.2)
|
||
};
|
||
// 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 = snapped(pl, depth, 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" };
|
||
// When no object moves more than 1e-9 pt along its plane's axes or its normal (Snap to Grid
|
||
// included) and there is no copy, the document and its undo history stay as they are.
|
||
let still = |g: &PerspectiveGrid, pl: Plane, depth: f64| {
|
||
if perp {
|
||
g.depth_at(pl, depth, from, to).is_some_and(|d| (d - depth).abs() <= 1e-9)
|
||
} else {
|
||
g.plane_delta(pl, depth, from, to).map(|dv| snapped(pl, depth, dv)).is_some_and(|dv| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9)
|
||
}
|
||
};
|
||
let first_still = if copy { None } else { unmoved(s, &ids, still)? };
|
||
let (targets, first) = match first_still {
|
||
Some(at) => (ids, Some(at)),
|
||
None => persp_edit(s, label, &ids, fallback, copy, &step)?,
|
||
};
|
||
// Transform Again repeats the first object's move, and the identity when no object moved.
|
||
if let Some((pl, depth)) = first {
|
||
let g = grid_of(&s.doc()?.doc);
|
||
let again = match perp {
|
||
_ if first_still.is_some() => Some(json!({"matrix": matrix_json(Affine::IDENTITY), "copy": copy})),
|
||
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(snapped(pl, depth, 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))
|
||
};
|
||
// An identity without a copy moves no object that is in perspective already: then the document
|
||
// and its undo history stay as they are, and Transform Again repeats the identity.
|
||
let identity = !copy && m == Affine::IDENTITY && dz == 0.0;
|
||
let targets = if identity && unmoved(s, &ids, |g, pl, depth| g.transform_map(pl, depth, m, dz).is_some())?.is_some() {
|
||
ids
|
||
} else {
|
||
persp_edit(s, if copy { "Copy in Perspective" } else { "Transform in Perspective" }, &ids, fallback, copy, &step)?.0
|
||
};
|
||
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 plane_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const C: &str = "perspective.plane.move";
|
||
let old = grid_of(&s.doc()?.doc);
|
||
let plane = plane_param(&old, p, C)?;
|
||
let from = old.offset(plane);
|
||
let to = match (p.get("offset").and_then(Value::as_f64), p.get("by").and_then(Value::as_f64)) {
|
||
(Some(o), _) => o,
|
||
(None, Some(b)) => from + b,
|
||
(None, None) => return Err(bad(C, "give offset or by")),
|
||
};
|
||
let objects = str_param(p, "objects").unwrap_or("none");
|
||
if !matches!(objects, "none" | "move" | "copy") {
|
||
return Err(bad(C, "objects must be none, move or copy"));
|
||
}
|
||
let mut g = old.clone();
|
||
g.set_offset(plane, to);
|
||
g.validate().map_err(|e| bad(C, e))?;
|
||
let label = match objects {
|
||
"copy" => "Move Plane and Copy Objects",
|
||
"move" => "Move Plane and Objects",
|
||
_ => "Move Plane",
|
||
};
|
||
// The objects on the plane where it was (not those moved off it along its normal).
|
||
let on_plane = |d: &Document| -> Vec<NodeId> {
|
||
persp::attached_roots(d)
|
||
.into_iter()
|
||
.filter(|(id, pl, depth)| *pl == plane && (depth - from).abs() < 1e-6 && d.is_editable(*id))
|
||
.map(|(id, ..)| id)
|
||
.collect()
|
||
};
|
||
// A plane moved to where it is, with its objects or without them, moves nothing: when the
|
||
// document holds the grid already, the document and its undo history stay as they are.
|
||
if to == from && objects != "copy" && grid_kept(&s.doc()?.doc, &g) {
|
||
let ids = if objects == "move" { on_plane(&s.doc()?.doc) } else { vec![] };
|
||
return Ok(json!({ "plane": plane.id(), "offset": to, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }));
|
||
}
|
||
// In an interaction whose document is still its snapshot, the copies of the objects on the
|
||
// plane are the copies the interaction keeps (`preview_copies`), unless the snapshot's grid
|
||
// lists attachments of its own (`stores_attachments`).
|
||
let kept = if objects == "copy" && s.in_interaction() && !stores_attachments(&s.doc()?.doc) {
|
||
let on = on_plane(&s.doc()?.doc);
|
||
super::object::preview_copies(s, &on, false)?
|
||
} else {
|
||
None
|
||
};
|
||
let ids = s.edit(label, |d, sel| {
|
||
old.adopt_stored_attachments(d);
|
||
let mut ids = vec![];
|
||
if objects != "none" {
|
||
ids = match &kept {
|
||
// The document becomes the snapshot with the kept copies, not yet moved, and the
|
||
// loop below moves only the copies.
|
||
Some(k) => {
|
||
*d = Document::clone(&k.doc);
|
||
sel.clone_from(&k.selection);
|
||
k.ids.clone()
|
||
}
|
||
None => {
|
||
let on = on_plane(d);
|
||
if objects == "copy" { duplicate_in(d, sel, &on, Affine::IDENTITY)? } else { on }
|
||
}
|
||
};
|
||
let m = old.transform_map(plane, from, Affine::IDENTITY, to - from).ok_or_else(beyond_horizon)?;
|
||
for id in &ids {
|
||
warp_checked(d, *id, &m)?;
|
||
persp::set_attachment(d.node_mut(*id).ok_or(EngineError::NoNode(*id))?, plane, to);
|
||
}
|
||
}
|
||
store_grid(d, &g);
|
||
Ok(ids)
|
||
})?;
|
||
Ok(json!({ "plane": plane.id(), "offset": to, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||
}
|
||
|
||
fn plane_match(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let st = s.doc()?;
|
||
let id = id_param(p, "id")
|
||
.or_else(|| st.selection.objects.first().copied())
|
||
.ok_or_else(|| EngineError::Other("select an object in perspective".into()))?;
|
||
let mut g = grid_of(&st.doc);
|
||
let (plane, depth) = g.attachment_of(&st.doc, id).ok_or_else(|| EngineError::Other("the object isn't in perspective".into()))?;
|
||
g.set_offset(plane, depth);
|
||
g.plane = plane;
|
||
g.validate().map_err(|e| bad("perspective.plane.matchObject", e))?;
|
||
// When the plane is at the object and active already, and the document holds that grid, the
|
||
// document and its undo history stay as they are.
|
||
if grid_kept(&st.doc, &g) {
|
||
return Ok(json!({ "plane": plane.id(), "offset": depth }));
|
||
}
|
||
s.edit("Move Plane to Match Object", |d, _| {
|
||
store_grid(d, &g);
|
||
Ok(())
|
||
})?;
|
||
Ok(json!({ "plane": plane.id(), "offset": depth }))
|
||
}
|
||
|
||
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 snap = bool_or(p, "snap", g.snap);
|
||
// A click-to-size dialog's shape keeps its sizes in plane units from the click.
|
||
let map = match point_param(p, "at") {
|
||
Some(at) => Some(g.size_homography(plane, at, snap).ok_or_else(beyond_horizon)?),
|
||
None => None,
|
||
};
|
||
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, snap, map)?;
|
||
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();
|
||
}
|
||
st.history.recost_last(&st.doc);
|
||
}
|
||
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",
|
||
"shape.flare",
|
||
"shape.spiral",
|
||
"shape.polarGrid",
|
||
];
|
||
if !SHAPES.contains(&cmd) {
|
||
return None;
|
||
}
|
||
let g = PerspectiveGrid::current(&s.active()?.doc);
|
||
(g.visible && g.plane != Plane::None).then(|| ("perspective.draw".to_string(), json!({"command": cmd, "params": params})))
|
||
}
|
||
|
||
/// A click-to-size shape dialog's command (`cmd` with `params`, clicked at `at`) as it runs: on the
|
||
/// active plane while the grid shows (`perspective.draw` keeping the sizes in plane units from the
|
||
/// click), else `None` (run it as it is).
|
||
pub fn perspective_click(s: &Session, cmd: &str, params: &Value, at: Point) -> Option<(String, Value)> {
|
||
let (c, mut p) = perspective_rewrite(s, cmd, params)?;
|
||
p["at"] = json!([at.x, at.y]);
|
||
Some((c, p))
|
||
}
|
||
|
||
/// Object › Perspective › Edit Text: type in perspective shown flat where it is drawn, in isolation
|
||
/// mode, to be edited; exiting isolation projects it again ([`finish_edit_text`]).
|
||
fn edit_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let st = s.doc()?;
|
||
let id =
|
||
id_param(p, "id").or_else(|| st.selection.objects.first().copied()).ok_or_else(|| EngineError::Other("select type in perspective".into()))?;
|
||
if !st.doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)) && n.projection().is_some()) {
|
||
return Err(EngineError::Other("select type in perspective".into()));
|
||
}
|
||
s.edit("Edit Text", |d, _| {
|
||
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
|
||
let h = n.projection().ok_or_else(|| EngineError::Other("select type in perspective".into()))?;
|
||
let NodeKind::Text(t) = &mut n.kind else { return Err(EngineError::Other("select type in perspective".into())) };
|
||
// Shown flat where it is drawn: the type moves there and the projection moves the other
|
||
// way, so the picture stays.
|
||
let (Some(flat), Some(drawn)) = (t.bounds(), t.bounds().and_then(|b| h.map_rect_bbox(b))) else { return Ok(()) };
|
||
let shift = vectorcraft_geom::Affine::translate(drawn.center() - flat.center());
|
||
t.transform(shift);
|
||
if let Some(rec) = n.extras_if_any().and_then(|e| e.perspective.as_deref_mut()) {
|
||
rec.projection = Some(h.then_after(&Homography::from_affine(shift.inverse())).to_array());
|
||
rec.editing = true;
|
||
}
|
||
Ok(())
|
||
})?;
|
||
let st = s.doc_mut()?;
|
||
st.isolation = Some(id);
|
||
st.selection.set([id]);
|
||
st.revision += 1;
|
||
Ok(json!({ "id": id.0 }))
|
||
}
|
||
|
||
/// Edit Text ends (isolation mode on `id` exits): the type is projected again, in the document and
|
||
/// in every undo state (the flat view is never an undo step of its own, and never saved: a saved
|
||
/// state stays saved, [`crate::DocState::change_unsaved`]).
|
||
pub(crate) fn finish_edit_text(st: &mut crate::DocState, id: NodeId) {
|
||
let clear = |doc: &mut Arc<Document>| {
|
||
// Undo steps keep no lookup cache: look without building one, and drop the one an edit
|
||
// builds (the document now keeps its own).
|
||
if doc.node_uncached(id).and_then(|n| n.extras().perspective.as_deref()).is_some_and(|p| p.editing)
|
||
&& let Some(p) = Arc::make_mut(doc).node_mut(id).and_then(|n| n.extras_if_any().and_then(|e| e.perspective.as_deref_mut()))
|
||
{
|
||
p.editing = false;
|
||
}
|
||
};
|
||
st.change_unsaved(clear);
|
||
for e in st.history.undo.iter_mut().chain(st.history.redo.iter_mut()) {
|
||
let saved = Arc::ptr_eq(&e.doc, &st.saved_doc);
|
||
clear(&mut e.doc);
|
||
e.doc.drop_lookup_cache();
|
||
if saved {
|
||
st.saved_doc = e.doc.clone();
|
||
}
|
||
}
|
||
if let Some(it) = &mut st.interaction {
|
||
clear(&mut it.doc);
|
||
}
|
||
st.revision += 1;
|
||
}
|