From ae7645572345f8b7444f21434ecf26dc43a029cb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 20:18:54 -0700 Subject: [PATCH] Perspective Selection: a move of loose art keeps its plane when 4 clears the active plane The move drag remembers the plane it places loose art on. Pressing 4 during the drag, or dragging on after it, previews and commits the move on that plane instead of dropping it. A 1-3 key pressed before the drag threshold also sets the plane the move uses. --- crates/engine/src/tests/persp_select.rs | 33 +++++++++++++++++++ crates/tools/src/distort/perspective.rs | 25 ++++++++++++++ crates/tools/src/distort/perspective/tools.rs | 30 ++++++++++------- 3 files changed, 77 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/tests/persp_select.rs b/crates/engine/src/tests/persp_select.rs index c590aee5b..4ca4b46fa 100644 --- a/crates/engine/src/tests/persp_select.rs +++ b/crates/engine/src/tests/persp_select.rs @@ -281,6 +281,39 @@ fn shift_drag_keeps_its_displacement_after_a_plane_key() { } } +#[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(); diff --git a/crates/tools/src/distort/perspective.rs b/crates/tools/src/distort/perspective.rs index d535b5cd2..7f3f37405 100644 --- a/crates/tools/src/distort/perspective.rs +++ b/crates/tools/src/distort/perspective.rs @@ -1286,6 +1286,31 @@ mod tests { assert_eq!(params["constrain"], json!(true)); } + #[test] + fn loose_move_preview_keeps_its_plane_when_the_active_plane_is_cleared() { + let (mut d, id) = doc_with_rect(); + let mut g = grid(2); + g.plane = Plane::Right; + g.store(&mut d); + let mut selection = Selection::default(); + selection.add(id); + let p = paint(); + let from = d.node(id).unwrap().geometric_bounds().unwrap().center(); + let to = from + Vec2::new(30.0, 15.0); + let mut tool = PerspectiveSelectionTool::default(); + let c = cx(&d, &selection, &p); + tool.pointer(&c, &PointerEvent::new(PointerKind::Down, from.x, from.y)); + let actions = tool.pointer(&c, &PointerEvent::new(PointerKind::Drag, to.x, to.y)); + assert!(matches!(actions.last(), Some(Action::Preview(_, params)) if params["plane"] == "right")); + g.plane = Plane::None; + g.store(&mut d); + let c = cx(&d, &selection, &p); + let actions = tool.key(&c, ToolKey::Digit(4), Mods::default()); + assert!(matches!(actions.last(), Some(Action::Preview(_, params)) if params["plane"] == "right"), "{actions:?}"); + let actions = tool.pointer(&c, &PointerEvent::new(PointerKind::Drag, to.x + 10.0, to.y)); + assert!(matches!(actions.last(), Some(Action::Preview(_, params)) if params["plane"] == "right"), "{actions:?}"); + } + #[test] fn selection_move_recomputes_shift_copy_and_perpendicular() { 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 e791f4ef3..915e4a883 100644 --- a/crates/tools/src/distort/perspective/tools.rs +++ b/crates/tools/src/distort/perspective/tools.rs @@ -120,7 +120,7 @@ enum SelDrag { 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` and `shift`: modifiers at the last event. - Move { ids: Vec, from: Point, last: Point, began: bool, perp: bool, copy: bool, shift: bool }, + Move { ids: Vec, from: Point, last: Point, plane: Plane, 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. @@ -164,9 +164,11 @@ 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, shift, .. } = &self.drag else { return None }; + let SelDrag::Move { ids, from, last, plane, perp, copy, shift, .. } = &self.drag else { return None }; let g = PerspectiveGrid::current(cx.doc); - 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 move_plane = if g.plane == Plane::None { *plane } else { g.plane }; + let at = + ids.first().and_then(|id| g.attachment_of(cx.doc, *id)).or((move_plane != Plane::None).then_some((move_plane, g.offset(move_plane)))); let constrained = if *shift && !*perp { at.and_then(|(plane, depth)| { let delta = g.plane_delta(plane, depth, *from, *last)?; @@ -179,8 +181,8 @@ impl PerspectiveSelectionTool { if *shift && !*perp { v["constrain"] = json!(true); } - if g.plane != Plane::None { - v["plane"] = json!(g.plane.id()); + if move_plane != Plane::None { + v["plane"] = json!(move_plane.id()); } self.measure = at.and_then(|(pl, depth)| { let text = if *perp { @@ -230,24 +232,26 @@ impl Tool for PerspectiveSelectionTool { vec![top] }; // Selecting an object in perspective makes its plane the active one. + let mut plane = g.plane; if let Some((pl, _)) = g.attachment_of(cx.doc, top) && pl != g.plane { acts.push(Action::Exec("perspective.plane.set".into(), json!({"plane": pl.id()}))); + plane = pl; } - self.drag = SelDrag::Move { ids, from: p, last: p, began: false, perp: false, copy: m.alt, shift: m.shift }; + self.drag = SelDrag::Move { ids, from: p, last: p, plane, began: false, perp: false, copy: m.alt, shift: m.shift }; acts } - (PointerKind::Drag, SelDrag::Move { ids, from, began, perp, .. }) => { + (PointerKind::Drag, SelDrag::Move { ids, from, plane, began, perp, .. }) => { if !began && p.distance(from) < cx.tol(3.0) { - self.drag = SelDrag::Move { ids, from, last: p, began, perp, copy: m.alt, shift: m.shift }; + self.drag = SelDrag::Move { ids, from, last: p, plane, 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, shift: m.shift }; + self.drag = SelDrag::Move { ids, from, last: p, plane, began: true, perp, copy: m.alt, shift: m.shift }; out.extend(self.move_preview(cx)); out } @@ -307,10 +311,14 @@ impl Tool for PerspectiveSelectionTool { 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 + && let SelDrag::Move { began, plane, shift, .. } = &mut self.drag { *shift = mods.shift; - return self.move_preview(cx).into_iter().collect(); + let active = PerspectiveGrid::current(cx.doc).plane; + if active != Plane::None { + *plane = active; + } + return if *began { self.move_preview(cx).into_iter().collect() } else { vec![] }; } let (dx, dy) = match key { ToolKey::Left => (-1, 0),