From 5a6f69f3f8cb2ebff2d2862f88adc7877830f252 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 13 Oct 2026 15:40:55 +0300 Subject: [PATCH] feat(select): drag selected objects to move them, Ctrl+drag to copy Pressing on a selected object (away from its grips) and dragging runs MOVE on the selection from the grabbed point, and the release is the destination, so the drag gets the command's preview, snaps and ortho. With Ctrl the drag runs COPY and places one copy. A press that does not move stays an ordinary click, and drags that start off the selection still make a window or lasso. Refs #750 Co-Authored-By: Claude Opus 5.5 --- src/app/mod.rs | 5 +++ src/app/update/viewport.rs | 91 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/src/app/mod.rs b/src/app/mod.rs index a22158dc1..26c440f88 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1223,6 +1223,10 @@ pub(super) struct OpenCADStudio { /// A direction typed as `, + /// A left press on a selected object: the point it grabbed, and whether + /// the drag has turned into a MOVE (or, with Ctrl, a COPY) of the + /// selection. + drag_move: Option<(glam::DVec3, bool)>, /// Inline rename state: (original_name, current_edit_value). layout_rename_state: Option<(String, String)>, /// Timestamp of the previous viewport left-click release (for double-click detection). @@ -4784,6 +4788,7 @@ impl OpenCADStudio { snap_override_popup: None, axis_lock_dir: None, angle_override: None, + drag_move: None, layout_rename_state: None, last_vp_click_time: None, last_vp_click_pos: None, diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 4782dba1a..25b414075 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -1233,6 +1233,9 @@ impl OpenCADStudio { return Task::none(); } let i = self.active_tab; + if let Some(task) = self.start_drag_move(i, p) { + return Task::batch([task, self.on_viewport_move(p)]); + } self.constraint_glyph_tooltip = None; let constraint_hover = self .constraint_glyph_under(i, p) @@ -3580,6 +3583,68 @@ impl OpenCADStudio { Some((constraint.kind, constraint.refs.clone())) } + /// Whether a selected object lies under `p` (tile-local). + fn selected_object_under( + &self, + i: usize, + p: Point, + bounds: iced::Rectangle, + edit_cam: &Option, + ) -> bool { + let (view_rot, eye, wires) = self.pick_view(i, edit_cam, bounds); + let aperture = crate::ui::overlay::pick_box_aperture_px(self.pick_box); + let world = self.cursor_model_point(i, edit_cam, p, bounds); + let scene = &self.tabs[i].scene; + let candidates = scene + .interaction_hover_candidates_near(wires, world, view_rot, eye, bounds, aperture * 2.0); + scene::pick::hit_test::click_hit( + p, + &candidates, + view_rot, + eye, + bounds, + scene.document.header.lineweight_display, + aperture, + &scene.draw_depth_map(), + ) + .and_then(Scene::handle_from_wire_name) + .is_some_and(|handle| scene.selected.contains(&handle)) + } + + /// Once a press on a selected object has moved far enough, MOVE (COPY + /// with Ctrl) runs on the selection from the grabbed point, so the drop + /// gets the command's preview, snaps and ortho. + fn start_drag_move(&mut self, i: usize, p: Point) -> Option> { + let (base, false) = self.drag_move? else { + return None; + }; + { + let mut sel = self.tabs[i].scene.selection.borrow_mut(); + let press = sel.input.left_press_pos?; + let held_ms = sel + .input + .left_press_time + .map_or(u128::MAX, |time| time.elapsed().as_millis()); + let (dx, dy) = (p.x - press.x, p.y - press.y); + if !sel.input.left_down || held_ms < POLY_START_DELAY_MS || dx * dx + dy * dy <= 9.0 { + return None; + } + sel.input.left_dragging = true; + sel.gesture.clear_left(); + } + self.drag_move = Some((base, true)); + let command = if self.ctrl_down { "COPY" } else { "MOVE" }; + let started = self.update(Message::Command(command.to_string())); + if self.tabs[i].active_cmd.is_none() { + self.drag_move = None; + return Some(started); + } + Some(Task::batch([ + started, + self.feed_command(crate::command::StepInput::Point(base)), + ])) + } + pub(super) fn on_viewport_left_press(&mut self) -> Task { let i = self.active_tab; self.block_palette.pressed = None; @@ -3993,6 +4058,12 @@ impl OpenCADStudio { .gesture .box_anchor .is_none(); + // A press on a selected object may become a drag of the selection. + self.drag_move = (self.tabs[i].active_cmd.is_none() + && !self.shift_down + && !self.tabs[i].scene.selected.is_empty() + && self.selected_object_under(i, p, bounds, &edit_cam)) + .then_some((self.tabs[i].last_cursor_world, false)); let mut sel = self.tabs[i].scene.selection.borrow_mut(); sel.input.left_down = true; // Stored in full-canvas space (like ViewportMove's cursor and @@ -4016,6 +4087,26 @@ impl OpenCADStudio { if let Some(item) = self.take_block_drop() { return self.drop_block(item); } + // A dragged selection drops where the button comes up: the release + // is the MOVE / COPY destination click, and a COPY places one copy. + if let Some((_, true)) = self.drag_move.take() { + { + // Starting the command reset the press; the drop reads as a + // click at the cursor. + let mut sel = self.tabs[i].scene.selection.borrow_mut(); + sel.input.left_down = true; + sel.input.left_dragging = false; + } + let placed = self.on_viewport_left_release(); + let copying = self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|command| command.name() == "COPY"); + if !copying { + return placed; + } + return Task::batch([placed, self.feed_command(crate::command::StepInput::Enter)]); + } if let Some(command) = self.tabs[i].active_cmd.as_mut() { command.set_ctrl(self.ctrl_down); command.set_shift(self.shift_down);