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)]