Files
dotCooCoo cf583304d1 Object: Lock, Transform Each, Perspective and other commands that change nothing record no undo step
These commands edited the document even when nothing changed, so the
call recorded an undo step, emptied Redo and marked the document
modified:

- All Artwork Above and Other Layers (Object › Lock and
  Object › Hide) with nothing to lock or hide: no object above the
  selection, no other layer, or every one already locked (Lock) or
  hidden (Hide).
- Transform Each with no object to transform, and without Copy when
  both scales are 100% and there is no move, rotation or reflection.
- Rearrange All Artboards when no artboard moves, or with no artboard.
- stroke.widthPoint.set and stroke.widthPoint.copy when the profile
  comes out as it was, and stroke.widthProfile.set with the profile the
  strokes have already.
- Liquify when the stroke reshapes no path, and Puppet Warp given the
  pins the art holds already (rest: true), or without rest when every
  pin stays where it is and turns the art by 0° or is free.
- Define Perspective Grid with the grid the document has, Attach to
  Active Plane with no object, Release with Perspective on art that is
  not in perspective, Move Plane to where the plane is, without copies,
  and Move Plane to Match Object when the plane is there and active
  already.
- Move, Nudge and Transform in Perspective without Copy when no object
  moves (1e-9 pt or less along the plane or its normal, Snap to Grid
  included, or the identity), and Transform Again after one.

These commands now return without editing the document. The replies
have the same form as before. Transform Each with Copy and no object
to transform no longer empties the selection. Puppet Warp without rest,
with every pin where it is and turning the art by 0° or free, no longer
turns a live shape into a plain path. All Artwork Above still deselects
selected objects that are hidden or locked. Width Point counts a change
of 1e-9 or less in a point's place or width factor (its width over half
the stroke weight) as none. OK in Width Point Edit with the widths it
shows, with or without Adjust Adjoining Width Points, records no undo
step. After a move in perspective that moved nothing, Transform Again
repeats the identity.

One line in the evidence list of docs/target-app-parity.md names the
commands.
2026-10-12 00:15:59 +07:00

602 lines
29 KiB
Rust

//! Width points, Liquify, Puppet Warp and Perspective Grid commands and tools.
use serde_json::json;
use vectorcraft_geom::{PathData, Point, Rect, Shape};
use vectorcraft_tools::distort::perspective::{PerspectiveGrid, Plane};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
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 line(s: &mut Session) -> NodeId {
let r = s.execute("shape.line", &json!({"x1": 100, "y1": 300, "x2": 500, "y2": 300})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("stroke.set", &json!({"ids": [id.0], "weight": 10})).unwrap();
id
}
fn path(s: &Session, id: NodeId) -> PathData {
s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone()
}
fn profile(s: &Session, id: NodeId) -> Option<Vec<(f64, f64, f64)>> {
s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().profile.as_ref().map(|p| p.points.clone())
}
fn undo_len(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
/// How many steps there are to undo and to redo, and whether the document has unsaved changes.
fn history(s: &Session) -> (usize, usize, bool) {
let st = s.doc().unwrap();
(st.history.undo.len(), st.history.redo.len(), st.is_dirty())
}
/// Leave a step to redo (a rectangle drawn and undone, which keeps the selection) and mark the
/// document saved.
fn with_a_step_to_redo(s: &mut Session) {
rect(s, 700.0, 500.0, 10.0, 10.0);
s.execute("edit.undo", &json!({})).unwrap();
s.doc_mut().unwrap().mark_saved();
}
fn gesture(s: &mut Session, tool: &str, pts: &[(f64, f64)], mods: Mods) {
let v = ViewInfo::default();
s.select_tool(tool, v).unwrap();
for (i, (x, y)) in pts.iter().enumerate() {
let kind = if i == 0 {
PointerKind::Down
} else if i == pts.len() - 1 {
PointerKind::Up
} else {
PointerKind::Drag
};
s.pointer(&PointerEvent::new(kind, *x, *y).with_mods(mods), v).unwrap();
}
}
fn close(a: Point, b: Point, tol: f64) -> bool {
a.distance(b) <= tol
}
// ---------- width ----------
#[test]
fn width_point_set_creates_profile_and_undoes() {
let mut s = session();
let id = line(&mut s);
let n = undo_len(&s);
let r = s.execute("stroke.widthPoint.set", &json!({"id": id.0, "t": 0.5, "left": 10, "right": 5})).unwrap();
assert_eq!(r["index"], json!(1));
assert_eq!(profile(&s, id).unwrap(), vec![(0.0, 1.0, 1.0), (0.5, 2.0, 1.0), (1.0, 1.0, 1.0)]);
assert_eq!(undo_len(&s), n + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(profile(&s, id), None);
}
#[test]
fn width_point_move_and_remove() {
let mut s = session();
let id = line(&mut s);
s.execute("stroke.widthPoint.set", &json!({"id": id.0, "t": 0.5, "left": 10, "right": 10})).unwrap();
let r = s.execute("stroke.widthPoint.set", &json!({"id": id.0, "t": 0.25, "left": 10, "right": 10, "index": 1})).unwrap();
assert_eq!(r["index"], json!(1));
assert_eq!(profile(&s, id).unwrap()[1], (0.25, 2.0, 2.0));
assert_eq!(profile(&s, id).unwrap().len(), 3);
s.execute("stroke.widthPoint.remove", &json!({"id": id.0, "index": 1})).unwrap();
assert_eq!(profile(&s, id).unwrap().len(), 2);
assert!(s.execute("stroke.widthPoint.remove", &json!({"id": id.0, "index": 7})).is_err());
assert!(s.execute("stroke.widthPoint.set", &json!({"id": id.0, "t": 0.5, "left": -1, "right": 1})).is_err());
}
#[test]
fn width_profile_set_and_clear() {
let mut s = session();
let id = line(&mut s);
s.execute("stroke.widthProfile.set", &json!({"ids": [id.0], "points": [[1, 0, 0], [0, 0.5, 0.5]]})).unwrap();
assert_eq!(profile(&s, id).unwrap(), vec![(0.0, 0.5, 0.5), (1.0, 0.0, 0.0)]);
s.execute("stroke.widthProfile.set", &json!({"ids": [id.0], "points": null})).unwrap();
assert_eq!(profile(&s, id), None);
}
#[test]
fn width_tool_gesture_is_one_undo_step() {
let mut s = session();
let id = line(&mut s);
let n = undo_len(&s);
gesture(&mut s, "width", &[(300.0, 301.0), (300.0, 310.0), (300.0, 320.0)], Mods::default());
let p = profile(&s, id).unwrap();
assert_eq!(p.len(), 3);
// The last drag sample (10 pt from the path) sets both sides: factor 10 / (10 / 2) = 2.
assert!((p[1].0 - 0.5).abs() < 1e-3 && (p[1].1 - 2.0).abs() < 1e-6 && (p[1].2 - 2.0).abs() < 1e-6, "{p:?}");
assert_eq!(undo_len(&s), n + 1);
// Delete removes the selected width point.
s.tool_key(ToolKey::Delete, Mods::default(), ViewInfo::default()).unwrap();
assert_eq!(profile(&s, id).unwrap().len(), 2);
}
/// Width Point with the place and widths a point has already (given its index, or at its place, with
/// Adjust Adjoining Width Points, or as Width Point Edit gives back the widths it shows), Copy Width
/// Point onto the side of a discontinuous point that has its widths already, and Width Profile with
/// the profile the strokes have already (or null for a uniform stroke and for art with no stroke)
/// record no undo step: the step to redo stays and the document stays unmodified.
#[test]
fn width_commands_that_change_nothing_record_no_undo_step() {
let mut s = session();
let id = line(&mut s);
let uniform = line(&mut s);
let r = rect(&mut s, 100.0, 400.0, 20.0, 20.0);
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
let group = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
// Points 1 and 2 make a discontinuous point at 0.5 whose sides are both 2 pt and 3 pt wide
// (point 1 copied onto its own place), and point 3 is at 0.75.
s.execute("stroke.widthPoint.set", &json!({"id": id.0, "t": 0.5, "left": 2, "right": 3})).unwrap();
s.execute("stroke.widthPoint.copy", &json!({"id": id.0, "index": 1, "t": 0.5})).unwrap();
s.execute("stroke.widthPoint.set", &json!({"id": id.0, "t": 0.75, "left": 4, "right": 4})).unwrap();
let points = profile(&s, id).unwrap();
assert_eq!(points, vec![(0.0, 1.0, 1.0), (0.5, 0.4, 0.6), (0.5, 0.4, 0.6), (0.75, 0.8, 0.8), (1.0, 1.0, 1.0)]);
// Width Point Edit shows a width as its factor times half the weight (5 pt) and gives that back.
// For this factor the round trip changes the last bit.
let dragged = line(&mut s);
let f = 1.8822996672167678;
assert_ne!(f * 5.0 / 5.0, f);
s.execute("stroke.widthProfile.set", &json!({"ids": [dragged.0], "points": [[0, 1, 1], [0.5, f, f], [1, 1, 1]]})).unwrap();
with_a_step_to_redo(&mut s);
let before = history(&s);
assert_eq!((before.1, before.2), (1, false), "a step to redo, and nothing unsaved");
let calls = [
("stroke.widthPoint.set", json!({"id": id.0, "index": 1, "t": 0.5, "left": 2, "right": 3}), json!({"index": 1})),
("stroke.widthPoint.set", json!({"id": id.0, "t": 0.75, "left": 4, "right": 4}), json!({"index": 3})),
// Adjust Adjoining multiplies point 2's 0.4 by 0.8 and divides the product by 0.8, which
// gives 0.4000000000000001.
("stroke.widthPoint.set", json!({"id": id.0, "index": 3, "t": 0.75, "left": 4, "right": 4, "adjustAdjoining": true}), json!({"index": 3})),
("stroke.widthPoint.set", json!({"id": dragged.0, "index": 1, "t": 0.5, "left": f * 5.0, "right": f * 5.0}), json!({"index": 1})),
("stroke.widthPoint.copy", json!({"id": id.0, "index": 2, "t": 0.5}), json!({"index": 2})),
("stroke.widthProfile.set", json!({"ids": [id.0], "points": points}), json!(null)),
("stroke.widthProfile.set", json!({"ids": [uniform.0, group.0], "points": null}), json!(null)),
];
for (c, p, reply) in calls {
assert_eq!(s.execute(c, &p).unwrap(), reply, "{c} {p}");
assert_eq!(history(&s), before, "{c} {p}");
assert_eq!(profile(&s, id).unwrap(), points, "{c} {p}");
assert_eq!(s.journal.last().unwrap().0, c);
}
assert_eq!(profile(&s, dragged).unwrap()[1], (0.5, f, f));
// Other widths are one undo step, and they clear Redo.
s.execute("stroke.widthPoint.set", &json!({"id": id.0, "index": 1, "t": 0.5, "left": 3, "right": 3})).unwrap();
assert_eq!(history(&s), (before.0 + 1, 0, true));
}
// ---------- liquify ----------
fn warp_params() -> serde_json::Value {
json!({"tool": "warp", "points": [[300, 150], [340, 150], [380, 150]], "diameter": 100, "intensity": 100})
}
#[test]
fn liquify_is_deterministic_and_localized() {
let run = || {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0);
let b = rect(&mut s, 500.0, 400.0, 100.0, 100.0);
s.execute("select.all", &json!({})).unwrap();
s.execute("object.liquify", &warp_params()).unwrap();
(path(&s, a), path(&s, b))
};
let (a1, b1) = run();
let (a2, b2) = run();
assert_eq!(a1, a2);
assert_eq!(b1, b2);
// The far rectangle is untouched; the right edge of the first bulged right near y = 150.
assert_eq!(b1.bounds().unwrap(), Rect::new(500.0, 400.0, 600.0, 500.0));
let bb = a1.bounds().unwrap();
assert!(bb.x1 > 320.0 && bb.x0 == 100.0 && bb.y1 == 300.0, "{bb:?}");
assert!(a1.anchor_count() > 4);
}
#[test]
fn liquify_without_selection_hits_paths_under_the_brush() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0);
let b = rect(&mut s, 500.0, 400.0, 100.0, 100.0);
s.execute("select.set", &json!({"ids": []})).unwrap();
let r = s.execute("object.liquify", &json!({"tool": "pucker", "points": [[550, 450]], "diameter": 150, "intensity": 1})).unwrap();
assert_eq!(r["ids"], json!([b.0]));
assert_eq!(path(&s, a).bounds().unwrap(), Rect::new(100.0, 100.0, 300.0, 300.0));
assert!(path(&s, b).bounds().unwrap().width() < 100.0);
}
#[test]
fn liquify_tool_replays_from_the_journal() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0);
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let n = undo_len(&s);
s.select_tool("twirl", ViewInfo::default()).unwrap();
s.set_tool_option("diameter", &json!(120));
gesture(&mut s, "twirl", &[(300.0, 200.0), (302.0, 210.0), (305.0, 220.0), (305.0, 230.0)], Mods::default());
assert_eq!(undo_len(&s), n + 1);
let after = path(&s, a);
assert_ne!(after, path(&session_with_rect(), a));
let (cmd, params) = s.journal.last().unwrap().clone();
assert_eq!(cmd, "object.liquify");
let mut t = session_with_rect();
t.execute("select.set", &json!({"ids": [a.0]})).unwrap();
t.execute(&cmd, &params).unwrap();
assert_eq!(path(&t, a), after);
}
/// A Liquify stroke that reshapes no path (the brush reaches none) records no undo step, run as a
/// command or dragged with the tool, and the command replies with no ids.
#[test]
fn liquify_that_reshapes_no_path_records_no_undo_step() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0);
with_a_step_to_redo(&mut s);
let before = history(&s);
let r = s.execute("object.liquify", &json!({"tool": "warp", "points": [[600, 450], [640, 450]], "diameter": 50})).unwrap();
assert_eq!(r["ids"], json!([]));
assert_eq!(history(&s), before, "object.liquify");
gesture(&mut s, "warp", &[(600.0, 450.0), (620.0, 450.0), (640.0, 450.0)], Mods::default());
assert_eq!(s.journal.last().unwrap().0, "object.liquify");
assert_eq!(history(&s), before, "a Warp tool drag");
assert_eq!(path(&s, a).bounds().unwrap(), Rect::new(100.0, 100.0, 300.0, 300.0));
}
fn session_with_rect() -> Session {
let mut s = session();
rect(&mut s, 100.0, 100.0, 200.0, 200.0);
s
}
// ---------- puppet warp ----------
#[test]
fn puppet_warp_satisfies_pins_and_keeps_area() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 300.0, 100.0);
let n = undo_len(&s);
s.execute("object.puppetWarp", &json!({"id": a.0, "pins": [[120, 150], [250, 150], [380, 150]], "moved": [[120, 150], [250, 150], [370, 90]]}))
.unwrap();
let p = path(&s, a);
let area0 = 300.0 * 100.0;
let area1 = p.to_bezpath().area().abs();
assert!((area1 / area0 - 1.0).abs() < 0.08, "area {area1}");
// The pinned left end stays; the right end rose.
let bb = p.bounds().unwrap();
assert!((bb.x0 - 100.0).abs() < 6.0, "{bb:?}");
assert!(bb.y0 < 80.0, "{bb:?}");
assert_eq!(undo_len(&s), n + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(&s, a).bounds().unwrap(), Rect::new(100.0, 100.0, 400.0, 200.0));
assert!(s.execute("object.puppetWarp", &json!({"id": a.0, "pins": [[1, 1]], "moved": []})).is_err());
}
#[test]
fn puppet_tool_drag_warps_selection() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 300.0, 100.0);
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let v = ViewInfo::default();
s.select_tool("puppetWarp", 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, 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);
}
/// Puppet Warp with the pins the art holds already (as object.puppetWarp.pins lists them), and
/// Puppet Warp with every pin left where it is and turning the art by 0° or free, record no undo
/// step and leave the art as it is: a live shape stays live.
#[test]
fn puppet_warp_that_moves_nothing_records_no_undo_step() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 300.0, 100.0);
let b = rect(&mut s, 100.0, 300.0, 300.0, 100.0);
// The automatic pins, the right end's moved up 30 pt, warp the art in one undo step.
let mut p = s.execute("object.puppetWarp.pins", &json!({"id": a.0})).unwrap();
let moved = p["moved"].as_array().unwrap();
let right = (0..moved.len()).max_by(|&i, &j| moved[i][0].as_f64().unwrap().total_cmp(&moved[j][0].as_f64().unwrap())).unwrap();
let y = p["moved"][right][1].as_f64().unwrap();
p["moved"][right][1] = json!(y - 30.0);
let undo = undo_len(&s);
s.execute("object.puppetWarp", &p).unwrap();
assert_eq!(undo_len(&s), undo + 1);
with_a_step_to_redo(&mut s);
let before = history(&s);
let pins = json!([[120, 350], [250, 350]]);
assert_eq!(s.execute("object.puppetWarp", &json!({"id": b.0, "pins": pins, "moved": pins})).unwrap(), json!({"ids": [b.0]}));
let live = matches!(s.doc().unwrap().doc.node(b).unwrap().kind, NodeKind::Path { live: Some(_), .. });
assert_eq!((history(&s), live), (before, true), "a live shape with its pins left where they are");
let held = s.execute("object.puppetWarp.pins", &json!({"id": a.0})).unwrap();
assert_eq!(held["auto"], json!(false));
let warped = path(&s, a);
let still = json!({"id": a.0, "pins": [[120, 150], [250, 150]], "moved": [[120, 150], [250, 150]], "angles": [null, 0]});
for p in [held, still] {
assert_eq!(s.execute("object.puppetWarp", &p).unwrap(), json!({"ids": [a.0]}), "{p}");
assert_eq!(history(&s), before, "{p}");
assert_eq!(path(&s, a), warped, "{p}");
}
}
// ---------- perspective ----------
fn grid(s: &Session) -> PerspectiveGrid {
PerspectiveGrid::from_doc(&s.doc().unwrap().doc).unwrap()
}
#[test]
fn grid_show_and_plane_are_view_state() {
let mut s = session();
let n = undo_len(&s);
assert_eq!(s.execute("perspective.grid.show", &json!({})).unwrap(), json!({"visible": true}));
s.execute("perspective.plane.set", &json!({"plane": "ground"})).unwrap();
assert_eq!(grid(&s).plane, Plane::Ground);
assert_eq!(undo_len(&s), n);
assert_eq!(s.execute("perspective.grid.show", &json!({})).unwrap(), json!({"visible": false}));
assert!(s.execute("perspective.plane.set", &json!({"plane": "up"})).is_err());
let ov = s.overlays(ViewInfo::default());
assert!(ov.is_empty(), "hidden grid draws nothing with the selection tool");
s.select_tool("perspectiveGrid", ViewInfo::default()).unwrap();
assert!(s.overlays(ViewInfo::default()).len() > 20);
}
#[test]
fn the_grid_hides_while_a_perspective_tool_is_chosen() {
// #321: choosing the Perspective Grid tool showed the grid for good.
let mut s = session();
let v = ViewInfo::default();
for tool in ["perspectiveGrid", "perspectiveSelection"] {
s.select_tool(tool, v).unwrap();
assert!(s.overlays(v).len() > 20, "{tool}");
let n = undo_len(&s);
assert_eq!(s.execute("perspective.grid.show", &json!({})).unwrap(), json!({"visible": false}));
assert!(s.overlays(v).is_empty(), "{tool}: the hidden grid still draws");
assert_eq!(undo_len(&s), n);
// Choosing the tool again shows it again.
s.select_tool(tool, v).unwrap();
assert!(s.overlays(v).len() > 20, "{tool}");
s.execute("perspective.grid.show", &json!({"visible": false})).unwrap();
s.select_tool("selection", v).unwrap();
}
}
#[test]
fn a_hidden_grid_stays_hidden_back_from_a_temporary_tool() {
// Cmd-dragging with a perspective tool borrows the Selection tool; going back to the
// perspective tool is not choosing it, so the grid hidden meanwhile stays hidden.
let mut s = session();
let v = ViewInfo::default();
s.select_tool("perspectiveGrid", v).unwrap();
s.execute("perspective.grid.show", &json!({"visible": false})).unwrap();
s.switch_tool("selection", v).unwrap();
s.switch_tool("perspectiveGrid", v).unwrap();
assert!(s.overlays(v).is_empty());
s.select_tool("selection", v).unwrap();
s.select_tool("perspectiveGrid", v).unwrap();
assert!(s.overlays(v).len() > 20, "choosing it shows the grid");
}
#[test]
fn grid_preset_and_set_are_undoable() {
let mut s = session();
s.execute("perspective.grid.preset", &json!({"kind": 3})).unwrap();
assert_eq!(grid(&s).kind, 3);
s.execute("perspective.grid.set", &json!({"cell": 40, "horizon": 200})).unwrap();
assert_eq!((grid(&s).cell, grid(&s).horizon), (40.0, 200.0));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(grid(&s).kind, 3);
assert_ne!(grid(&s).cell, 40.0);
assert!(s.execute("perspective.grid.set", &json!({"distance": 0})).is_err());
assert!(s.execute("perspective.grid.preset", &json!({"kind": 4})).is_err());
// The grid is saved with the document.
let saved = serde_json::to_value(&*s.doc().unwrap().doc).unwrap();
assert_eq!(saved["unknown"]["perspectiveGrid"]["kind"], json!(3));
}
#[test]
fn attach_projects_onto_the_plane_and_releases() {
let mut s = session();
s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap();
let a = rect(&mut s, 450.0, 300.0, 100.0, 100.0);
s.execute("perspective.attach", &json!({"ids": [a.0], "plane": "right"})).unwrap();
let g = grid(&s);
assert_eq!(g.attached_plane(a), Some(Plane::Right));
// Every anchor lies on the plane: its plane coordinates form an axis-aligned rectangle.
let p = path(&s, a);
let q: Vec<Point> = p.anchors().map(|(_, _, an)| g.to_plane(Plane::Right, an.p).unwrap()).collect();
let xs: Vec<f64> = q.iter().map(|v| v.x).collect();
let ys: Vec<f64> = q.iter().map(|v| v.y).collect();
for v in &q {
assert!(xs.iter().filter(|x| (*x - v.x).abs() < 1e-6).count() == 2 && ys.iter().filter(|y| (*y - v.y).abs() < 1e-6).count() == 2, "{q:?}");
}
// The dragged corners are kept.
assert!(p.anchors().any(|(_, _, an)| close(an.p, Point::new(450.0, 300.0), 1e-6)));
assert!(p.anchors().any(|(_, _, an)| close(an.p, Point::new(550.0, 400.0), 1e-6)));
s.execute("perspective.release", &json!({"ids": [a.0]})).unwrap();
assert_eq!(grid(&s).attached_plane(a), None);
assert_eq!(path(&s, a), p);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(&s, a).bounds().unwrap(), Rect::new(450.0, 300.0, 550.0, 400.0));
}
#[test]
fn rectangle_tool_draws_in_perspective_on_the_active_plane() {
let mut s = session();
s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap();
s.execute("perspective.plane.set", &json!({"plane": "left"})).unwrap();
// Snap to Grid (on by default) would move the dragged corners onto gridlines.
s.execute("perspective.grid.snap", &json!({"on": false})).unwrap();
let n = undo_len(&s);
gesture(&mut s, "rectangle", &[(300.0, 320.0), (330.0, 360.0), (350.0, 400.0)], Mods::default());
assert_eq!(undo_len(&s), n + 1);
let (cmd, params) = s.journal.last().unwrap().clone();
assert_eq!(cmd, "perspective.draw");
assert_eq!(params["command"], json!("shape.rectangle"));
let id = *s.doc().unwrap().selection.objects.last().unwrap();
assert_eq!(grid(&s).attached_plane(id), Some(Plane::Left));
let p = path(&s, id);
assert!(p.anchors().any(|(_, _, an)| close(an.p, Point::new(300.0, 320.0), 1e-6)));
// Not a plain rectangle any more: the top edge slopes toward the left vanishing point.
let ys: Vec<f64> = p.anchors().map(|(_, _, an)| an.p.y).collect();
assert!(ys.iter().any(|y| (y - 320.0).abs() > 0.5 && (y - 400.0).abs() > 0.5), "{ys:?}");
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(id).is_none());
}
#[test]
fn perspective_selection_moves_within_the_plane() {
let mut s = session();
s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap();
let a = rect(&mut s, 450.0, 380.0, 60.0, 60.0);
s.execute("perspective.attach", &json!({"ids": [a.0], "plane": "right"})).unwrap();
// Snap to Grid (on by default) would land the moved edges on gridlines.
s.execute("perspective.grid.snap", &json!({"on": false})).unwrap();
let g = grid(&s);
let before: Vec<Point> = path(&s, a).anchors().map(|(_, _, an)| g.to_plane(Plane::Right, an.p).unwrap()).collect();
let centre = before.iter().fold(vectorcraft_geom::Vec2::ZERO, |a, p| a + p.to_vec2()) / before.len() as f64;
let from = g.to_page(Plane::Right, centre.to_point()).unwrap();
let to = g.to_page(Plane::Right, (centre + vectorcraft_geom::Vec2::new(40.0, 20.0)).to_point()).unwrap();
let n = undo_len(&s);
gesture(&mut s, "perspectiveSelection", &[(from.x, from.y), (to.x, to.y), (to.x, to.y)], Mods::default());
assert_eq!(undo_len(&s), n + 1);
let after: Vec<Point> = path(&s, a).anchors().map(|(_, _, an)| g.to_plane(Plane::Right, an.p).unwrap()).collect();
for (b, c) in before.iter().zip(&after) {
assert!((*c - *b - vectorcraft_geom::Vec2::new(40.0, 20.0)).hypot() < 1e-6, "{b:?} → {c:?}");
}
}
#[test]
fn perspective_move_attaches_loose_objects_to_the_given_plane() {
let mut s = session();
s.execute("perspective.grid.preset", &json!({"kind": 1})).unwrap();
let a = rect(&mut s, 250.0, 380.0, 50.0, 50.0);
s.execute("perspective.move", &json!({"ids": [a.0], "from": [260, 390], "to": [270, 390], "plane": "ground"})).unwrap();
assert_eq!(grid(&s).attached_plane(a), Some(Plane::Ground));
assert!(s.execute("perspective.attach", &json!({"ids": [a.0], "plane": "none"})).is_err());
}
/// With the grid defined, Define Perspective Grid with the values the grid has (Reproject or not),
/// Attach to Active Plane with no object, Release with Perspective on art not in perspective, Move
/// Plane to where the plane is (its objects moved with it or not), and Move Plane to Match Object for
/// an object the active plane is at already record no undo step and reply as before.
#[test]
fn perspective_grid_commands_that_change_nothing_record_no_undo_step() {
let mut s = session();
s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap();
s.execute("perspective.grid.set", &json!({"cell": 40})).unwrap();
s.execute("perspective.plane.set", &json!({"plane": "left"})).unwrap();
let a = rect(&mut s, 300.0, 320.0, 40.0, 40.0);
s.execute("perspective.attach", &json!({"ids": [a.0], "plane": "left"})).unwrap();
let loose = rect(&mut s, 600.0, 100.0, 20.0, 20.0);
with_a_step_to_redo(&mut s);
let before = history(&s);
let definition = grid(&s).definition_json();
let left = |ids: Vec<u64>| json!({"plane": "left", "offset": 0.0, "ids": ids});
let calls = [
("perspective.grid.set", json!({"cell": 40}), definition.clone()),
("perspective.grid.set", json!({"cell": 40, "reproject": true}), definition),
("perspective.attach", json!({"ids": []}), json!({"ids": [], "plane": "left"})),
("perspective.release", json!({"ids": [loose.0]}), json!(null)),
("perspective.plane.move", json!({"by": 0}), left(vec![])),
("perspective.plane.move", json!({"offset": 0, "objects": "move"}), left(vec![a.0])),
("perspective.plane.matchObject", json!({"id": a.0}), json!({"plane": "left", "offset": 0.0})),
];
for (c, p, reply) in calls {
assert_eq!(s.execute(c, &p).unwrap(), reply, "{c} {p}");
assert_eq!(history(&s), before, "{c} {p}");
assert_eq!(s.journal.last().unwrap().0, c);
}
// Moving the plane by 0 with copies of its objects is one undo step.
s.execute("perspective.plane.move", &json!({"by": 0, "objects": "copy"})).unwrap();
assert_eq!(history(&s), (before.0 + 1, 0, true));
}
/// For an object in perspective, Move in Perspective by 0 (along its plane or its normal),
/// Transform in Perspective by the identity, Transform Again after each, and a Perspective Selection
/// drag that ends where it started record no undo step. So do a Move by 0 and a 1 pt Nudge that
/// Snap to Grid takes back to the gridlines the object's edges are on, and Transform Again after
/// each. With Copy, the identity copies the object in one undo step.
#[test]
fn perspective_moves_that_move_nothing_record_no_undo_step() {
let mut s = session();
s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap();
s.execute("perspective.grid.snap", &json!({"on": false})).unwrap();
let a = rect(&mut s, 450.0, 380.0, 60.0, 60.0);
s.execute("perspective.attach", &json!({"ids": [a.0], "plane": "right"})).unwrap();
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
with_a_step_to_redo(&mut s);
let before = history(&s);
let shape = path(&s, a);
let at = json!([480, 410]);
let calls = [
("perspective.move", json!({"from": at, "to": at})),
("object.transformAgain", json!({})),
("perspective.move", json!({"from": at, "to": at, "perpendicular": true})),
("object.transformAgain", json!({})),
("perspective.transform", json!({"matrix": [1, 0, 0, 1, 0, 0]})),
("object.transformAgain", json!({})),
];
for (c, p) in calls {
assert_eq!(s.execute(c, &p).unwrap(), json!({"ids": [a.0]}), "{c} {p}");
assert_eq!((history(&s), path(&s, a)), (before, shape.clone()), "{c} {p}");
assert_eq!(s.journal.last().unwrap().0, c);
}
// Grabbing the object makes its plane the active one (perspective.plane.set), which marks the
// document modified without an undo step.
gesture(&mut s, "perspectiveSelection", &[(480.0, 410.0), (500.0, 420.0), (480.0, 410.0), (480.0, 410.0)], Mods::default());
assert_eq!(s.journal.last().unwrap().0, "perspective.move");
let (undo, redo, _) = history(&s);
assert_eq!(((undo, redo), path(&s, a)), ((before.0, before.1), shape.clone()), "a drag back to its start");
// Snap to Grid takes a move that ends 0.3 pt past a gridline crossing onto the crossing. Then it
// takes a move by 0 and a 1 pt nudge back there, and Transform Again after each moves nothing.
s.execute("perspective.grid.snap", &json!({"on": true})).unwrap();
let g = grid(&s);
let (_, _, b) = g.plane_bounds(&s.doc().unwrap().doc, &[a]).unwrap();
let past = |v: f64| (v / g.cell).ceil() * g.cell + 0.3 - v;
let (c, dv) = (b.center(), vectorcraft_geom::Vec2::new(past(b.x0), past(b.y0)));
let (from, to) = (g.to_page(Plane::Right, c).unwrap(), g.to_page(Plane::Right, c + dv).unwrap());
s.execute("perspective.move", &json!({"from": [from.x, from.y], "to": [to.x, to.y]})).unwrap();
with_a_step_to_redo(&mut s);
let before = history(&s);
let on_grid = path(&s, a);
let calls = [
("perspective.move", json!({"from": [to.x, to.y], "to": [to.x, to.y]})),
("object.transformAgain", json!({})),
("perspective.nudge", json!({"dx": 1, "dy": 0})),
("object.transformAgain", json!({})),
];
for (c, p) in calls {
assert_eq!(s.execute(c, &p).unwrap(), json!({"ids": [a.0]}), "{c} {p}");
assert_eq!((history(&s), path(&s, a)), (before, on_grid.clone()), "{c} {p}");
}
// The identity with Copy copies the object in one undo step.
let r = s.execute("perspective.transform", &json!({"matrix": [1, 0, 0, 1, 0, 0], "copy": true})).unwrap();
assert_ne!(r, json!({"ids": [a.0]}));
assert_eq!(history(&s), (before.0 + 1, 0, true));
}