mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-30 20:58:00 -06:00
M8.12: Perspective Selection tool scales, copies, moves perpendicular and nudges in perspective
The Perspective Selection tool shows the selection's bounding box in perspective; its handles scale in plane space (perspective.transform; Shift proportional, Alt from the centre). Alt-drag moves copies that stay attached, pressing 5 while dragging moves along the plane's normal (ToolKey::Digit, delivered by the canvas and MCP press_key), and the arrow keys nudge in perspective (perspective.nudge). Transform Again repeats the last perspective move or scale in plane space. Attachments (plane and depth) now live on the objects (Node.perspective), so copies, duplicates and pastes keep them; grids from older files hand theirs over. Homography moves to vectorcraft-geom. Hint-bar text for the tool. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7df7cf568a
commit
a0847e8a25
@@ -0,0 +1,253 @@
|
||||
//! 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::{Mods, PointerEvent, PointerKind, ToolKey};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 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();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s.execute("perspective.grid.preset", &json!({"kind": 2})).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 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());
|
||||
}
|
||||
Reference in New Issue
Block a user