M8.6: Puppet Warp pins appear at once, warp from the rest shape and follow Undo

- 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>
This commit is contained in:
bflatastic
2026-10-06 11:16:10 +07:00
co-authored by Claude Opus 5.5
parent 47ef223a02
commit 117aa0b5a3
12 changed files with 1115 additions and 263 deletions
+54 -13
View File
@@ -7,11 +7,11 @@
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, NodeId, NodeKind, WidthProfile};
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::{arap, collect_points, mesh_for, warp_node_with};
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::*;
@@ -69,10 +69,19 @@ pub fn specs() -> Vec<CommandSpec> {
"Puppet Warp",
[],
None,
"{id?|ids? (default: selection), pins: [[x,y]…] (current pin positions), moved: [[x,y]…] (targets, same length), angles?: [deg|null…] (same length: the turn the art takes around each pin, as Alt-dragging around a pin does; null leaves it free), expand?: pt} as-rigid-as-possible mesh warp of anchors and handles",
"{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",
@@ -424,30 +433,43 @@ fn liquify(s: &mut Session, p: &Value) -> Result<Value> {
// ---------- 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]…]"))?;
if pins.is_empty() || pins.len() != moved.len() {
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 = match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) {
Some(v) => v,
None => selected_roots(s)?,
};
if ids.is_empty() {
return Err(EngineError::Other("select the artwork to warp".into()));
}
let 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 mesh = mesh_for(&s.doc()?.doc, &ids, expand).ok_or_else(|| EngineError::Other("nothing to warp".into()))?;
let angles: Vec<Option<f64>> = match p.get("angles") {
None | Some(Value::Null) => vec![None; pins.len()],
Some(Value::Array(a)) if a.len() == pins.len() => a.iter().map(|v| v.as_f64().filter(|d| d.is_finite()).map(f64::to_radians)).collect(),
Some(_) => return Err(bad(C, "angles must be a list as long as pins (degrees or null)")),
};
let 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);
@@ -458,7 +480,26 @@ fn puppet_warp(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(())
})?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
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 ----------
+4
View File
@@ -1049,6 +1049,10 @@ impl Session {
f(&st.doc, &mut st.selection);
st.selection.prune(&st.doc);
st.revision += 1;
// Puppet Warp pins belong to the art they were placed on: another selection starts afresh.
if st.doc.puppet.as_ref().is_some_and(|p| p.ids != st.selection.objects) {
cmd::distortcmds::drop_puppet_pins(st);
}
Ok(())
}
+12 -6
View File
@@ -207,13 +207,19 @@ fn puppet_tool_drag_warps_selection() {
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let v = ViewInfo::default();
s.select_tool("puppetWarp", v).unwrap();
// First click seeds pins and adds one at the right end, then drag it.
s.pointer(&PointerEvent::new(PointerKind::Down, 385.0, 150.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 385.0, 150.0), v).unwrap();
// The automatic pins show at once, one in the middle of each end: drag the right end's up.
let pins = s.execute("object.puppetWarp.pins", &json!({})).unwrap()["moved"].clone();
let right = pins
.as_array()
.unwrap()
.iter()
.map(|q| (q[0].as_f64().unwrap(), q[1].as_f64().unwrap()))
.fold((0.0, 0.0), |a, q| if q.0 > a.0 { q } else { a });
assert!(right.0 > 350.0, "{pins}");
let n = undo_len(&s);
s.pointer(&PointerEvent::new(PointerKind::Down, 385.0, 150.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 385.0, 120.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 385.0, 120.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, right.0, right.1), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, right.0, right.1 - 30.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, right.0, right.1 - 30.0), v).unwrap();
assert_eq!(undo_len(&s), n + 1);
assert_eq!(s.journal.last().unwrap().0, "object.puppetWarp");
assert!(path(&s, a).bounds().unwrap().y0 < 95.0);
+199 -3
View File
@@ -1,6 +1,6 @@
//! Puppet Warp: turning the art around a pin (`object.puppetWarp {angles}`) and the tool's pins.
use serde_json::json;
use serde_json::{Value, json};
use vectorcraft_geom::{Point, Rect};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
@@ -60,8 +60,204 @@ fn alt_drag_with_the_tool_turns_the_art_in_one_undo_step() {
assert_eq!(s.doc().unwrap().history.undo.len(), n + 1);
let (cmd, p) = s.journal.last().unwrap();
assert_eq!(cmd, "object.puppetWarp");
let angles = p["angles"].as_array().unwrap();
assert!(angles.iter().any(|a| a.as_f64().is_some_and(|d| (d - 90.0).abs() < 15.0)), "about a quarter turn: {p}");
// The turn is the angle the pointer swept around the pin.
let i = p["angles"].as_array().unwrap().iter().position(|a| a.is_number()).unwrap();
let (cx, cy) = (p["moved"][i][0].as_f64().unwrap(), p["moved"][i][1].as_f64().unwrap());
let swept = ((170.0 - cy).atan2(200.0 - cx) - (150.0 - cy).atan2(220.0 - cx)).to_degrees();
assert!((p["angles"][i].as_f64().unwrap() - swept).abs() < 1e-6 && swept > 60.0, "{p}");
let b = bounds(&s, a);
assert!(b.height() > 110.0, "the art turned: {b:?}");
}
// ---------- the pins' session (M8.6) ----------
fn pins(s: &mut Session) -> Value {
s.execute("object.puppetWarp.pins", &json!({})).unwrap()
}
fn anchors(s: &Session, id: NodeId) -> Vec<Point> {
let mut out = vec![];
s.doc().unwrap().doc.node(id).unwrap().walk(&mut |n| {
if let Some(p) = n.path_data() {
out.extend(p.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p)));
}
});
out
}
fn worst(a: &[Point], b: &[Point]) -> f64 {
assert_eq!(a.len(), b.len());
a.iter().zip(b).map(|(x, y)| x.distance(*y)).fold(0.0, f64::max)
}
fn click(s: &mut Session, x: f64, y: f64) {
let v = ViewInfo::default();
s.pointer(&PointerEvent::new(PointerKind::Down, x, y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, x, y), v).unwrap();
}
fn drag(s: &mut Session, from: (f64, f64), to: (f64, f64)) {
let v = ViewInfo::default();
s.pointer(&PointerEvent::new(PointerKind::Down, from.0, from.1), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, (from.0 + to.0) / 2.0, (from.1 + to.1) / 2.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, to.0, to.1), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, to.0, to.1), v).unwrap();
}
fn pin_overlays(s: &mut Session) -> usize {
s.overlays(ViewInfo::default()).iter().filter(|o| matches!(o, vectorcraft_tools::Overlay::Anchor { .. })).count()
}
#[test]
fn pins_appear_when_the_tool_is_chosen_or_art_selected() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 300.0, 60.0);
// Art selected, then the tool: the automatic pins show at once (the centre and both ends).
s.select_tool("puppetWarp", ViewInfo::default()).unwrap();
assert_eq!(pin_overlays(&mut s), 3);
let p = pins(&mut s);
assert_eq!((p["auto"].clone(), p["ids"].clone(), p["rest"].clone()), (json!(true), json!([a.0]), json!(true)));
assert_eq!(p["pins"], p["moved"], "automatic pins sit where they started");
// The tool first, then art selected.
s.execute("select.none", &json!({})).unwrap();
assert_eq!(pin_overlays(&mut s), 0);
assert!(s.execute("object.puppetWarp.pins", &json!({})).is_err(), "nothing selected");
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
assert_eq!(pin_overlays(&mut s), 3);
}
#[test]
fn warps_start_from_the_rest_shape_and_dont_stack() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 300.0, 60.0);
let rest = anchors(&s, a);
let p = pins(&mut s);
// Move the right-end pin up, then back: the original comes back.
let right = p["pins"].as_array().unwrap().iter().position(|q| q[0].as_f64().unwrap() > 300.0).unwrap();
let mut up = p.clone();
up["moved"][right][1] = json!(up["moved"][right][1].as_f64().unwrap() - 60.0);
s.execute("object.puppetWarp", &up).unwrap();
let once = anchors(&s, a);
assert!(worst(&rest, &once) > 30.0);
// The same pins again: the same warp (it doesn't stack on the last one).
s.execute("object.puppetWarp", &up).unwrap();
assert!(worst(&once, &anchors(&s, a)) < 1e-9);
s.execute("object.puppetWarp", &p).unwrap();
assert!(worst(&rest, &anchors(&s, a)) < 0.01, "back to the original");
// The query reads the pins back: where they started, where they are.
let now = pins(&mut s);
assert_eq!((now["pins"].clone(), now["auto"].clone()), (p["pins"].clone(), json!(false)));
// A pin off the mesh is refused.
let mut off = p.clone();
off["pins"][0] = json!([900, 900]);
let e = s.execute("object.puppetWarp", &off).unwrap_err().to_string();
assert!(e.contains("off the artwork"), "{e}");
}
#[test]
fn pins_follow_undo_and_redo() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 300.0, 60.0);
let rest = anchors(&s, a);
s.select_tool("puppetWarp", ViewInfo::default()).unwrap();
// Adding a pin is one undo step; dragging it is another.
click(&mut s, 250.0, 150.0);
let added = pins(&mut s);
assert_eq!(added["pins"].as_array().unwrap().len(), 4);
drag(&mut s, (250.0, 150.0), (250.0, 110.0));
let warped = anchors(&s, a);
let moved = pins(&mut s);
assert_eq!(moved["moved"][3], json!([250.0, 110.0]));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(pins(&mut s), added, "the pin went back with the art");
assert!(worst(&rest, &anchors(&s, a)) < 1e-9);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(pins(&mut s)["auto"], json!(true), "no pin placed");
assert_eq!(pin_overlays(&mut s), 3);
s.execute("edit.redo", &json!({})).unwrap();
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(pins(&mut s), moved);
assert!(worst(&warped, &anchors(&s, a)) < 1e-9);
// Pins persist across tool switches while the selection stays.
s.select_tool("selection", ViewInfo::default()).unwrap();
s.select_tool("puppetWarp", ViewInfo::default()).unwrap();
assert_eq!(pins(&mut s), moved);
assert_eq!(pin_overlays(&mut s), 4);
}
#[test]
fn a_new_selection_clears_the_pins() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 300.0, 60.0);
let b = rect(&mut s, 100.0, 300.0, 50.0, 50.0);
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
s.select_tool("puppetWarp", ViewInfo::default()).unwrap();
click(&mut s, 250.0, 150.0);
drag(&mut s, (250.0, 150.0), (250.0, 120.0));
let warped = anchors(&s, a);
assert_eq!(pins(&mut s)["auto"], json!(false));
s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
let dirty = s.doc().unwrap().is_dirty();
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
// Fresh automatic pins on the art as it is now (the warp stays).
let p = pins(&mut s);
assert_eq!(p["auto"], json!(true));
assert!(worst(&warped, &anchors(&s, a)) < 1e-9);
assert_eq!((s.doc().unwrap().history.undo.len(), s.doc().unwrap().is_dirty()), (undo, dirty), "not an edit");
// Something else editing the art keeps the pins, starting again from the art as it is.
click(&mut s, 200.0, 140.0);
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
let p2 = pins(&mut s);
assert_eq!(p2["auto"], json!(false));
assert_eq!(p2["pins"], p2["moved"]);
}
#[test]
fn puppet_warp_works_on_groups_compound_paths_and_open_strokes() {
for kind in ["group", "compound", "open"] {
let mut s = session();
let id = match kind {
"open" => {
let r = s.execute("shape.line", &json!({"x1": 100, "y1": 200, "x2": 400, "y2": 200})).unwrap();
s.execute("stroke.set", &json!({"weight": 8})).unwrap();
NodeId(r["id"].as_u64().unwrap())
}
_ => {
let a = rect(&mut s, 100.0, 170.0, 140.0, 60.0);
let b = rect(&mut s, 260.0, 170.0, 140.0, 60.0);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let cmd = if kind == "group" { "object.group" } else { "object.compoundPath.make" };
let r = s.execute(cmd, &json!({})).unwrap();
r["id"].as_u64().map(NodeId).unwrap_or_else(|| s.doc().unwrap().selection.objects[0])
}
};
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
let rest = anchors(&s, id);
s.select_tool("puppetWarp", ViewInfo::default()).unwrap();
// Pin both ends, lift the right one with the tool, then put it back.
click(&mut s, 104.0, 200.0);
click(&mut s, 396.0, 200.0);
drag(&mut s, (396.0, 200.0), (396.0, 140.0));
assert!(worst(&rest, &anchors(&s, id)) > 20.0, "{kind}: warped");
let n = s.doc().unwrap().doc.node(id).unwrap().children().map(Vec::len);
drag(&mut s, (396.0, 140.0), (396.0, 200.0));
assert!(worst(&rest, &anchors(&s, id)) < 0.05, "{kind}: back to the original");
assert_eq!(s.doc().unwrap().doc.node(id).unwrap().children().map(Vec::len), n, "{kind}: the structure stays");
}
}
#[test]
fn pins_are_never_saved() {
let mut s = session();
rect(&mut s, 100.0, 100.0, 300.0, 60.0);
s.select_tool("puppetWarp", ViewInfo::default()).unwrap();
click(&mut s, 250.0, 150.0);
assert!(s.doc().unwrap().doc.puppet.is_some());
let r = s.execute("document.serialize", &json!({"format": "vectorcraft"})).unwrap();
let data = r["dataBase64"].as_str().unwrap().to_string();
s.execute("document.open", &json!({"name": "pins.vectorcraft", "dataBase64": data})).unwrap();
let st = s.doc().unwrap();
assert!(st.doc.puppet.is_none());
assert_eq!(st.doc.node_count(), 2, "layer + rectangle");
}