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
+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");
}