From d5d6733d822524b3cb7554cce1a7d9d56d24ac62 Mon Sep 17 00:00:00 2001 From: yztink Date: Sat, 10 Oct 2026 17:01:12 -0700 Subject: [PATCH] feat(snap): add transparent object snap overrides in active commands Allow users to temporarily override object snaps during active point prompts by typing at least the first 3 letters of any snap mode (e.g. MID, END, CEN, PER, NON). The override activates for the next point pick only and automatically restores running snaps once consumed or cancelled. --- src/app/automation.rs | 75 +++++++++++++++ src/app/command_driver/utilities.rs | 22 +++++ src/app/commands/mod.rs | 1 + src/app/update/command.rs | 24 +++++ src/app/update/dynamic.rs | 2 + src/app/update/mod.rs | 15 +-- src/app/update/viewport_snap.rs | 24 +++++ src/snap.rs | 137 ++++++++++++++++++++++++++++ 8 files changed, 287 insertions(+), 13 deletions(-) diff --git a/src/app/automation.rs b/src/app/automation.rs index 088493ab7..c5b1143c8 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -3477,6 +3477,81 @@ mod tests { ); } + #[test] + fn test_snap_override_typed_in_line_command() { + use crate::app::Message; + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + { + app.tabs[0].scene.selection.borrow_mut().view.vp_size = (1920.0, 1080.0); + app.tabs[0].scene.sync_tiles_from_panes(1920.0, 1080.0); + } + + // Start LINE + let _ = app.update(Message::CommandInput("LINE".to_string())); + let _ = app.update(Message::CommandSubmit); + assert_eq!( + app.tabs[0].active_cmd.as_ref().map(|c| c.name()), + Some("LINE") + ); + + // Turn running osnap off to prove override temporarily enables snapping + app.snapper.snap_enabled = false; + assert!(!app.snapper.snap_enabled); + + // Type MID at first point prompt + let _ = app.update(Message::CommandInput("MID".to_string())); + let _ = app.update(Message::CommandSubmit); + + // LINE remains the active command + assert_eq!( + app.tabs[0].active_cmd.as_ref().map(|c| c.name()), + Some("LINE") + ); + // Snapper is now forced on with only Midpoint enabled + assert!(app.snapper.snap_enabled); + assert_eq!(app.snapper.enabled.len(), 1); + assert!(app.snapper.enabled.contains(&crate::snap::SnapType::Midpoint)); + + // Submit first point (0,0) + let _ = app.update(Message::CommandInput("0,0".to_string())); + let _ = app.update(Message::CommandSubmit); + + // Override is consumed by the point commit, restoring snap_enabled == false + assert!(!app.snapper.snap_enabled); + + // At next point prompt, type END + let _ = app.update(Message::CommandInput("END".to_string())); + let _ = app.update(Message::CommandSubmit); + assert!(app.snapper.snap_enabled); + assert!(app.snapper.enabled.contains(&crate::snap::SnapType::Endpoint)); + + // Escape cancels LINE and clears unconsumed snap override + let _ = app.update(Message::CommandEscape); + assert!(app.tabs[0].active_cmd.is_none()); + assert!(!app.snapper.snap_enabled); + } + + #[test] + fn test_snap_override_feed_token() { + use crate::app::Message; + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let _ = app.update(Message::CommandInput("LINE".to_string())); + let _ = app.update(Message::CommandSubmit); + + app.snapper.snap_enabled = false; + let _ = app.feed_active_cmd("CEN"); + assert!(app.snapper.snap_enabled); + assert_eq!(app.snapper.enabled.len(), 1); + assert!(app.snapper.enabled.contains(&crate::snap::SnapType::Center)); + + let _ = app.feed_active_cmd("5,5"); + assert!(!app.snapper.snap_enabled); + + let _ = app.update(Message::CommandEscape); + } + #[test] fn test_open_color_dropdown() { use crate::app::Message; diff --git a/src/app/command_driver/utilities.rs b/src/app/command_driver/utilities.rs index 84ee63d47..862d682f6 100644 --- a/src/app/command_driver/utilities.rs +++ b/src/app/command_driver/utilities.rs @@ -583,6 +583,7 @@ impl OpenCADStudio { if !self.record_accepted_snap(i, None, None, *point) { return (Task::none(), true); } + self.snapper.clear_override(); } if default_start { let StepInput::Point(point) = &input else { @@ -1113,6 +1114,27 @@ impl OpenCADStudio { return Task::none(); } } + if let Some(override_kind) = crate::snap::parse_snap_override(token) { + let is_point_step = !self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.input_kind().wants_text()) + .unwrap_or(true) + || self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.point_step_accepts_keywords()) + .unwrap_or(false); + let not_entity_pick = !self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.needs_entity_pick()) + .unwrap_or(false); + if is_point_step && not_entity_pick { + self.apply_snap_override(override_kind); + return Task::none(); + } + } if let Some((coord, kind)) = crate::app::helpers::parse_coord(token) { // Match the GUI command line: typed coordinates are in the active // UCS (relative offsets are rotated by the UCS axes), so a multi- diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 72c664de6..94cae6512 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -74,6 +74,7 @@ impl OpenCADStudio { // A new command collects its own points, so the previous command's // accepted snaps must not leak into it. self.clear_accepted_snaps(); + self.snapper.clear_override(); // Starting a command restarts the right-click cycle, so its first // right-click acts as Enter rather than opening the context menu. self.tabs[i] diff --git a/src/app/update/command.rs b/src/app/update/command.rs index a54f841f1..4a423c97e 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -820,6 +820,28 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { return self.focus_cmd_input(); } } + if let Some(override_kind) = crate::snap::parse_snap_override(&raw) { + let is_point_step = !self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.input_kind().wants_text()) + .unwrap_or(true) + || self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.point_step_accepts_keywords()) + .unwrap_or(false); + let not_entity_pick = !self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.needs_entity_pick()) + .unwrap_or(false); + if is_point_step && not_entity_pick { + self.command_line.input.clear(); + self.apply_snap_override(override_kind); + return self.focus_cmd_input(); + } + } let text = if free_text { raw } else { @@ -918,6 +940,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.dyn_coord_absolute = false; self.sync_dyn_fields(); self.reset_tracking_after_point(); + self.snapper.clear_override(); self.push_ucs_to_cmd(i); let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(wcs_pt)); if let Some(r) = result { @@ -1030,6 +1053,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.dyn_coord_absolute = false; self.sync_dyn_fields(); self.reset_tracking_after_point(); + self.snapper.clear_override(); if let Some((_, dir)) = distance_ray { self.snapper.remember_distance_reference(dir); } diff --git a/src/app/update/dynamic.rs b/src/app/update/dynamic.rs index c9bf42f23..2df141801 100644 --- a/src/app/update/dynamic.rs +++ b/src/app/update/dynamic.rs @@ -679,6 +679,7 @@ impl OpenCADStudio { self.dyn_coord_absolute = false; self.sync_dyn_fields(); self.reset_tracking_after_point(); + self.snapper.clear_override(); self.snapper.remember_distance_reference(dir); self.push_ucs_to_cmd(i); let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(pt)); @@ -752,6 +753,7 @@ impl OpenCADStudio { self.dyn_coord_absolute = false; self.sync_dyn_fields(); self.reset_tracking_after_point(); + self.snapper.clear_override(); self.push_ucs_to_cmd(i); let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(pt)); for f in self.tabs[i].dyn_fields.iter_mut() { diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index d11a087ed..1b42ca778 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -940,23 +940,12 @@ impl OpenCADStudio { } Message::SnapOverridePick(t) => { - self.snap_override_popup = None; - self.snapper.set_override(t); - let label = crate::snap::ALL_SNAP_MODES - .iter() - .find(|(m, _, _)| *m == t) - .map(|(_, _, l)| *l) - .unwrap_or("Snap"); - self.command_line - .push_info(crate::tf!("Snap override: {label} (next pick only).").as_ref()); + self.apply_snap_override(crate::snap::SnapOverrideKind::Mode(t)); Task::none() } Message::SnapOverrideNone => { - self.snap_override_popup = None; - self.snapper.set_override_none(); - self.command_line - .push_info(crate::t!("Snap override: None (next pick only).").as_ref()); + self.apply_snap_override(crate::snap::SnapOverrideKind::None); Task::none() } diff --git a/src/app/update/viewport_snap.rs b/src/app/update/viewport_snap.rs index c9dc920f2..23cf7f36e 100644 --- a/src/app/update/viewport_snap.rs +++ b/src/app/update/viewport_snap.rs @@ -50,6 +50,30 @@ impl OpenCADStudio { self.accepted_snaps.clear(); } + /// Apply a transparent one-shot snap override (from typed input or the context menu). + pub(crate) fn apply_snap_override(&mut self, kind: crate::snap::SnapOverrideKind) { + match kind { + crate::snap::SnapOverrideKind::Mode(t) => { + self.snap_override_popup = None; + self.snapper.set_override(t); + let label = crate::snap::ALL_SNAP_MODES + .iter() + .chain(crate::snap::ALL_3D_SNAP_MODES.iter()) + .find(|(m, _, _)| *m == t) + .map(|(_, _, l)| *l) + .unwrap_or("Snap"); + self.command_line + .push_info(crate::tf!("Snap override: {label} (next pick only).").as_ref()); + } + crate::snap::SnapOverrideKind::None => { + self.snap_override_popup = None; + self.snapper.set_override_none(); + self.command_line + .push_info(crate::t!("Snap override: None (next pick only).").as_ref()); + } + } + } + /// Record the snap a point step just accepted. `snap` is the displayed snap /// result (already in the current space's coordinates) or `None` for a free /// / typed point; `committed` is the point the command actually received diff --git a/src/snap.rs b/src/snap.rs index 78d11b6ac..f8de4fc8b 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -115,6 +115,76 @@ pub const ALL_3D_SNAP_MODES: &[(SnapType, &str, &str)] = &[ (SnapType::NearestFace, "✦", "Nearest to face"), ]; +/// Transparent object snap override kind (single-pick override). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SnapOverrideKind { + Mode(SnapType), + None, +} + +/// Parses a transparent object snap override keyword (e.g. `MID`, `END`, `CEN`, `NON`). +/// +/// Requires at least 3 letters matching a supported snap mode or alias. +/// Leading underscores (e.g. `_MID`) and case variations are permitted. +pub fn parse_snap_override(input: &str) -> Option { + let s = input.trim().trim_start_matches('_').to_ascii_lowercase(); + if s.len() < 3 { + return None; + } + if s.starts_with("end") && "endpoint".starts_with(&s) { + return Some(SnapOverrideKind::Mode(SnapType::Endpoint)); + } + if s.starts_with("mid") && "midpoint".starts_with(&s) { + return Some(SnapOverrideKind::Mode(SnapType::Midpoint)); + } + if s.starts_with("cen") && ("center".starts_with(&s) || "centre".starts_with(&s)) { + return Some(SnapOverrideKind::Mode(SnapType::Center)); + } + if (s.starts_with("gce") && "gcen".starts_with(&s)) + || (s.starts_with("geo") && ("geocenter".starts_with(&s) || "geometriccenter".starts_with(&s))) + { + return Some(SnapOverrideKind::Mode(SnapType::GeometricCenter)); + } + if s.starts_with("nod") && "node".starts_with(&s) { + return Some(SnapOverrideKind::Mode(SnapType::Node)); + } + if s.starts_with("qua") && "quadrant".starts_with(&s) { + return Some(SnapOverrideKind::Mode(SnapType::Quadrant)); + } + if s.starts_with("int") && "intersection".starts_with(&s) { + return Some(SnapOverrideKind::Mode(SnapType::Intersection)); + } + if s.starts_with("ext") && "extension".starts_with(&s) { + return Some(SnapOverrideKind::Mode(SnapType::Extension)); + } + if s.starts_with("ins") && ("insertion".starts_with(&s) || "insert".starts_with(&s)) { + return Some(SnapOverrideKind::Mode(SnapType::Insertion)); + } + if s.starts_with("per") && "perpendicular".starts_with(&s) { + return Some(SnapOverrideKind::Mode(SnapType::Perpendicular)); + } + if s.starts_with("tan") && "tangent".starts_with(&s) { + return Some(SnapOverrideKind::Mode(SnapType::Tangent)); + } + if s.starts_with("nea") && "nearest".starts_with(&s) { + return Some(SnapOverrideKind::Mode(SnapType::Nearest)); + } + if s.starts_with("app") + && ("apparent".starts_with(&s) + || "apparentintersection".starts_with(&s) + || "appint".starts_with(&s)) + { + return Some(SnapOverrideKind::Mode(SnapType::ApparentIntersection)); + } + if s.starts_with("par") && "parallel".starts_with(&s) { + return Some(SnapOverrideKind::Mode(SnapType::Parallel)); + } + if s.starts_with("non") && "none".starts_with(&s) { + return Some(SnapOverrideKind::None); + } + None +} + // ── Snap result ─────────────────────────────────────────────────────────── #[derive(Debug, Clone, Copy)] @@ -5197,4 +5267,71 @@ mod ext_tests { ); assert!(res_break.is_none(), "cursor over ellipse arc break must NOT snap to tangent"); } + + #[test] + fn test_parse_snap_override() { + use super::{parse_snap_override, SnapOverrideKind, SnapType}; + + // 3-letter prefixes + assert_eq!(parse_snap_override("end"), Some(SnapOverrideKind::Mode(SnapType::Endpoint))); + assert_eq!(parse_snap_override("mid"), Some(SnapOverrideKind::Mode(SnapType::Midpoint))); + assert_eq!(parse_snap_override("cen"), Some(SnapOverrideKind::Mode(SnapType::Center))); + assert_eq!(parse_snap_override("nod"), Some(SnapOverrideKind::Mode(SnapType::Node))); + assert_eq!(parse_snap_override("qua"), Some(SnapOverrideKind::Mode(SnapType::Quadrant))); + assert_eq!(parse_snap_override("int"), Some(SnapOverrideKind::Mode(SnapType::Intersection))); + assert_eq!(parse_snap_override("ext"), Some(SnapOverrideKind::Mode(SnapType::Extension))); + assert_eq!(parse_snap_override("ins"), Some(SnapOverrideKind::Mode(SnapType::Insertion))); + assert_eq!(parse_snap_override("per"), Some(SnapOverrideKind::Mode(SnapType::Perpendicular))); + assert_eq!(parse_snap_override("tan"), Some(SnapOverrideKind::Mode(SnapType::Tangent))); + assert_eq!(parse_snap_override("nea"), Some(SnapOverrideKind::Mode(SnapType::Nearest))); + assert_eq!(parse_snap_override("app"), Some(SnapOverrideKind::Mode(SnapType::ApparentIntersection))); + assert_eq!(parse_snap_override("par"), Some(SnapOverrideKind::Mode(SnapType::Parallel))); + assert_eq!(parse_snap_override("non"), Some(SnapOverrideKind::None)); + assert_eq!(parse_snap_override("geo"), Some(SnapOverrideKind::Mode(SnapType::GeometricCenter))); + assert_eq!(parse_snap_override("gce"), Some(SnapOverrideKind::Mode(SnapType::GeometricCenter))); + + // Case insensitivity & leading underscores + assert_eq!(parse_snap_override("_MID"), Some(SnapOverrideKind::Mode(SnapType::Midpoint))); + assert_eq!(parse_snap_override("_Endpoint"), Some(SnapOverrideKind::Mode(SnapType::Endpoint))); + assert_eq!(parse_snap_override(" _NON "), Some(SnapOverrideKind::None)); + assert_eq!(parse_snap_override("CEN"), Some(SnapOverrideKind::Mode(SnapType::Center))); + + // Full names and aliases + assert_eq!(parse_snap_override("endpoint"), Some(SnapOverrideKind::Mode(SnapType::Endpoint))); + assert_eq!(parse_snap_override("midpoint"), Some(SnapOverrideKind::Mode(SnapType::Midpoint))); + assert_eq!(parse_snap_override("center"), Some(SnapOverrideKind::Mode(SnapType::Center))); + assert_eq!(parse_snap_override("centre"), Some(SnapOverrideKind::Mode(SnapType::Center))); + assert_eq!(parse_snap_override("quadrant"), Some(SnapOverrideKind::Mode(SnapType::Quadrant))); + assert_eq!(parse_snap_override("intersection"), Some(SnapOverrideKind::Mode(SnapType::Intersection))); + assert_eq!(parse_snap_override("perpendicular"), Some(SnapOverrideKind::Mode(SnapType::Perpendicular))); + assert_eq!(parse_snap_override("tangent"), Some(SnapOverrideKind::Mode(SnapType::Tangent))); + assert_eq!(parse_snap_override("nearest"), Some(SnapOverrideKind::Mode(SnapType::Nearest))); + assert_eq!(parse_snap_override("apparent"), Some(SnapOverrideKind::Mode(SnapType::ApparentIntersection))); + assert_eq!(parse_snap_override("apparentintersection"), Some(SnapOverrideKind::Mode(SnapType::ApparentIntersection))); + assert_eq!(parse_snap_override("appint"), Some(SnapOverrideKind::Mode(SnapType::ApparentIntersection))); + assert_eq!(parse_snap_override("parallel"), Some(SnapOverrideKind::Mode(SnapType::Parallel))); + assert_eq!(parse_snap_override("insert"), Some(SnapOverrideKind::Mode(SnapType::Insertion))); + assert_eq!(parse_snap_override("insertion"), Some(SnapOverrideKind::Mode(SnapType::Insertion))); + assert_eq!(parse_snap_override("none"), Some(SnapOverrideKind::None)); + assert_eq!(parse_snap_override("gcen"), Some(SnapOverrideKind::Mode(SnapType::GeometricCenter))); + assert_eq!(parse_snap_override("geometriccenter"), Some(SnapOverrideKind::Mode(SnapType::GeometricCenter))); + assert_eq!(parse_snap_override("geocenter"), Some(SnapOverrideKind::Mode(SnapType::GeometricCenter))); + + // Rejection of strings shorter than 3 chars + assert_eq!(parse_snap_override("en"), None); + assert_eq!(parse_snap_override("mi"), None); + assert_eq!(parse_snap_override("ce"), None); + assert_eq!(parse_snap_override("p"), None); + assert_eq!(parse_snap_override("no"), None); + assert_eq!(parse_snap_override("_m"), None); + assert_eq!(parse_snap_override(""), None); + + // Non-matching tokens + assert_eq!(parse_snap_override("line"), None); + assert_eq!(parse_snap_override("circle"), None); + assert_eq!(parse_snap_override("10,20"), None); + assert_eq!(parse_snap_override("@5<45"), None); + assert_eq!(parse_snap_override("undo"), None); + assert_eq!(parse_snap_override("close"), None); + } }