264 lines
12 KiB
Rust
264 lines
12 KiB
Rust
//! 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");
|
||
}
|