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:
bflatastic
2026-10-06 11:00:15 +07:00
co-authored by Claude Opus 5.5
parent 7df7cf568a
commit a0847e8a25
18 changed files with 1250 additions and 140 deletions
+175 -22
View File
@@ -8,12 +8,12 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, NodeId, NodeKind, WidthProfile};
use vectorcraft_geom::Point;
use vectorcraft_geom::{Affine, Homography, Point};
use vectorcraft_tools::distort::liquify::{Dabber, LiquifyParams, PathStroke, Sample};
use vectorcraft_tools::distort::perspective::{PerspectiveGrid, Plane};
use vectorcraft_tools::distort::perspective::{self as persp, PerspectiveGrid, Plane};
use vectorcraft_tools::distort::{arap, collect_points, mesh_for, warp_node_with};
use super::edit::selected_roots;
use super::edit::{duplicate_in, selected_roots};
use super::*;
use crate::EngineError;
@@ -124,7 +124,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move in Perspective",
[],
None,
"{ids?, from: [x,y], to: [x,y], plane?} slide objects within their plane (unattached objects attach to `plane`/the active plane first)",
"{ids?, from: [x,y], to: [x,y], plane?, copy?: bool (move copies, as Alt-dragging does), perpendicular?: bool (move along the plane's normal instead, as pressing 5 while dragging does)} slide objects within their plane (unattached objects attach to `plane`/the active plane first); Transform Again repeats it",
has_doc,
persp_move
),
@@ -137,6 +137,24 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
persp_draw
),
cmd!(
"perspective.transform",
"Transform in Perspective",
[],
None,
"{ids?, matrix: [a,b,c,d,e,f] (affine map in plane coordinates, points: u along the plane, v up or away), depth?: pt to move along the plane's normal (default 0), copy?: bool, plane?} transform objects within their own planes (Perspective Selection tool handles; unattached objects attach to `plane`/the active plane first); Transform Again repeats it → {ids}",
has_doc,
persp_transform
),
cmd!(
"perspective.nudge",
"Nudge in Perspective",
[],
None,
"{dx, dy: arrow direction (-1, 0 or 1), big?: bool (×10), copy?: bool} move the selection in perspective by the keyboard increment, as the arrow keys do with the Perspective Selection tool → {ids}",
has_selection,
persp_nudge
),
]
}
@@ -523,6 +541,7 @@ fn attach_in(d: &mut Document, g: &mut PerspectiveGrid, id: NodeId, plane: Plane
let b = d.node(id).ok_or(EngineError::NoNode(id))?.geometric_bounds().ok_or_else(|| EngineError::Other("the object has no geometry".into()))?;
let m = g.attach_map(plane, b).ok_or_else(|| EngineError::Other("the object is beyond the plane's horizon".into()))?;
warp_checked(d, id, &m)?;
persp::set_attachment(d.node_mut(id).ok_or(EngineError::NoNode(id))?, plane, 0.0);
g.attached.insert(id.0.to_string(), plane);
Ok(())
}
@@ -547,6 +566,9 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Release with Perspective", |d, _| {
for id in &ids {
g.attached.remove(&id.0.to_string());
if let Some(n) = d.node_mut(*id) {
n.perspective = None;
}
}
store_grid(d, &g);
Ok(())
@@ -554,30 +576,161 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
/// One object's share of a perspective edit: the page map for an object on `plane` at `depth`, and
/// the depth it ends at.
type PerspStep<'a> = &'a dyn Fn(&PerspectiveGrid, Plane, f64) -> Result<(Homography, f64)>;
/// Where an object is in perspective: its plane and depth.
type Attached = (Plane, f64);
fn beyond_horizon() -> EngineError {
EngineError::Other("the pointer is beyond the plane's horizon".into())
}
/// Edit objects in perspective (one undo step `label`): with `copy` their duplicates (which stay
/// attached), each mapped by `step` for its own plane and depth; objects not in perspective attach
/// to `fallback` first. → the edited objects and where the first one was (plane, depth).
fn persp_edit(
s: &mut Session,
label: &str,
ids: &[NodeId],
fallback: Option<Plane>,
copy: bool,
step: PerspStep,
) -> Result<(Vec<NodeId>, Option<Attached>)> {
if ids.is_empty() {
return Err(EngineError::Other("select the objects to transform in perspective".into()));
}
let mut g = grid_of(&s.doc()?.doc);
s.edit(label, |d, sel| {
g.adopt_stored_attachments(d);
let targets = if copy { duplicate_in(d, sel, ids, Affine::IDENTITY)? } else { ids.to_vec() };
let mut first = None;
for id in &targets {
let (plane, depth) = match g.attachment_of(d, *id) {
Some(a) => a,
None => {
let pl = fallback.ok_or_else(|| EngineError::Other("the object isn't on a perspective plane".into()))?;
attach_in(d, &mut g, *id, pl)?;
(pl, 0.0)
}
};
first.get_or_insert((plane, depth));
let (h, depth) = step(&g, plane, depth)?;
warp_checked(d, *id, &|q| h.apply(q))?;
persp::set_attachment(d.node_mut(*id).ok_or(EngineError::NoNode(*id))?, plane, depth);
}
store_grid(d, &g);
Ok((targets, first))
})
}
/// Remember `again` (`perspective.transform` params) for Object › Transform › Transform Again, once
/// the drag that made it is committed.
fn record_again(s: &mut Session, again: Value) -> Result<()> {
let st = s.doc_mut()?;
match &mut st.interaction {
Some(it) => it.perspective_again = Some(again),
None => st.last_perspective = Some(again),
}
Ok(())
}
fn ids_json(ids: &[NodeId]) -> Value {
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
}
/// A plane-space affine map as `perspective.transform`'s `matrix` param.
fn matrix_json(m: Affine) -> Value {
json!(m.as_coeffs())
}
fn persp_move(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.move";
let ids = roots(s, p)?;
let from = point_param(p, "from").ok_or_else(|| bad(C, "missing from"))?;
let to = point_param(p, "to").ok_or_else(|| bad(C, "missing to"))?;
let mut g = grid_of(&s.doc()?.doc);
let fallback = plane_param(&g, p, C).ok();
s.edit("Move in Perspective", |d, _| {
for id in &ids {
let plane = match g.attached_plane(*id) {
Some(pl) => pl,
None => {
let pl = fallback.ok_or_else(|| EngineError::Other("the object isn't on a perspective plane".into()))?;
attach_in(d, &mut g, *id, pl)?;
pl
}
};
let m = g.move_map(plane, from, to).ok_or_else(|| EngineError::Other("the pointer is beyond the plane's horizon".into()))?;
warp_checked(d, *id, &m)?;
if ![from.x, from.y, to.x, to.y].iter().all(|v| v.is_finite()) {
return Err(bad(C, "from and to must be finite"));
}
let fallback = plane_param(&grid_of(&s.doc()?.doc), p, C).ok();
let (perp, copy) = (bool_or(p, "perpendicular", false), bool_or(p, "copy", false));
// In-plane: the plane-space offset under the pointer; perpendicular: the depth it reaches.
let step = |g: &PerspectiveGrid, pl: Plane, depth: f64| -> Result<(Homography, f64)> {
if perp {
let to_depth = g.depth_at(pl, depth, from, to).ok_or_else(beyond_horizon)?;
Ok((g.transform_map(pl, depth, Affine::IDENTITY, to_depth - depth).ok_or_else(beyond_horizon)?, to_depth))
} else {
let dv = g.plane_delta(pl, depth, from, to).ok_or_else(beyond_horizon)?;
Ok((g.transform_map(pl, depth, Affine::translate(dv), 0.0).ok_or_else(beyond_horizon)?, depth))
}
store_grid(d, &g);
Ok(())
})?;
ok()
};
let label = if copy { "Copy in Perspective" } else { "Move in Perspective" };
let (targets, first) = persp_edit(s, label, &ids, fallback, copy, &step)?;
// Transform Again repeats the first object's move.
if let Some((pl, depth)) = first {
let g = grid_of(&s.doc()?.doc);
let again = match perp {
true => g.depth_at(pl, depth, from, to).map(|d| json!({"matrix": matrix_json(Affine::IDENTITY), "depth": d - depth, "copy": copy})),
false => g.plane_delta(pl, depth, from, to).map(|dv| json!({"matrix": matrix_json(Affine::translate(dv)), "copy": copy})),
};
if let Some(a) = again {
record_again(s, a)?;
}
}
Ok(ids_json(&targets))
}
fn persp_transform(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.transform";
let m = matrix_param(p, "matrix").ok_or_else(|| bad(C, "missing matrix [a,b,c,d,e,f]"))?;
let dz = f64_or(p, "depth", 0.0);
if !m.as_coeffs().iter().all(|v| v.is_finite()) || m.determinant().abs() < 1e-12 || !dz.is_finite() {
return Err(bad(C, "matrix must be finite and invertible, depth finite"));
}
let ids = roots(s, p)?;
let fallback = plane_param(&grid_of(&s.doc()?.doc), p, C).ok();
let copy = bool_or(p, "copy", false);
let step = |g: &PerspectiveGrid, pl: Plane, depth: f64| -> Result<(Homography, f64)> {
let to = depth + dz;
if to.abs() > persp::MAX_DEPTH {
return Err(EngineError::Other("the objects would leave the grid".into()));
}
Ok((g.transform_map(pl, depth, m, dz).ok_or_else(beyond_horizon)?, to))
};
let (targets, _) = persp_edit(s, if copy { "Copy in Perspective" } else { "Transform in Perspective" }, &ids, fallback, copy, &step)?;
record_again(s, json!({"matrix": matrix_json(m), "depth": dz, "copy": copy}))?;
Ok(ids_json(&targets))
}
/// Object › Transform › Transform Again after a perspective move or scale: the same plane-space
/// transform on the selection.
pub(crate) fn transform_again(s: &mut Session, again: &Value) -> Result<Value> {
let mut p = again.clone();
if let Some(o) = p.as_object_mut() {
o.remove("ids");
}
persp_transform(s, &p)
}
fn persp_nudge(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.nudge";
let (dx, dy) = (f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
if !(dx.is_finite() && dy.is_finite()) || (dx == 0.0 && dy == 0.0) {
return Err(bad(C, "dx and dy give the arrow's direction"));
}
let k = s.prefs.keyboard_increment * if bool_or(p, "big", false) { 10.0 } else { 1.0 };
let ids = selected_roots(s)?;
// The step is measured on the page at the centre of the selection's perspective box.
let st = s.doc()?;
let g = grid_of(&st.doc);
let (plane, depth, rect) = g.plane_bounds(&st.doc, &ids).ok_or_else(|| EngineError::Other("the selection isn't in perspective".into()))?;
let from = g.homography_at(plane, depth).and_then(|h| h.apply(rect.center())).ok_or_else(beyond_horizon)?;
let to = from + vectorcraft_geom::Vec2::new(dx.clamp(-1.0, 1.0), dy.clamp(-1.0, 1.0)) * k;
persp_move(
s,
&json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "from": [from.x, from.y], "to": [to.x, to.y], "copy": bool_or(p, "copy", false)}),
)
}
fn persp_draw(s: &mut Session, p: &Value) -> Result<Value> {
+4
View File
@@ -219,6 +219,7 @@ pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec<NodeId>, xf
let st = s.doc_mut()?;
if st.interaction.is_none() {
st.last_transform = Some((xf, copy));
st.last_perspective = None;
}
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
@@ -299,6 +300,9 @@ fn shear(s: &mut Session, p: &Value) -> Result<Value> {
}
fn transform_again(s: &mut Session, _: &Value) -> Result<Value> {
if let Some(again) = s.doc()?.last_perspective.clone() {
return super::distortcmds::transform_again(s, &again);
}
let (m, copy) = s.doc()?.last_transform.ok_or_else(|| EngineError::Other("no previous transform".into()))?;
let ids = selected_roots(s)?;
apply_transform(s, "Transform Again", ids, m, &json!({ "copy": copy }))
+15
View File
@@ -81,6 +81,9 @@ pub struct Interaction {
/// Per-document state restored on cancel (current layer, isolation).
pub active_layer: Option<NodeId>,
pub isolation: Option<NodeId>,
/// The perspective transform the previews make (`perspective.transform` params): Transform
/// Again repeats it once the drag is committed.
pub perspective_again: Option<Value>,
}
/// Per-document editing state.
@@ -131,6 +134,9 @@ pub struct DocState {
pub recovery: Option<cmd::recovery::RecoveryCopy>,
/// View → Show Print Tiling, per document (view state: not saved, not undoable).
pub print_tiling: bool,
/// Transform Again after a perspective move or scale (Perspective Selection tool): the
/// `perspective.transform` params it repeats. `None` once an ordinary transform follows.
pub last_perspective: Option<Value>,
}
static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
@@ -163,6 +169,7 @@ impl DocState {
recovered: false,
recovery: None,
print_tiling: false,
last_perspective: None,
}
}
/// Unsaved changes: the document differs from the saved one (selection changes don't count).
@@ -1052,6 +1059,7 @@ impl Session {
preview: None,
active_layer: st.active_layer,
isolation: st.isolation,
perspective_again: None,
});
Ok(())
}
@@ -1080,14 +1088,19 @@ impl Session {
let st = self.doc_mut()?;
let Some(mut it) = st.interaction.take() else { return Ok(()) };
let Some(preview) = it.preview.take() else { return Ok(()) };
let perspective_again = it.perspective_again.take();
st.keep_interaction(it);
if preview.0 == "object.transform" {
let m = cmd::matrix_param(&preview.1, "matrix");
let copy = preview.1.get("copy").and_then(Value::as_bool).unwrap_or(false);
if let Some(m) = m {
st.last_transform = Some((m, copy));
st.last_perspective = None;
}
}
if perspective_again.is_some() {
st.last_perspective = perspective_again;
}
self.journal.push(preview);
Ok(())
}
@@ -1256,6 +1269,8 @@ mod tests_pdfpresets;
#[cfg(test)]
mod tests_pdfraster;
#[cfg(test)]
mod tests_persp_select;
#[cfg(test)]
mod tests_perspgrid;
#[cfg(test)]
mod tests_place;
+253
View File
@@ -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());
}