mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 05:22:22 +00:00
With Snap to Grid on, a move of objects on one perspective plane at different depths applied the snap correction only to the objects at the first object's depth, and every other object moved by the pointer's offset read at its own depth. Those objects left the grid and lost their spacing. Every object on the first object's plane now moves by that object's plane offset plus the correction, so the edge of the joint bounds nearer a gridline lands on it and the objects keep their spacing. A selection at one depth snaps as before, and objects on other planes keep their own offset.
582 lines
29 KiB
Rust
582 lines
29 KiB
Rust
//! Perspective Selection tool: scaling in perspective, Alt-drag copies, moving perpendicular to the
|
||
//! plane (5), Transform Again and arrow nudges in perspective.
|
||
|
||
use serde_json::json;
|
||
use vectorcraft_geom::{Point, Rect, Vec2};
|
||
use vectorcraft_tools::distort::perspective::{self as persp, PerspectiveGrid, Plane};
|
||
use vectorcraft_tools::{Cursor, Mods, PointerEvent, PointerKind, ToolKey};
|
||
|
||
use crate::*;
|
||
|
||
/// An 800 × 600 document with a two-point grid and a rectangle attached to the right plane.
|
||
pub(crate) fn persp_session() -> (Session, NodeId) {
|
||
let mut s = Session::new();
|
||
// Separately created fixtures must compare equal across clock ticks.
|
||
s.execute("file.new", &json!({"width": 800, "height": 600, "created": null})).unwrap();
|
||
s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap();
|
||
// Snap to Grid (on by default) would land moved edges on gridlines: these tests measure exact moves.
|
||
s.execute("perspective.grid.snap", &json!({"on": false})).unwrap();
|
||
let r = s.execute("shape.rectangle", &json!({"x": 450, "y": 380, "width": 60, "height": 60})).unwrap();
|
||
let id = NodeId(r["id"].as_u64().unwrap());
|
||
s.execute("perspective.attach", &json!({"ids": [id.0], "plane": "right"})).unwrap();
|
||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||
(s, id)
|
||
}
|
||
|
||
pub(crate) fn grid(s: &Session) -> PerspectiveGrid {
|
||
PerspectiveGrid::from_doc(&s.doc().unwrap().doc).unwrap()
|
||
}
|
||
|
||
pub(crate) fn undo_len(s: &Session) -> usize {
|
||
s.doc().unwrap().history.undo.len()
|
||
}
|
||
|
||
/// Where `id` is attached (plane, depth).
|
||
pub(crate) fn attached(s: &Session, id: NodeId) -> Option<(Plane, f64)> {
|
||
grid(s).attachment_of(&s.doc().unwrap().doc, id)
|
||
}
|
||
|
||
/// The anchors of path `id` in the plane coordinates of `plane` at `depth`.
|
||
pub(crate) fn plane_pts(s: &Session, id: NodeId, plane: Plane, depth: f64) -> Vec<Point> {
|
||
let g = grid(s);
|
||
let hi = g.homography_at(plane, depth).unwrap().inverse().unwrap();
|
||
s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().anchors().map(|(_, _, a)| hi.apply(a.p).unwrap()).collect()
|
||
}
|
||
|
||
pub(crate) fn bounds_of(pts: &[Point]) -> Rect {
|
||
pts.iter().skip(1).fold(Rect::from_points(pts[0], pts[0]), |r, p| r.union_pt(*p))
|
||
}
|
||
|
||
/// Pointer events with the active tool: down at the first point, drags, up at the last.
|
||
pub(crate) fn drag(s: &mut Session, pts: &[Point], mods: Mods) {
|
||
let v = ViewInfo::default();
|
||
for (i, p) 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, p.x, p.y).with_mods(mods), v).unwrap();
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn handles_scale_in_perspective_about_the_opposite_handle() {
|
||
let (mut s, id) = persp_session();
|
||
s.select_tool("perspectiveSelection", ViewInfo::default()).unwrap();
|
||
let g = grid(&s);
|
||
let before = bounds_of(&plane_pts(&s, id, Plane::Right, 0.0));
|
||
// The box's handles are drawn in perspective: the far corner of the plane-space box.
|
||
let h = g.homography(Plane::Right).unwrap();
|
||
let corner = h.apply(Point::new(before.x1, before.y1)).unwrap();
|
||
let anchors = s.overlays(ViewInfo::default()).into_iter().filter(|o| matches!(o, vectorcraft_tools::Overlay::Anchor { .. })).count();
|
||
assert!(anchors >= 8, "{anchors} handles");
|
||
// Drag that handle to twice the size in plane space.
|
||
let target = h.apply(Point::new(before.x0 + 2.0 * before.width(), before.y0 + 2.0 * before.height())).unwrap();
|
||
let n = undo_len(&s);
|
||
drag(&mut s, &[corner, target, target], Mods::default());
|
||
assert_eq!(undo_len(&s), n + 1, "one undo step");
|
||
assert_eq!(s.journal.last().unwrap().0, "perspective.transform");
|
||
let after = bounds_of(&plane_pts(&s, id, Plane::Right, 0.0));
|
||
assert!((after.x0 - before.x0).abs() < 1e-6 && (after.y0 - before.y0).abs() < 1e-6, "the opposite corner stays: {before:?} → {after:?}");
|
||
assert!((after.width() - 2.0 * before.width()).abs() < 1e-6 && (after.height() - 2.0 * before.height()).abs() < 1e-6, "{after:?}");
|
||
// Still a rectangle on the plane.
|
||
for q in plane_pts(&s, id, Plane::Right, 0.0) {
|
||
assert!(((q.x - after.x0).abs() < 1e-6 || (q.x - after.x1).abs() < 1e-6) && ((q.y - after.y0).abs() < 1e-6 || (q.y - after.y1).abs() < 1e-6));
|
||
}
|
||
assert_eq!(attached(&s, id), Some((Plane::Right, 0.0)));
|
||
}
|
||
|
||
#[test]
|
||
fn alt_drag_copies_and_the_copy_stays_attached() {
|
||
let (mut s, id) = persp_session();
|
||
s.select_tool("perspectiveSelection", ViewInfo::default()).unwrap();
|
||
let before = plane_pts(&s, id, Plane::Right, 0.0);
|
||
let c = bounds_of(&before).center();
|
||
let h = grid(&s).homography(Plane::Right).unwrap();
|
||
let (from, to) = (h.apply(c).unwrap(), h.apply(c + Vec2::new(80.0, 0.0)).unwrap());
|
||
let n = undo_len(&s);
|
||
drag(&mut s, &[from, to, to], Mods { alt: true, ..Default::default() });
|
||
assert_eq!(undo_len(&s), n + 1);
|
||
let copy = *s.doc().unwrap().selection.objects.first().unwrap();
|
||
assert_ne!(copy, id, "the copy is selected");
|
||
assert_eq!(plane_pts(&s, id, Plane::Right, 0.0), before, "the original stays");
|
||
assert_eq!(attached(&s, copy), Some((Plane::Right, 0.0)), "the copy is attached");
|
||
for (a, b) in before.iter().zip(plane_pts(&s, copy, Plane::Right, 0.0)) {
|
||
assert!((b - *a - Vec2::new(80.0, 0.0)).hypot() < 1e-6);
|
||
}
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert!(s.doc().unwrap().doc.node(copy).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn shift_drag_moves_on_a_plane_axis() {
|
||
let (mut s, id) = persp_session();
|
||
s.select_tool("perspectiveSelection", ViewInfo::default()).unwrap();
|
||
let before = plane_pts(&s, id, Plane::Right, 0.0);
|
||
let h = grid(&s).homography(Plane::Right).unwrap();
|
||
let start = bounds_of(&before).center();
|
||
let (from, to) = (h.apply(start).unwrap(), h.apply(start + Vec2::new(80.0, 20.0)).unwrap());
|
||
let n = undo_len(&s);
|
||
drag(&mut s, &[from, to, to], Mods { shift: true, ..Default::default() });
|
||
assert_eq!(undo_len(&s), n + 1);
|
||
for (a, b) in before.iter().zip(plane_pts(&s, id, Plane::Right, 0.0)) {
|
||
assert!((b - *a - Vec2::new(80.0, 0.0)).hypot() < 1e-6, "shift move: {a:?} → {b:?}");
|
||
}
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(plane_pts(&s, id, Plane::Right, 0.0), before);
|
||
}
|
||
|
||
/// An attached rectangle with a horizontal edge close enough to a gridline to be pulled by snapping.
|
||
fn near_gridline_session() -> (Session, NodeId) {
|
||
let (mut s, id) = persp_session();
|
||
let g = grid(&s);
|
||
let b = bounds_of(&plane_pts(&s, id, Plane::Right, 0.0));
|
||
let dy = (b.y0 / g.cell).round() * g.cell + g.cell * 0.1 - b.y0;
|
||
let h = g.homography(Plane::Right).unwrap();
|
||
let from = h.apply(b.center()).unwrap();
|
||
let to = h.apply(b.center() + Vec2::new(0.0, dy)).unwrap();
|
||
s.execute("perspective.move", &json!({"ids": [id.0], "from": [from.x, from.y], "to": [to.x, to.y], "snap": false})).unwrap();
|
||
s.execute("perspective.grid.snap", &json!({"on": true})).unwrap();
|
||
(s, id)
|
||
}
|
||
|
||
fn shift_move_near_gridline(diagonal: bool, copy: bool) {
|
||
let (mut s, id) = near_gridline_session();
|
||
s.select_tool("perspectiveSelection", ViewInfo::default()).unwrap();
|
||
let before = plane_pts(&s, id, Plane::Right, 0.0);
|
||
let h = grid(&s).homography(Plane::Right).unwrap();
|
||
let start = bounds_of(&before).center();
|
||
let raw = if diagonal { Vec2::new(40.0, 50.0) } else { Vec2::new(80.0, 20.0) };
|
||
let from = h.apply(start).unwrap();
|
||
let to = h.apply(start + raw).unwrap();
|
||
drag(&mut s, &[from, to, to], Mods { shift: true, alt: copy, ..Default::default() });
|
||
let target = if copy { *s.doc().unwrap().selection.objects.first().unwrap() } else { id };
|
||
assert!(!copy || target != id);
|
||
let after = plane_pts(&s, target, Plane::Right, 0.0);
|
||
for (a, b) in before.iter().zip(after) {
|
||
let d = b - *a;
|
||
if diagonal {
|
||
assert!((d.x - d.y).abs() < 1e-6, "diagonal move left its line: {d:?}");
|
||
} else {
|
||
assert!(d.y.abs() < 1e-6, "axis move left its line: {d:?}");
|
||
}
|
||
}
|
||
if copy {
|
||
assert_eq!(plane_pts(&s, id, Plane::Right, 0.0), before);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn shift_axis_move_stays_on_axis_with_grid_snap() {
|
||
shift_move_near_gridline(false, false);
|
||
}
|
||
|
||
#[test]
|
||
fn shift_axis_copy_stays_on_axis_with_grid_snap() {
|
||
shift_move_near_gridline(false, true);
|
||
}
|
||
|
||
#[test]
|
||
fn shift_diagonal_move_stays_on_diagonal_with_grid_snap() {
|
||
shift_move_near_gridline(true, false);
|
||
}
|
||
|
||
#[test]
|
||
fn shift_diagonal_copy_stays_on_diagonal_with_grid_snap() {
|
||
shift_move_near_gridline(true, true);
|
||
}
|
||
|
||
#[test]
|
||
fn constrained_move_command_stays_on_axis_with_grid_snap() {
|
||
let (mut s, id) = near_gridline_session();
|
||
let before = plane_pts(&s, id, Plane::Right, 0.0);
|
||
let h = grid(&s).homography(Plane::Right).unwrap();
|
||
let start = bounds_of(&before).center();
|
||
let from = h.apply(start).unwrap();
|
||
let to = h.apply(start + Vec2::new(80.0, 0.0)).unwrap();
|
||
s.execute("perspective.move", &json!({"ids": [id.0], "from": [from.x, from.y], "to": [to.x, to.y], "constrain": true})).unwrap();
|
||
for (a, b) in before.iter().zip(plane_pts(&s, id, Plane::Right, 0.0)) {
|
||
assert!((b.y - a.y).abs() < 1e-6, "constrained command left axis: {a:?} to {b:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn diagonal_shift_snap_lands_an_edge_on_a_gridline() {
|
||
let (mut s, id) = near_gridline_session();
|
||
let g = grid(&s);
|
||
let before = plane_pts(&s, id, Plane::Right, 0.0);
|
||
let b = bounds_of(&before);
|
||
let (raw, correction) = (1..200)
|
||
.map(|n| {
|
||
let raw = Vec2::new(n as f64 * g.cell * 0.05, n as f64 * g.cell * 0.05);
|
||
(raw, g.snap_offset(b, raw) - raw)
|
||
})
|
||
.find(|(_, fix)| fix.x.abs() < 1e-9 && fix.y.abs() > 1e-6)
|
||
.unwrap();
|
||
let h = g.homography(Plane::Right).unwrap();
|
||
let start = b.center();
|
||
let from = h.apply(start).unwrap();
|
||
let to = h.apply(start + raw).unwrap();
|
||
s.execute("perspective.move", &json!({"ids": [id.0], "from": [from.x, from.y], "to": [to.x, to.y], "constrain": true})).unwrap();
|
||
let after = plane_pts(&s, id, Plane::Right, 0.0);
|
||
let delta = after[0] - before[0];
|
||
assert!((delta.x - delta.y).abs() < 1e-6, "{delta:?}");
|
||
let edge = [b.y0 + delta.y, b.y1 + delta.y];
|
||
assert!(edge.iter().any(|y| ((y / g.cell).round() * g.cell - y).abs() < 1e-6), "{edge:?}, correction {correction:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn snap_moves_objects_at_different_depths_by_one_plane_offset() {
|
||
let (mut s, _) = persp_session();
|
||
let rect = |s: &mut Session, x: f64| {
|
||
NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 380, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap())
|
||
};
|
||
let near = rect(&mut s, 240.0);
|
||
s.execute("perspective.attach", &json!({"ids": [near.0], "plane": "left"})).unwrap();
|
||
s.execute("perspective.plane.move", &json!({"plane": "left", "offset": 50})).unwrap();
|
||
let far = rect(&mut s, 150.0);
|
||
s.execute("perspective.attach", &json!({"ids": [far.0], "plane": "left"})).unwrap();
|
||
s.execute("perspective.plane.move", &json!({"plane": "left", "offset": 0})).unwrap();
|
||
assert_eq!(attached(&s, near), Some((Plane::Left, 0.0)));
|
||
assert_eq!(attached(&s, far), Some((Plane::Left, 50.0)));
|
||
s.execute("perspective.grid.snap", &json!({"on": true})).unwrap();
|
||
let g = grid(&s);
|
||
let (near_before, far_before) = (plane_pts(&s, near, Plane::Left, 0.0), plane_pts(&s, far, Plane::Left, 50.0));
|
||
let b = bounds_of(&near_before).union(bounds_of(&far_before));
|
||
// A move that ends 0.3 pt past a gridline crossing on the near object's plane.
|
||
let past = |v: f64| (v / g.cell).ceil() * g.cell + 0.3 - v;
|
||
let start = bounds_of(&near_before).center();
|
||
let h = g.homography_at(Plane::Left, 0.0).unwrap();
|
||
let from = h.apply(start).unwrap();
|
||
let to = h.apply(start + Vec2::new(past(b.x0), past(b.y0))).unwrap();
|
||
s.execute("perspective.move", &json!({"ids": [near.0, far.0], "from": [from.x, from.y], "to": [to.x, to.y]})).unwrap();
|
||
let near_delta = plane_pts(&s, near, Plane::Left, 0.0)[0] - near_before[0];
|
||
let far_delta = plane_pts(&s, far, Plane::Left, 50.0)[0] - far_before[0];
|
||
assert!((near_delta - far_delta).hypot() < 1e-6, "near {near_delta:?}, far {far_delta:?}");
|
||
let on_line = |v: f64| ((v / g.cell).round() * g.cell - v).abs() < 1e-6;
|
||
let moved = b + near_delta;
|
||
assert!(on_line(moved.x0) || on_line(moved.x1), "{moved:?}");
|
||
assert!(on_line(moved.y0) || on_line(moved.y1), "{moved:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn constrained_move_uses_each_objects_plane() {
|
||
let (mut s, right) = persp_session();
|
||
let left_result = s.execute("shape.rectangle", &json!({"x": 240, "y": 380, "width": 60, "height": 60})).unwrap();
|
||
let left = NodeId(left_result["id"].as_u64().unwrap());
|
||
s.execute("perspective.attach", &json!({"ids": [left.0], "plane": "left"})).unwrap();
|
||
let right_before = plane_pts(&s, right, Plane::Right, 0.0);
|
||
let left_before = plane_pts(&s, left, Plane::Left, 0.0);
|
||
let from = grid(&s).homography(Plane::Right).unwrap().apply(bounds_of(&right_before).center()).unwrap();
|
||
let to = from + Vec2::new(70.0, 30.0);
|
||
let g = grid(&s);
|
||
for plane in [Plane::Right, Plane::Left] {
|
||
let raw = g.plane_delta(plane, 0.0, from, to).unwrap();
|
||
assert!(raw.x.abs() > 1e-3 && raw.y.abs() > 1e-3 && (raw.x.abs() - raw.y.abs()).abs() > 1e-3, "raw {plane:?}: {raw:?}");
|
||
}
|
||
s.execute("perspective.move", &json!({"ids": [right.0, left.0], "from": [from.x, from.y], "to": [to.x, to.y], "constrain": true, "snap": false}))
|
||
.unwrap();
|
||
for (id, plane, before) in [(right, Plane::Right, right_before), (left, Plane::Left, left_before)] {
|
||
let after = plane_pts(&s, id, plane, 0.0);
|
||
let delta = after[0] - before[0];
|
||
assert!(delta.x.abs() < 1e-6 || delta.y.abs() < 1e-6 || (delta.x.abs() - delta.y.abs()).abs() < 1e-6, "{plane:?}: {delta:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn constrained_move_rejects_non_boolean_switch() {
|
||
let (mut s, id) = near_gridline_session();
|
||
for direction in [json!([0.0, 0.0]), json!([1.0]), json!(1), json!("true")] {
|
||
let error = s.execute("perspective.move", &json!({"ids": [id.0], "from": [1.0, 1.0], "to": [2.0, 2.0], "constrain": direction})).unwrap_err();
|
||
assert!(error.to_string().contains("`constrain` must be true or false"), "{error}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn shift_drag_keeps_its_displacement_after_a_plane_key() {
|
||
let (mut s, id) = persp_session();
|
||
let v = ViewInfo::default();
|
||
s.select_tool("perspectiveSelection", v).unwrap();
|
||
let before = plane_pts(&s, id, Plane::Right, 0.0);
|
||
let h = grid(&s).homography(Plane::Right).unwrap();
|
||
let start = bounds_of(&before).center();
|
||
let (from, to) = (h.apply(start).unwrap(), h.apply(start + Vec2::new(80.0, 20.0)).unwrap());
|
||
let shift = Mods { shift: true, ..Default::default() };
|
||
s.pointer(&PointerEvent::new(PointerKind::Down, from.x, from.y), v).unwrap();
|
||
s.pointer(&PointerEvent::new(PointerKind::Drag, to.x, to.y), v).unwrap();
|
||
s.tool_key(ToolKey::Digit(2), shift, v).unwrap();
|
||
assert_eq!(grid(&s).plane, Plane::Ground);
|
||
s.pointer(&PointerEvent::new(PointerKind::Up, to.x, to.y).with_mods(shift), v).unwrap();
|
||
for (a, b) in before.iter().zip(plane_pts(&s, id, Plane::Right, 0.0)) {
|
||
assert!((b - *a - Vec2::new(80.0, 0.0)).hypot() < 1e-6, "plane key: {a:?} → {b:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn loose_art_move_survives_clearing_the_active_plane() {
|
||
for (drag_after_key, switch_before_drag) in [(false, false), (true, false), (false, true)] {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 800, "height": 600, "created": null})).unwrap();
|
||
s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap();
|
||
s.execute("perspective.grid.snap", &json!({"on": false})).unwrap();
|
||
s.execute("perspective.plane.set", &json!({"plane": "right"})).unwrap();
|
||
let result = s.execute("shape.rectangle", &json!({"x": 450, "y": 380, "width": 60, "height": 60})).unwrap();
|
||
let id = NodeId(result["id"].as_u64().unwrap());
|
||
assert_eq!(attached(&s, id), None);
|
||
let before = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap();
|
||
let (from, to) = (before.center(), before.center() + Vec2::new(30.0, 15.0));
|
||
let v = ViewInfo::default();
|
||
s.select_tool("perspectiveSelection", v).unwrap();
|
||
let undo_before = undo_len(&s);
|
||
s.pointer(&PointerEvent::new(PointerKind::Down, from.x, from.y), v).unwrap();
|
||
if switch_before_drag {
|
||
s.tool_key(ToolKey::Digit(2), Mods::default(), v).unwrap();
|
||
}
|
||
s.pointer(&PointerEvent::new(PointerKind::Drag, to.x, to.y), v).unwrap();
|
||
s.tool_key(ToolKey::Digit(4), Mods::default(), v).unwrap();
|
||
assert_eq!(grid(&s).plane, Plane::None);
|
||
if drag_after_key {
|
||
s.pointer(&PointerEvent::new(PointerKind::Drag, to.x + 10.0, to.y), v).unwrap();
|
||
}
|
||
s.pointer(&PointerEvent::new(PointerKind::Up, to.x, to.y), v).unwrap();
|
||
assert_eq!(attached(&s, id), Some((if switch_before_drag { Plane::Ground } else { Plane::Right }, 0.0)));
|
||
assert_ne!(s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap(), before);
|
||
assert_eq!(undo_len(&s), undo_before + 1);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn cmd_alt_shift_drag_copies_on_a_plane_axis() {
|
||
let (mut s, id) = persp_session();
|
||
let v = ViewInfo::default();
|
||
s.select_tool("directSelection", v).unwrap();
|
||
s.select_tool("perspectiveSelection", v).unwrap();
|
||
let before = plane_pts(&s, id, Plane::Right, 0.0);
|
||
let count = s.doc().unwrap().doc.node_count();
|
||
let h = grid(&s).homography(Plane::Right).unwrap();
|
||
let start = bounds_of(&before).center();
|
||
let (from, to) = (h.apply(start).unwrap(), h.apply(start + Vec2::new(80.0, 20.0)).unwrap());
|
||
let n = undo_len(&s);
|
||
drag(&mut s, &[from, to, to], Mods { cmd: true, alt: true, shift: true, ..Default::default() });
|
||
assert_eq!(undo_len(&s), n + 1);
|
||
assert_eq!(s.doc().unwrap().doc.node_count(), count + 1);
|
||
let copy = *s.doc().unwrap().selection.objects.first().unwrap();
|
||
assert_ne!(copy, id);
|
||
assert_eq!(s.doc().unwrap().selection.objects, vec![copy]);
|
||
assert_eq!(plane_pts(&s, id, Plane::Right, 0.0), before);
|
||
assert_eq!(attached(&s, copy), Some((Plane::Right, 0.0)));
|
||
for (a, b) in before.iter().zip(plane_pts(&s, copy, Plane::Right, 0.0)) {
|
||
assert!((b - *a - Vec2::new(80.0, 0.0)).hypot() < 1e-6, "shift copy: {a:?} → {b:?}");
|
||
}
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(s.doc().unwrap().doc.node_count(), count);
|
||
assert_eq!(plane_pts(&s, id, Plane::Right, 0.0), before);
|
||
assert_eq!(s.doc().unwrap().selection.objects, vec![id]);
|
||
}
|
||
|
||
#[test]
|
||
fn cmd_alt_drag_copies_on_the_perspective_plane() {
|
||
let (mut s, id) = persp_session();
|
||
let v = ViewInfo::default();
|
||
s.select_tool("directSelection", v).unwrap();
|
||
s.select_tool("perspectiveSelection", v).unwrap();
|
||
let before = plane_pts(&s, id, Plane::Right, 0.0);
|
||
let count = s.doc().unwrap().doc.node_count();
|
||
let c = bounds_of(&before).center();
|
||
let h = grid(&s).homography(Plane::Right).unwrap();
|
||
let (from, to) = (h.apply(c).unwrap(), h.apply(c + Vec2::new(80.0, 0.0)).unwrap());
|
||
let n = undo_len(&s);
|
||
drag(&mut s, &[from, to, to], Mods { cmd: true, alt: true, ..Default::default() });
|
||
assert_eq!(undo_len(&s), n + 1, "one undo step");
|
||
assert_eq!(s.doc().unwrap().doc.node_count(), count + 1, "one copy");
|
||
assert_eq!(s.doc().unwrap().selection.objects.len(), 1);
|
||
let copy = s.doc().unwrap().selection.objects[0];
|
||
assert_ne!(copy, id, "the copy is selected");
|
||
assert_eq!(plane_pts(&s, id, Plane::Right, 0.0), before, "the original stays");
|
||
assert_eq!(attached(&s, copy), Some((Plane::Right, 0.0)), "the copy is attached");
|
||
for (a, b) in before.iter().zip(plane_pts(&s, copy, Plane::Right, 0.0)) {
|
||
assert!((b - *a - Vec2::new(80.0, 0.0)).hypot() < 1e-6);
|
||
}
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(s.doc().unwrap().doc.node_count(), count);
|
||
assert_eq!(plane_pts(&s, id, Plane::Right, 0.0), before);
|
||
assert_eq!(s.doc().unwrap().selection.objects, vec![id]);
|
||
}
|
||
|
||
#[test]
|
||
fn cmd_drag_still_lends_selection_from_perspective_selection() {
|
||
let (mut s, id) = persp_session();
|
||
let v = ViewInfo::default();
|
||
s.select_tool("directSelection", v).unwrap();
|
||
s.select_tool("perspectiveSelection", v).unwrap();
|
||
let p = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().center();
|
||
let cmd = Mods { cmd: true, ..Default::default() };
|
||
s.pointer(&PointerEvent::new(PointerKind::Down, p.x, p.y).with_mods(cmd), v).unwrap();
|
||
assert_eq!(s.tool_id(), "directSelection");
|
||
s.pointer(&PointerEvent::new(PointerKind::Up, p.x, p.y).with_mods(cmd), v).unwrap();
|
||
assert_eq!(s.tool_id(), "perspectiveSelection");
|
||
let cmd_alt = Mods { cmd: true, alt: true, ..Default::default() };
|
||
s.pointer(&PointerEvent::new(PointerKind::Down, p.x, p.y).with_mods(cmd_alt), v).unwrap();
|
||
assert_eq!(s.tool_id(), "perspectiveSelection");
|
||
s.pointer(&PointerEvent::new(PointerKind::Up, p.x, p.y).with_mods(cmd_alt), v).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn cmd_alt_cursor_stays_with_perspective_selection() {
|
||
let (mut s, id) = persp_session();
|
||
let v = ViewInfo::default();
|
||
s.select_tool("directSelection", v).unwrap();
|
||
s.select_tool("perspectiveSelection", v).unwrap();
|
||
let p = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().center();
|
||
let cmd_alt = Mods { cmd: true, alt: true, ..Default::default() };
|
||
assert_eq!(s.cursor(p, cmd_alt, v), Cursor::Arrow);
|
||
assert_eq!(s.tool_id(), "perspectiveSelection");
|
||
}
|
||
|
||
#[test]
|
||
fn pressing_5_while_dragging_moves_perpendicular_to_the_plane() {
|
||
let (mut s, id) = persp_session();
|
||
s.select_tool("perspectiveSelection", ViewInfo::default()).unwrap();
|
||
let before = plane_pts(&s, id, Plane::Right, 0.0);
|
||
let c = bounds_of(&before).center();
|
||
let g = grid(&s);
|
||
let from = g.homography(Plane::Right).unwrap().apply(c).unwrap();
|
||
// The point 40 pt in front of the plane (along its normal, toward the viewer's left wall side).
|
||
let to = g.homography_at(Plane::Right, 40.0).unwrap().apply(c).unwrap();
|
||
let v = ViewInfo::default();
|
||
let n = undo_len(&s);
|
||
s.pointer(&PointerEvent::new(PointerKind::Down, from.x, from.y), v).unwrap();
|
||
s.pointer(&PointerEvent::new(PointerKind::Drag, to.x, to.y), v).unwrap();
|
||
s.tool_key(ToolKey::Digit(5), Mods::default(), v).unwrap();
|
||
s.pointer(&PointerEvent::new(PointerKind::Up, to.x, to.y), v).unwrap();
|
||
assert_eq!(undo_len(&s), n + 1);
|
||
let (plane, depth) = attached(&s, id).unwrap();
|
||
assert_eq!(plane, Plane::Right);
|
||
assert!((depth - 40.0).abs() < 1e-6, "{depth}");
|
||
// Same place within the plane, on the parallel plane 40 pt along the normal.
|
||
for (a, b) in before.iter().zip(plane_pts(&s, id, Plane::Right, 40.0)) {
|
||
assert!((*a - b).hypot() < 1e-6, "{a:?} {b:?}");
|
||
}
|
||
// Pressing 5 again goes back to moving within the plane.
|
||
let c2 = g.homography_at(Plane::Right, 40.0).unwrap().apply(c).unwrap();
|
||
let to2 = g.homography_at(Plane::Right, 40.0).unwrap().apply(c + Vec2::new(30.0, 0.0)).unwrap();
|
||
s.pointer(&PointerEvent::new(PointerKind::Down, c2.x, c2.y), v).unwrap();
|
||
s.tool_key(ToolKey::Digit(5), Mods::default(), v).unwrap();
|
||
s.tool_key(ToolKey::Digit(5), Mods::default(), v).unwrap();
|
||
s.pointer(&PointerEvent::new(PointerKind::Drag, to2.x, to2.y), v).unwrap();
|
||
s.pointer(&PointerEvent::new(PointerKind::Up, to2.x, to2.y), v).unwrap();
|
||
assert!((attached(&s, id).unwrap().1 - 40.0).abs() < 1e-6);
|
||
for (a, b) in before.iter().zip(plane_pts(&s, id, Plane::Right, 40.0)) {
|
||
assert!((b - *a - Vec2::new(30.0, 0.0)).hypot() < 1e-6);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn transform_again_repeats_the_last_perspective_move() {
|
||
let (mut s, id) = persp_session();
|
||
s.select_tool("perspectiveSelection", ViewInfo::default()).unwrap();
|
||
let before = plane_pts(&s, id, Plane::Right, 0.0);
|
||
let c = bounds_of(&before).center();
|
||
let h = grid(&s).homography(Plane::Right).unwrap();
|
||
let (from, to) = (h.apply(c).unwrap(), h.apply(c + Vec2::new(25.0, 10.0)).unwrap());
|
||
drag(&mut s, &[from, to, to], Mods::default());
|
||
// Twice the plane-space move, not twice the page move (which foreshortening would distort).
|
||
s.execute("object.transformAgain", &json!({})).unwrap();
|
||
for (a, b) in before.iter().zip(plane_pts(&s, id, Plane::Right, 0.0)) {
|
||
assert!((b - *a - Vec2::new(50.0, 20.0)).hypot() < 1e-6, "{a:?} → {b:?}");
|
||
}
|
||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Transform in Perspective");
|
||
// An ordinary move takes over Transform Again.
|
||
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
|
||
let p0 = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap();
|
||
s.execute("object.transformAgain", &json!({})).unwrap();
|
||
let p1 = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap();
|
||
assert!((p1.x0 - p0.x0 - 5.0).abs() < 1e-9 && (p1.y0 - p0.y0).abs() < 1e-9);
|
||
// A cancelled drag records nothing.
|
||
s.execute("perspective.transform", &json!({"matrix": [1, 0, 0, 1, 7, 0]})).unwrap();
|
||
let v = ViewInfo::default();
|
||
let c = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().center();
|
||
s.pointer(&PointerEvent::new(PointerKind::Down, c.x, c.y), v).unwrap();
|
||
s.pointer(&PointerEvent::new(PointerKind::Drag, c.x + 20.0, c.y), v).unwrap();
|
||
assert!(s.in_interaction());
|
||
s.cancel_interaction().unwrap();
|
||
assert_eq!(s.doc().unwrap().last_perspective.as_ref().unwrap()["matrix"][4], json!(7.0));
|
||
}
|
||
|
||
#[test]
|
||
fn transform_again_repeats_a_perspective_scale_and_copies() {
|
||
let (mut s, id) = persp_session();
|
||
let before = bounds_of(&plane_pts(&s, id, Plane::Right, 0.0));
|
||
let r = s.execute("perspective.transform", &json!({"matrix": [1, 0, 0, 1, 100, 0], "copy": true})).unwrap();
|
||
let copy = NodeId(r["ids"][0].as_u64().unwrap());
|
||
s.execute("object.transformAgain", &json!({})).unwrap();
|
||
let third = *s.doc().unwrap().selection.objects.first().unwrap();
|
||
assert!(third != copy && third != id);
|
||
let b = bounds_of(&plane_pts(&s, third, Plane::Right, 0.0));
|
||
assert!((b.x0 - before.x0 - 200.0).abs() < 1e-6, "{before:?} {b:?}");
|
||
assert_eq!(attached(&s, third), Some((Plane::Right, 0.0)));
|
||
assert!(s.execute("perspective.transform", &json!({"matrix": [0, 0, 0, 0, 0, 0]})).is_err());
|
||
assert!(s.execute("perspective.transform", &json!({"matrix": [1, 0, 0, 1, 0, 0], "depth": f64::MAX})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn arrow_keys_nudge_in_perspective() {
|
||
let (mut s, id) = persp_session();
|
||
let v = ViewInfo::default();
|
||
s.select_tool("perspectiveSelection", v).unwrap();
|
||
assert!(s.tool_claims_key(ToolKey::Right, v), "the tool takes the arrows for a selection in perspective");
|
||
let before = plane_pts(&s, id, Plane::Right, 0.0);
|
||
let n = undo_len(&s);
|
||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||
assert_eq!(undo_len(&s), n + 1);
|
||
let after = plane_pts(&s, id, Plane::Right, 0.0);
|
||
let d = after[0] - before[0];
|
||
for (a, b) in before.iter().zip(&after) {
|
||
assert!((*b - *a - d).hypot() < 1e-6, "moves within the plane");
|
||
}
|
||
// The page step at the centre is the keyboard increment.
|
||
let h = grid(&s).homography(Plane::Right).unwrap();
|
||
let c = bounds_of(&before).center();
|
||
let step = h.apply(c + d).unwrap() - h.apply(c).unwrap();
|
||
assert!((step - Vec2::new(s.prefs.keyboard_increment, 0.0)).hypot() < 1e-6, "{step:?}");
|
||
// Alt copies; Shift takes ten steps.
|
||
s.tool_key(ToolKey::Left, Mods { alt: true, shift: true, ..Default::default() }, v).unwrap();
|
||
let copy = *s.doc().unwrap().selection.objects.first().unwrap();
|
||
assert_ne!(copy, id);
|
||
assert_eq!(attached(&s, copy).map(|a| a.0), Some(Plane::Right));
|
||
// Nothing attached: the arrows nudge as usual.
|
||
s.execute("perspective.release", &json!({"ids": [id.0, copy.0]})).unwrap();
|
||
assert!(!s.tool_claims_key(ToolKey::Right, v));
|
||
assert!(s.execute("perspective.nudge", &json!({"dx": 1, "dy": 0})).is_err());
|
||
assert!(s.execute("perspective.nudge", &json!({"dx": 0, "dy": 0})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn selecting_an_attached_object_switches_to_its_plane() {
|
||
let (mut s, id) = persp_session();
|
||
s.execute("perspective.plane.set", &json!({"plane": "left"})).unwrap();
|
||
s.execute("select.none", &json!({})).unwrap();
|
||
s.select_tool("perspectiveSelection", ViewInfo::default()).unwrap();
|
||
let c = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().center();
|
||
drag(&mut s, &[c, c], Mods::default());
|
||
assert_eq!(grid(&s).plane, Plane::Right);
|
||
assert_eq!(s.doc().unwrap().selection.objects, vec![id]);
|
||
// A click is no edit.
|
||
assert_eq!(persp::attachment(s.doc().unwrap().doc.node(id).unwrap()), Some((Plane::Right, 0.0)));
|
||
}
|
||
|
||
#[test]
|
||
fn attachments_round_trip_through_native_files() {
|
||
let (mut s, id) = persp_session();
|
||
s.execute("perspective.transform", &json!({"matrix": [1, 0, 0, 1, 0, 0], "depth": 12.5})).unwrap();
|
||
let doc = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||
assert_eq!(persp::attachment(doc.node(id).unwrap()), Some((Plane::Right, 12.5)));
|
||
assert_eq!(*doc.node(id).unwrap(), *s.doc().unwrap().doc.node(id).unwrap());
|
||
// Objects outside perspective write nothing for it.
|
||
let v = serde_json::to_value(vectorcraft_doc::Node::new(NodeId(1), vectorcraft_doc::NodeKind::Group { children: vec![], clip: false })).unwrap();
|
||
assert!(v.get("perspective").is_none());
|
||
}
|