Files
vectorcraft/crates/engine/src/tests_puppetwarp.rs
bflatasticandClaude Opus 5.5 117aa0b5a3 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>
2026-10-06 11:16:10 +07:00

264 lines
12 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Puppet Warp: turning the art around a pin (`object.puppetWarp {angles}`) and the tool's pins.
use serde_json::{Value, json};
use vectorcraft_geom::{Point, Rect};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
let r = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap();
NodeId(r["id"].as_u64().unwrap())
}
fn bounds(s: &Session, id: NodeId) -> Rect {
s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap()
}
#[test]
fn a_pin_with_an_angle_turns_the_art_around_it() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
// One pin at the centre, held at a quarter turn: the 200 × 100 rectangle stands up.
s.execute("object.puppetWarp", &json!({"id": a.0, "pins": [[200, 150]], "moved": [[200, 150]], "angles": [90]})).unwrap();
let b = bounds(&s, a);
assert!((b.width() - 100.0).abs() < 4.0 && (b.height() - 200.0).abs() < 4.0, "{b:?}");
assert!(b.center().distance(Point::new(200.0, 150.0)) < 2.0, "{b:?}");
// null leaves a pin free; the list must be as long as the pins.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("object.puppetWarp", &json!({"id": a.0, "pins": [[200, 150]], "moved": [[210, 150]], "angles": [null]})).unwrap();
assert!((bounds(&s, a).x0 - 110.0).abs() < 0.5, "a free single pin only moves the art");
for bad in [json!([90, 0]), json!("90"), json!({})] {
assert!(s.execute("object.puppetWarp", &json!({"id": a.0, "pins": [[200, 150]], "moved": [[200, 150]], "angles": bad})).is_err(), "{bad}");
}
}
#[test]
fn alt_drag_with_the_tool_turns_the_art_in_one_undo_step() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let v = ViewInfo::default();
s.select_tool("puppetWarp", v).unwrap();
s.set_tool_option("selectAllPins", &json!(false));
// Select the pin in the middle, then Alt-drag about a quarter turn around it, 20 pt out.
s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 150.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 200.0, 150.0), v).unwrap();
let n = s.doc().unwrap().history.undo.len();
let alt = Mods { alt: true, ..Default::default() };
for (k, x, y) in
[(PointerKind::Down, 220.0, 150.0), (PointerKind::Drag, 214.0, 164.0), (PointerKind::Drag, 200.0, 170.0), (PointerKind::Up, 200.0, 170.0)]
{
s.pointer(&PointerEvent::new(k, x, y).with_mods(alt), v).unwrap();
}
assert_eq!(s.doc().unwrap().history.undo.len(), n + 1);
let (cmd, p) = s.journal.last().unwrap();
assert_eq!(cmd, "object.puppetWarp");
// 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");
}