From f677c60abae62a270986df7585f0c387a288af05 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 18:49:05 -0700 Subject: [PATCH] Perspective Selection: Shift constrains a move to the plane's axes and diagonals Holding Shift while dragging objects with the Perspective Selection tool snaps the move, in the plane's own coordinates, to the nearest plane axis or 45 degree diagonal. This applies to plain moves and to Alt and Ctrl+Alt copies. The preview, the final move and the measurement label use the snapped point. Pressing or releasing Shift, pressing 5 and pressing 1 to 4 during the drag recompute the preview from the pointer; a move perpendicular to the plane is not constrained. --- crates/engine/src/tests/persp_select.rs | 67 +++++++++++++++ crates/engine/src/tooling.rs | 5 ++ crates/tools/src/distort/perspective.rs | 81 ++++++++++++++++++- crates/tools/src/distort/perspective/tools.rs | 53 ++++++++---- docs/target-app-parity.md | 1 + 5 files changed, 191 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/tests/persp_select.rs b/crates/engine/src/tests/persp_select.rs index 3b848d927..a6f912b73 100644 --- a/crates/engine/src/tests/persp_select.rs +++ b/crates/engine/src/tests/persp_select.rs @@ -111,6 +111,73 @@ fn alt_drag_copies_and_the_copy_stays_attached() { 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); +} + +#[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 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(); diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 8229979d0..90f4ff61c 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -457,6 +457,11 @@ impl Session { // 1–4 pick the active perspective plane while the grid is shown. if let Some(plane) = self.plane_key(key) { self.execute("perspective.plane.set", &serde_json::json!({ "plane": plane.id() }))?; + if self.tool_id() == "perspectiveSelection" { + let acts = self.with_tool_cx(view, |t, cx| t.key(cx, key, mods)); + self.take_tool_panic()?; + return self.apply_actions(acts); + } return Ok(vec![]); } let acts = self.with_tool_cx(view, |t, cx| t.key(cx, key, mods)); diff --git a/crates/tools/src/distort/perspective.rs b/crates/tools/src/distort/perspective.rs index 9a9863e55..2542e6a1a 100644 --- a/crates/tools/src/distort/perspective.rs +++ b/crates/tools/src/distort/perspective.rs @@ -1204,11 +1204,90 @@ mod tests { let Action::Preview(cmd, v) = &acts[0] else { panic!("{acts:?}") }; assert_eq!((cmd.as_str(), &v["perpendicular"], &v["to"]), ("perspective.move", &json!(true), &json!([mid.x + 20.0, mid.y]))); assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text.starts_with("dZ")))); - assert!(t.key(&c, ToolKey::Digit(3), Mods::default()).is_empty()); + assert!(matches!(t.key(&c, ToolKey::Digit(3), Mods::default()).as_slice(), [Action::Preview(cmd, _)] if cmd == "perspective.move")); assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, mid.x + 20.0, mid.y)), vec![Action::Commit]); assert!(!t.busy()); } + #[test] + fn selection_move_shift_snaps_in_each_plane() { + for plane in Plane::ALL { + let g = grid(2); + let h = g.homography(plane).unwrap(); + let from = h.apply(Point::new(50.0, 50.0)).unwrap(); + let mut d = Document::new(800.0, 600.0); + let layer = d.layers[0].id; + let id = d.alloc_id(); + d.insert( + Some(layer), + 0, + vectorcraft_doc::Node::path( + id, + vectorcraft_geom::shapes::rectangle(Rect::new(from.x - 20.0, from.y - 20.0, from.x + 20.0, from.y + 20.0)), + vectorcraft_doc::Appearance::default_art(), + ), + ) + .unwrap(); + g.store(&mut d); + set_attachment(d.node_mut(id).unwrap(), plane, 0.0); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let c = cx(&d, &s, &p); + let hi = h.inverse().unwrap(); + let start = hi.apply(from).unwrap(); + for (raw, expected) in [ + (Vec2::new(60.0, 12.0), Vec2::new(60.0, 0.0)), + (Vec2::new(12.0, 60.0), Vec2::new(0.0, 60.0)), + (Vec2::new(40.0, 50.0), Vec2::new(45.0, 45.0)), + ] { + let to = h.apply(start + raw).unwrap(); + let mut t = PerspectiveSelectionTool::default(); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, from.x, from.y)); + let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, to.x, to.y).with_mods(Mods { shift: true, ..Default::default() })); + let Some(Action::Preview(cmd, v)) = acts.last() else { panic!("{acts:?}") }; + assert_eq!(cmd, "perspective.move"); + let endpoint = Point::new(v["to"][0].as_f64().unwrap(), v["to"][1].as_f64().unwrap()); + let delta = hi.apply(endpoint).unwrap() - start; + assert!((delta - expected).hypot() < 1e-6, "{plane:?}: {delta:?} != {expected:?}"); + } + } + } + + #[test] + fn selection_move_recomputes_shift_copy_and_perpendicular() { + let (mut d, id) = doc_with_rect(); + grid(2).store(&mut d); + set_attachment(d.node_mut(id).unwrap(), Plane::Right, 0.0); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let c = cx(&d, &s, &p); + let h = PerspectiveGrid::current(&d).homography(Plane::Right).unwrap(); + let from = d.node(id).unwrap().geometric_bounds().unwrap().center(); + let start = h.inverse().unwrap().apply(from).unwrap(); + let to = h.apply(start + Vec2::new(60.0, 12.0)).unwrap(); + let mut t = PerspectiveSelectionTool::default(); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, from.x, from.y)); + let plain = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, to.x, to.y)); + let Some(Action::Preview(_, raw)) = plain.last() else { panic!("{plain:?}") }; + assert_eq!(raw["to"], json!([to.x, to.y])); + let shifted = + t.pointer(&c, &PointerEvent::new(PointerKind::Drag, to.x, to.y).with_mods(Mods { shift: true, alt: true, ..Default::default() })); + let Some(Action::Preview(_, snapped)) = shifted.last() else { panic!("{shifted:?}") }; + assert_eq!(snapped["copy"], json!(true)); + assert_ne!(snapped["to"], raw["to"]); + let perpendicular = t.key(&c, ToolKey::Digit(5), Mods { shift: true, alt: true, ..Default::default() }); + let Some(Action::Preview(_, depth)) = perpendicular.last() else { panic!("{perpendicular:?}") }; + assert_eq!(depth["to"], raw["to"]); + let in_plane = t.key(&c, ToolKey::Digit(5), Mods { shift: true, alt: true, ..Default::default() }); + let Some(Action::Preview(_, snapped_again)) = in_plane.last() else { panic!("{in_plane:?}") }; + assert_eq!(snapped_again["to"], snapped["to"]); + let released = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, to.x, to.y)); + let Some(Action::Preview(_, raw_again)) = released.last() else { panic!("{released:?}") }; + assert_eq!(raw_again["to"], raw["to"]); + } + #[test] fn escape_cancels_a_perspective_selection_drag() { let (mut d, id) = doc_with_rect(); diff --git a/crates/tools/src/distort/perspective/tools.rs b/crates/tools/src/distort/perspective/tools.rs index 541e7a8f9..9e9454b30 100644 --- a/crates/tools/src/distort/perspective/tools.rs +++ b/crates/tools/src/distort/perspective/tools.rs @@ -119,17 +119,18 @@ enum SelDrag { #[default] Idle, /// Pressed on an object: moves `ids` once the pointer leaves the drag threshold. `perp`: along - /// the plane's normal (5 pressed while dragging); `copy`: Alt at the last event. - Move { ids: Vec, from: Point, last: Point, began: bool, perp: bool, copy: bool }, + /// the plane's normal (5 pressed while dragging); `copy` and `shift`: modifiers at the last event. + Move { ids: Vec, from: Point, last: Point, began: bool, perp: bool, copy: bool, shift: bool }, /// Dragging `handle` of the perspective bounding box `rect` (plane space, on `plane` at `depth`). Scale { ids: Vec, plane: Plane, depth: f64, rect: Rect, handle: BoxHandle }, /// Dragging a plane widget. Plane(PlaneDrag), } -/// The Perspective Selection tool: selects, moves (Alt copies; press 5 while dragging to move -/// perpendicular to the plane) and scales (bounding-box handles; Shift proportional, Alt from the -/// centre) objects within their perspective planes; the arrow keys nudge them in perspective. +/// The Perspective Selection tool: selects, moves (Shift constrains to plane-space 45° directions, +/// Alt copies; press 5 while dragging to move perpendicular to the plane) and scales (bounding-box +/// handles; Shift proportional, Alt from the center) objects within their perspective planes; the +/// arrow keys nudge them in perspective. #[derive(Default)] pub struct PerspectiveSelectionTool { drag: SelDrag, @@ -163,21 +164,36 @@ pub(super) fn resize_cursor(d: Vec2) -> Cursor { impl PerspectiveSelectionTool { /// The `perspective.move` preview for the move drag (and its measurement label). fn move_preview(&mut self, cx: &ToolContext) -> Option { - let SelDrag::Move { ids, from, last, perp, copy, .. } = &self.drag else { return None }; + let SelDrag::Move { ids, from, last, perp, copy, shift, .. } = &self.drag else { return None }; let g = PerspectiveGrid::current(cx.doc); - let mut v = json!({"ids": crate::json_ids(ids), "from": [from.x, from.y], "to": [last.x, last.y], "copy": copy, "perpendicular": perp}); + let at = ids.first().and_then(|id| g.attachment_of(cx.doc, *id)).or((g.plane != Plane::None).then_some((g.plane, g.offset(g.plane)))); + let to = if *shift && !*perp { + at.and_then(|(plane, depth)| { + let h = g.homography_at(plane, depth)?; + let hi = h.inverse()?; + let start = hi.apply(*from)?; + let delta = hi.apply(*last)? - start; + let angle = (delta.y.atan2(delta.x) / std::f64::consts::FRAC_PI_4).round() * std::f64::consts::FRAC_PI_4; + let direction = Vec2::new(angle.cos(), angle.sin()); + let distance = delta.x * direction.x + delta.y * direction.y; + h.apply(start + direction * distance).filter(|p| p.x.is_finite() && p.y.is_finite()) + }) + .unwrap_or(*last) + } else { + *last + }; + let mut v = json!({"ids": crate::json_ids(ids), "from": [from.x, from.y], "to": [to.x, to.y], "copy": copy, "perpendicular": perp}); if g.plane != Plane::None { v["plane"] = json!(g.plane.id()); } - let at = ids.first().and_then(|id| g.attachment_of(cx.doc, *id)).or((g.plane != Plane::None).then_some((g.plane, g.offset(g.plane)))); self.measure = at.and_then(|(pl, depth)| { let text = if *perp { - format!("dZ: {}", cx.len(g.depth_at(pl, depth, *from, *last)? - depth)) + format!("dZ: {}", cx.len(g.depth_at(pl, depth, *from, to)? - depth)) } else { - let d = g.plane_delta(pl, depth, *from, *last)?; + let d = g.plane_delta(pl, depth, *from, to)?; cx.offset_label(d.x, d.y) }; - Some((*last, text)) + Some((to, text)) }); Some(Action::Preview("perspective.move".into(), v)) } @@ -223,19 +239,19 @@ impl Tool for PerspectiveSelectionTool { { acts.push(Action::Exec("perspective.plane.set".into(), json!({"plane": pl.id()}))); } - self.drag = SelDrag::Move { ids, from: p, last: p, began: false, perp: false, copy: m.alt }; + self.drag = SelDrag::Move { ids, from: p, last: p, began: false, perp: false, copy: m.alt, shift: m.shift }; acts } (PointerKind::Drag, SelDrag::Move { ids, from, began, perp, .. }) => { if !began && p.distance(from) < cx.tol(3.0) { - self.drag = SelDrag::Move { ids, from, last: p, began, perp, copy: m.alt }; + self.drag = SelDrag::Move { ids, from, last: p, began, perp, copy: m.alt, shift: m.shift }; return vec![]; } let mut out = vec![]; if !began { out.push(Action::Begin(if m.alt { "Copy in Perspective" } else { "Move in Perspective" }.into())); } - self.drag = SelDrag::Move { ids, from, last: p, began: true, perp, copy: m.alt }; + self.drag = SelDrag::Move { ids, from, last: p, began: true, perp, copy: m.alt, shift: m.shift }; out.extend(self.move_preview(cx)); out } @@ -289,10 +305,17 @@ impl Tool for PerspectiveSelectionTool { _ => vec![Action::Cancel], }; } - if let (ToolKey::Digit(5), SelDrag::Move { perp, began, .. }) = (key, &mut self.drag) { + if let (ToolKey::Digit(5), SelDrag::Move { perp, began, shift, .. }) = (key, &mut self.drag) { *perp = !*perp; + *shift = mods.shift; return if *began { self.move_preview(cx).into_iter().collect() } else { vec![] }; } + if matches!(key, ToolKey::Digit(1..=4)) + && let SelDrag::Move { began: true, shift, .. } = &mut self.drag + { + *shift = mods.shift; + return self.move_preview(cx).into_iter().collect(); + } let (dx, dy) = match key { ToolKey::Left => (-1, 0), ToolKey::Right => (1, 0), diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 02a030972..51c037334 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1104,6 +1104,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur An Alt-drag with the Selection, Direct Selection, Group Selection, Rotate, Scale, Reflect or Shear tool copies the selection on its first move, and the moves after it transform those copies; the previews of the Move, Rotate, Scale, Reflect and Shear dialogs with Copy and Preview on copy the selection once in the same way (#1429). An Alt-drag of objects in perspective with the Perspective Selection tool, and an Alt-drag of a plane widget with that tool or the Perspective Grid tool, copy the objects on the first move, and the moves after it move those copies (#1429). With the Perspective Selection tool, Ctrl+Alt-drag (Command+Option-drag on macOS) also copies objects on their perspective plane; Alt/Option-drag continues to do so. + With the Perspective Selection tool, holding Shift while moving or copying on a plane constrains the displacement to the nearest plane axis or 45° diagonal; releasing Shift resumes the raw pointer position, and 5 still moves perpendicular to the plane. When the desktop app opens a document on a thread of its own (#1245), that thread also reads the document's linked files and lays out its type, and the UI thread only adds the finished document: in release builds, the step left on the UI thread takes 0.02 ms instead of 1.04 s for a document with 12 linked 3000 × 2000 TIFFs, and it takes 0.1 ms instead of 0.85 s for one with 300 Auto Size area type objects, every second one in three columns. The Layers panel's Delete Selection, Duplicate Selection, Collect in New Layer, Merge Selected, Flatten Artwork, Release to Layers (Sequence) and row drags also take time in proportion to the size of the document, and so do Cut Artboard and Duplicate Artboard with their art and each move of an Alt-drag of one artboard with the Artboard tool while Move/Copy Artwork with Artboard is on (#1429). In Outline view the renderer draws paths and compound paths from the geometry it keeps for them between frames, and it culls the members of clip groups by the bounds it keeps for them. Layers with Preview off and the outlines of Image Trace objects are drawn the same way.