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.
This commit is contained in:
yztink committed 2026-10-10 17:01:12 -07:00
1 parent 68646f5616
commit d5d6733d82
8 files changed
+287 -13

No files matched your search

+75
View File
@@ -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;
+22
View File
@@ -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-
+1
View File
@@ -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]
+24
View File
@@ -820,6 +820,28 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
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<Message> {
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<Message> {
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);
}
+2
View File
@@ -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() {
+2 -13
View File
@@ -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()
}
+24
View File
@@ -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
+137
View File
@@ -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<SnapOverrideKind> {
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);
}
}