From a5fd040745b8c1e8012af0a75f29547b0fce461e Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:25:27 +0300 Subject: [PATCH] feat(grips): compatible grip shortcut menu A right-click while a grip is hot opens the grip menu: Enter places the grip, Stretch / Move / Rotate / Scale / Mirror switch how the gripped objects are edited (the latter four hand the selection to the matching command with the grip as its base point), Base Point re-bases the gesture at the next click, Copy leaves the originals in place and adds the moved shapes as new entities while keeping the grip hot, Undo snaps the shapes back to where the gesture started, Exit drops the edit. The grip commit path moves out of the left-release handler into `commit_active_grip_edit` so the menu, Enter and the placement click share it; Enter with a hot grip now places it instead of repeating the last command. Co-Authored-By: Claude Opus 5 --- src/app/command_driver.rs | 3 + src/app/update/command.rs | 6 + src/app/update/context_menu.rs | 241 +++++++++++++++++++++++++++++- src/app/update/viewport.rs | 259 +++++++++++++++++++++------------ 4 files changed, 413 insertions(+), 96 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 3eb22b41c..8498c44e6 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -176,6 +176,9 @@ impl OpenCADStudio { pub(super) fn cancel_active_grip_edit(&mut self) -> bool { let i = self.active_tab; + // Grip-menu toggles live only as long as the gesture. + self.tabs[i].grip_copy = false; + self.tabs[i].grip_base_pending = false; let had_grip = self.tabs[i].active_grip.take().is_some() || self.grip_add_provisional.is_some() || !self.grip_preview_handles.is_empty() diff --git a/src/app/update/command.rs b/src/app/update/command.rs index b2871fd4e..ec238814d 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -942,6 +942,12 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { if grip_dyn_locked { return self.update(Message::CommandSubmit); } + // Enter while a grip is hot places it where it is (or keeps + // it hot when it has not moved) — it must not fall through to + // "repeat the last command", which would discard the edit. + if self.tabs[i].active_grip.is_some() && self.tabs[i].active_cmd.is_none() { + return self.commit_active_grip_edit(); + } // Normal command Dynamic Input commit. if let Some(task) = self.try_dyn_commit() { diff --git a/src/app/update/context_menu.rs b/src/app/update/context_menu.rs index f1ebbce3a..1413ba5c1 100644 --- a/src/app/update/context_menu.rs +++ b/src/app/update/context_menu.rs @@ -5,9 +5,13 @@ //! this module is the glue between that model and the app's messages. use crate::app::{ArrowKey, ContextMenuNav, Message, OpenCADStudio}; +use crate::command::StepInput; +use crate::scene::pick::grip::GripEditMode; use crate::ui::popup::context_menu::{ - build_context_menu, ContextMenu, GripMenuContext, MenuAction, MenuContext, SubmenuId, + build_context_menu, ContextMenu, GripMenuCmd, GripMenuContext, MenuAction, MenuContext, + SubmenuId, }; +use acadrust::Handle; use iced::Task; impl OpenCADStudio { @@ -121,6 +125,9 @@ impl OpenCADStudio { } let focus = self.focus_cmd_input(); let task = match action { + MenuAction::Enter if self.tabs[i].active_grip.is_some() => { + self.commit_active_grip_edit() + } MenuAction::Enter => self.update(Message::CommandFinalize), MenuAction::Cancel => self.update(Message::CommandEscape), MenuAction::Option(kw) => self.update(Message::CommandOptionPick(kw)), @@ -299,10 +306,80 @@ impl OpenCADStudio { None } - /// Grip-mode shortcut menu actions (Stage 7). - pub(in crate::app) fn on_grip_context_pick(&mut self, cmd: crate::ui::popup::context_menu::GripMenuCmd) -> Task { - let _ = cmd; - Task::none() + /// Grip-mode shortcut menu (AutoCAD's grip menu): the grip stays hot + /// for Stretch / Base Point / Copy / Undo; Move / Rotate / Scale / Mirror + /// hand the selection to the matching command with the grip as its base + /// point; Exit drops the edit. + pub(in crate::app) fn on_grip_context_pick(&mut self, cmd: GripMenuCmd) -> Task { + let i = self.active_tab; + let Some(grip) = self.tabs[i].active_grip.clone() else { + return Task::none(); + }; + match cmd { + GripMenuCmd::Exit => { + self.cancel_active_grip_edit(); + self.tabs[i].grip_copy = false; + self.tabs[i].grip_base_pending = false; + Task::none() + } + GripMenuCmd::Stretch => { + if let Some(active) = self.tabs[i].active_grip.as_mut() { + active.mode = GripEditMode::Stretch; + active.axis = None; + } + Task::none() + } + GripMenuCmd::Move | GripMenuCmd::Rotate | GripMenuCmd::Scale | GripMenuCmd::Mirror => { + let base = grip.origin_world; + let handles: Vec = grip.targets.iter().map(|t| t.handle).collect(); + self.cancel_active_grip_edit(); + self.tabs[i].grip_copy = false; + self.tabs[i].grip_base_pending = false; + // The command edits the current selection; make sure the + // gripped objects are in it. + if self.tabs[i].scene.selected.is_empty() { + self.tabs[i].scene.select_entities(&handles); + } + let name = match cmd { + GripMenuCmd::Move => "MOVE", + GripMenuCmd::Rotate => "ROTATE", + GripMenuCmd::Scale => "SCALE", + _ => "MIRROR", + }; + let start = self.dispatch_command(name); + if self.tabs[i].active_cmd.is_none() { + return start; + } + // The grip is the base point, so the command opens directly + // at its second prompt. + self.last_point = Some(base); + let fed = self.feed_command(StepInput::Point(base)); + Task::batch(vec![start, fed]) + } + GripMenuCmd::BasePoint => { + self.tabs[i].grip_base_pending = true; + Task::none() + } + GripMenuCmd::CopyToggle => { + self.tabs[i].grip_copy = !self.tabs[i].grip_copy; + Task::none() + } + GripMenuCmd::Undo => { + // Put the shapes back where the gesture started and keep the + // grip hot there (the Copy toggle survives). + let copy = self.tabs[i].grip_copy; + self.cancel_active_grip_edit(); + self.tabs[i].grip_copy = copy; + let mut again = grip; + let delta = again.last_world - again.origin_world; + again.last_world = again.origin_world; + for target in &mut again.targets { + target.last_world -= delta; + } + self.tabs[i].active_grip = Some(again); + Task::none() + } + } } } @@ -598,4 +675,158 @@ mod tests { assert_eq!(lines, 0); }); } + + // ── Grip menu ───────────────────────────────────────────────────── + + /// A drawing with one line, selected, its start grip hot at the origin. + fn grip_app() -> (OpenCADStudio, Handle) { + use acadrust::{entities::Line, types::Vector3, EntityType}; + let mut app = app(); + app.snapper.snap_enabled = false; + app.snapper.grid_snap_on = false; + app.snapper.otrack_enabled = false; + app.ortho_mode = false; + app.polar_mode = false; + let handle = app.tabs[0].scene.add_entity(EntityType::Line(Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(10.0, 0.0, 0.0), + ))); + app.tabs[0].scene.selected.insert(handle); + app.refresh_selected_grips(); + app.tabs[0].active_grip = Some(crate::scene::pick::grip::GripEdit::single( + handle, + 0, + false, + DVec3::ZERO, + )); + (app, handle) + } + + fn move_cursor_to(app: &mut OpenCADStudio, world: DVec3) { + let cursor = app.tabs[0] + .scene + .camera + .borrow() + .project(world, iced::Rectangle::with_size(iced::Size::new(800.0, 600.0))) + .unwrap(); + let _ = app.on_viewport_move(Point::new(cursor.x, cursor.y)); + } + + fn line_start(app: &OpenCADStudio, handle: Handle) -> (f64, f64) { + match app.tabs[0].scene.document.get_entity(handle) { + Some(acadrust::EntityType::Line(l)) => (l.start.x, l.start.y), + other => panic!("{other:?}"), + } + } + + #[test] + fn rmb_on_hot_grip_opens_grip_menu() { + with_stack(|| { + let (mut app, _) = grip_app(); + right_click(&mut app); + assert!(menu_open(&app)); + let acts = menu_actions(&app); + assert_eq!(acts[0], MenuAction::Enter); + assert!(acts.contains(&MenuAction::Grip(GripMenuCmd::Move))); + assert!(acts.contains(&MenuAction::Grip(GripMenuCmd::Exit))); + assert!(app.tabs[0].active_grip.is_some(), "the grip stays hot under the menu"); + }); + } + + #[test] + fn grip_menu_move_dispatches_move_with_grip_base() { + with_stack(|| { + let (mut app, _) = grip_app(); + right_click(&mut app); + let _ = app.update(Message::ContextMenuPick(MenuAction::Grip(GripMenuCmd::Move))); + assert!(!menu_open(&app)); + assert!(app.tabs[0].active_grip.is_none()); + assert_eq!(active(&app), Some("MOVE")); + let prompt = app.tabs[0].active_cmd.as_ref().map(|c| c.prompt()).unwrap_or_default(); + assert!(!prompt.contains("base point"), "base already consumed: {prompt}"); + assert_eq!(app.last_point, Some(DVec3::ZERO)); + }); + } + + #[test] + fn grip_menu_exit_restores_original_geometry() { + with_stack(|| { + let (mut app, handle) = grip_app(); + move_cursor_to(&mut app, DVec3::new(3.0, 4.0, 0.0)); + let (x, y) = line_start(&app, handle); + assert!((x - 3.0).abs() < 1e-3 && (y - 4.0).abs() < 1e-3, "drag applied"); + right_click(&mut app); + let _ = app.update(Message::ContextMenuPick(MenuAction::Grip(GripMenuCmd::Exit))); + assert!(app.tabs[0].active_grip.is_none()); + let (x, y) = line_start(&app, handle); + assert!(x.abs() < 1e-9 && y.abs() < 1e-9, "Exit restores the original"); + }); + } + + #[test] + fn grip_menu_undo_snaps_back_but_stays_hot() { + with_stack(|| { + let (mut app, handle) = grip_app(); + move_cursor_to(&mut app, DVec3::new(3.0, 4.0, 0.0)); + right_click(&mut app); + let _ = app.update(Message::ContextMenuPick(MenuAction::Grip(GripMenuCmd::Undo))); + let (x, y) = line_start(&app, handle); + assert!(x.abs() < 1e-9 && y.abs() < 1e-9); + let grip = app.tabs[0].active_grip.as_ref().expect("grip still hot"); + assert_eq!(grip.last_world, grip.origin_world); + }); + } + + #[test] + fn grip_menu_copy_leaves_original_and_adds_copy() { + with_stack(|| { + let (mut app, handle) = grip_app(); + right_click(&mut app); + let _ = app.update(Message::ContextMenuPick(MenuAction::Grip(GripMenuCmd::CopyToggle))); + assert!(app.tabs[0].grip_copy); + move_cursor_to(&mut app, DVec3::new(3.0, 4.0, 0.0)); + let _ = app.update(Message::CommandFinalize); + let lines: Vec<(f64, f64)> = app.tabs[0] + .scene + .document + .entities() + .filter_map(|e| match e { + acadrust::EntityType::Line(l) => Some((l.start.x, l.start.y)), + _ => None, + }) + .collect(); + assert_eq!(lines.len(), 2, "{lines:?}"); + let (x, y) = line_start(&app, handle); + assert!(x.abs() < 1e-9 && y.abs() < 1e-9, "original untouched"); + assert!(lines.iter().any(|&(x, y)| (x - 3.0).abs() < 1e-3 && (y - 4.0).abs() < 1e-3)); + assert!(app.tabs[0].active_grip.is_some(), "grip re-armed for the next copy"); + // One undo step removes the copy again. + app.undo_steps(1); + let count = app.tabs[0] + .scene + .document + .entities() + .filter(|e| matches!(e, acadrust::EntityType::Line(_))) + .count(); + assert_eq!(count, 1); + }); + } + + #[test] + fn grip_menu_base_point_rebases_without_placing() { + with_stack(|| { + let (mut app, handle) = grip_app(); + right_click(&mut app); + let _ = app.update(Message::ContextMenuPick(MenuAction::Grip(GripMenuCmd::BasePoint))); + assert!(app.tabs[0].grip_base_pending); + move_cursor_to(&mut app, DVec3::new(1.0, 1.0, 0.0)); + let _ = app.update(Message::ViewportLeftPress); + let _ = app.update(Message::ViewportLeftRelease); + let grip = app.tabs[0].active_grip.as_ref().expect("grip still hot"); + assert_eq!(grip.origin_world, grip.last_world); + assert!(!app.tabs[0].grip_base_pending); + let (x, y) = line_start(&app, handle); + assert!((x - 1.0).abs() < 1e-3 && (y - 1.0).abs() < 1e-3); + }); + } } diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 8478a94b9..ae5ac70ac 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -3436,102 +3436,21 @@ impl OpenCADStudio { let mut sel = self.tabs[i].scene.selection.borrow_mut(); sel.clear_left_selection_gesture(); } + // Grip-menu Base Point: this click re-bases the gesture at the + // cursor instead of placing anything. + if self.tabs[i].grip_base_pending { + self.tabs[i].grip_base_pending = false; + if let Some(active) = self.tabs[i].active_grip.as_mut() { + active.origin_world = active.last_world; + } + return Task::none(); + } let moved = grip.last_world != grip.origin_world; if is_click && !moved { // Engaging click — stay hot, wait for the placement click. return Task::none(); } - if matches!( - grip.mode, - GripEditMode::Lengthen - | GripEditMode::Radius - | GripEditMode::ArcLength - | GripEditMode::RectangleWidth - | GripEditMode::RectangleHeight - | GripEditMode::MoveParallel - ) { - self.grip_pending = None; - self.command_line.input.clear(); - } - let added_vertex_focus = grip.targets.iter().find_map(|target| { - let original = self - .grip_originals - .iter() - .find(|(handle, _)| *handle == target.handle) - .map(|(_, entity)| entity)?; - let current = self.tabs[i].scene.document.get_entity(target.handle)?; - is_added_polyline_vertex(original, current, target.grip_id) - .then_some(target.grip_id) - }); - // Keep originals available until every history shape has a valid - // final display. A rejected rebuild cancels the entire gesture. - let history_handles: Vec<_> = self - .grip_preview_handles - .iter() - .copied() - .filter(|handle| { - self.tabs[i] - .scene - .document - .solid_history_operation(*handle) - .is_some() - }) - .collect(); - for handle in history_handles { - if !self.tabs[i].scene.finalize_solid_history(handle) { - self.cancel_active_grip_edit(); - self.command_line.push_error(crate::t!("The edited shape could not be displayed; the original geometry was restored.").as_ref()); - self.refresh_properties(); - return Task::none(); - } - } - self.tabs[i].active_grip = None; - // Commit the grip drag as one undoable group, then put every - // edited entity back into the resident tessellation. - let handles = std::mem::take(&mut self.grip_preview_handles); - let originals = std::mem::take(&mut self.grip_originals); - let history_originals = std::mem::take(&mut self.grip_history_originals); - let dirty_before = self.grip_dirty_before.take().unwrap_or(self.tabs[i].dirty); - if !handles.is_empty() { - if !originals.is_empty() { - self.push_entity_group_history( - i, - "GRIP", - originals - .into_iter() - .map(|(handle, entity)| (handle, std::sync::Arc::new(entity))) - .collect(), - history_originals - .into_iter() - .flat_map(|(_, objects)| objects) - .collect(), - dirty_before, - ); - self.tabs[i].dirty = true; - } - self.grip_reference_wires.clear(); - self.grip_text_verts = Vec::new(); - self.grip_text_slide = false; - for &handle in &handles { - self.tabs[i].scene.preview_hidden.remove(&handle); - } - self.tabs[i].scene.clear_preview_wire(); - let changes: Vec<_> = handles - .into_iter() - .map(|handle| (handle, crate::scene::ChangeKind::Modified)) - .collect(); - self.tabs[i].scene.bump_entities_after_parametric_solve(&changes); - } - // Placement confirmed — keep the just-added leader. - self.grip_add_provisional = None; - self.tabs[i].snap_result = None; - if let Some(vertex_id) = added_vertex_focus { - self.tabs[i].properties.prop_vertex = vertex_id; - self.tabs[i].properties.prop_vertex_indicator_active = true; - crate::scene::view::dispatch::set_prop_current_vertex(vertex_id); - } - self.refresh_properties(); - return Task::none(); + return self.commit_active_grip_edit(); } // Map the release point into the active Model tile so the @@ -6204,6 +6123,164 @@ properties={:.1}ms picked={}", } Task::none() } + + /// Commit the active grip edit at its current (already applied) position: + /// one undoable group per gesture, every edited entity back into the + /// resident tessellation. With the grip-menu Copy toggle on, the + /// originals are put back and the moved shapes are added as new + /// entities, and the grip stays hot for the next copy. + pub(in crate::app) fn commit_active_grip_edit(&mut self) -> Task { + let i = self.active_tab; + let Some(grip) = self.tabs[i].active_grip.clone() else { + return Task::none(); + }; + if grip.last_world == grip.origin_world { + // Nothing placed yet — the grip simply stays hot. + return Task::none(); + } + if self.tabs[i].grip_copy { + return self.commit_active_grip_edit_as_copy(grip); + } + self.tabs[i].grip_base_pending = false; + if matches!( + grip.mode, + GripEditMode::Lengthen + | GripEditMode::Radius + | GripEditMode::ArcLength + | GripEditMode::RectangleWidth + | GripEditMode::RectangleHeight + | GripEditMode::MoveParallel + ) { + self.grip_pending = None; + self.command_line.input.clear(); + } + let added_vertex_focus = grip.targets.iter().find_map(|target| { + let original = self + .grip_originals + .iter() + .find(|(handle, _)| *handle == target.handle) + .map(|(_, entity)| entity)?; + let current = self.tabs[i].scene.document.get_entity(target.handle)?; + is_added_polyline_vertex(original, current, target.grip_id) + .then_some(target.grip_id) + }); + // Keep originals available until every history shape has a valid + // final display. A rejected rebuild cancels the entire gesture. + let history_handles: Vec<_> = self + .grip_preview_handles + .iter() + .copied() + .filter(|handle| { + self.tabs[i] + .scene + .document + .solid_history_operation(*handle) + .is_some() + }) + .collect(); + for handle in history_handles { + if !self.tabs[i].scene.finalize_solid_history(handle) { + self.cancel_active_grip_edit(); + self.command_line.push_error(crate::t!("The edited shape could not be displayed; the original geometry was restored.").as_ref()); + self.refresh_properties(); + return Task::none(); + } + } + self.tabs[i].active_grip = None; + // Commit the grip drag as one undoable group, then put every + // edited entity back into the resident tessellation. + let handles = std::mem::take(&mut self.grip_preview_handles); + let originals = std::mem::take(&mut self.grip_originals); + let history_originals = std::mem::take(&mut self.grip_history_originals); + let dirty_before = self.grip_dirty_before.take().unwrap_or(self.tabs[i].dirty); + if !handles.is_empty() { + if !originals.is_empty() { + self.push_entity_group_history( + i, + "GRIP", + originals + .into_iter() + .map(|(handle, entity)| (handle, std::sync::Arc::new(entity))) + .collect(), + history_originals + .into_iter() + .flat_map(|(_, objects)| objects) + .collect(), + dirty_before, + ); + self.tabs[i].dirty = true; + } + self.grip_reference_wires.clear(); + self.grip_text_verts = Vec::new(); + self.grip_text_slide = false; + for &handle in &handles { + self.tabs[i].scene.preview_hidden.remove(&handle); + } + self.tabs[i].scene.clear_preview_wire(); + let changes: Vec<_> = handles + .into_iter() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities_after_parametric_solve(&changes); + } + // Placement confirmed — keep the just-added leader. + self.grip_add_provisional = None; + self.tabs[i].snap_result = None; + if let Some(vertex_id) = added_vertex_focus { + self.tabs[i].properties.prop_vertex = vertex_id; + self.tabs[i].properties.prop_vertex_indicator_active = true; + crate::scene::view::dispatch::set_prop_current_vertex(vertex_id); + } + self.refresh_properties(); + Task::none() + } + + /// Grip-menu Copy: leave the originals where they were and add the + /// edited shapes as new entities, then re-arm the same grip at its + /// origin so the next placement makes another copy (AutoCAD grip Copy). + fn commit_active_grip_edit_as_copy(&mut self, grip: GripEdit) -> Task { + let i = self.active_tab; + let handles = std::mem::take(&mut self.grip_preview_handles); + let originals = std::mem::take(&mut self.grip_originals); + let _ = std::mem::take(&mut self.grip_history_originals); + self.grip_dirty_before = None; + self.grip_add_provisional = None; + self.grip_reference_wires.clear(); + self.grip_text_verts = Vec::new(); + self.grip_text_slide = false; + for &handle in &handles { + self.tabs[i].scene.preview_hidden.remove(&handle); + } + self.tabs[i].scene.clear_preview_wire(); + if !originals.is_empty() { + self.push_undo_snapshot(i, "GRIP"); + let mut restored = Vec::with_capacity(originals.len()); + for (handle, original) in &originals { + let Some(moved) = self.tabs[i].scene.document.get_entity(*handle).cloned() else { + continue; + }; + if self.tabs[i].scene.update_entity(original.clone()) { + restored.push((*handle, crate::scene::ChangeKind::Modified)); + } + self.tabs[i].scene.add_entity_clone(moved); + } + self.tabs[i].scene.bump_entities_after_parametric_solve(&restored); + self.finish_pending_history(i); + self.tabs[i].dirty = true; + } + self.tabs[i].snap_result = None; + // Re-arm: the grip sits at its origin again over the restored shape. + let mut again = grip; + let delta = again.last_world - again.origin_world; + again.last_world = again.origin_world; + for target in &mut again.targets { + target.last_world -= delta; + } + self.tabs[i].active_grip = Some(again); + self.refresh_properties(); + Task::none() + } + } #[cfg(test)]