diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index fa833b75f..28cb3cd40 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -889,8 +889,19 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { } else { let (go, gr) = self.tabs[i].ucs_grid_basis(); self.snapper.from_point = self.last_point; - self.snapper - .snap(snap_cursor, p, &all_wires[..], view_rot, eye, bounds, go, gr) + let force_corners = self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.forces_endpoint_snap()) + .unwrap_or(false); + if force_corners { + self.snapper.snap_forced_corners( + snap_cursor, p, &all_wires[..], view_rot, eye, bounds, go, gr, + ) + } else { + self.snapper + .snap(snap_cursor, p, &all_wires[..], view_rot, eye, bounds, go, gr) + } }; // Object Snap Tracking: update dwell, then align the cursor diff --git a/src/command.rs b/src/command.rs index 3587d7e41..862f80d24 100644 --- a/src/command.rs +++ b/src/command.rs @@ -530,6 +530,13 @@ pub trait CadCommand: Send { false } + /// True if this command should have Endpoint/Intersection object-snap + /// forced on while it is active, regardless of the global OSNAP toggle. + /// Used by PLOTWINDOW so the plot-area corners always snap to geometry. + fn forces_endpoint_snap(&self) -> bool { + false + } + /// Called when the text editor closes, either because the user committed or cancelled the edit. fn on_editor_closed(&mut self, _committed: bool) -> CmdResult { CmdResult::Cancel diff --git a/src/modules/view/plot_window.rs b/src/modules/view/plot_window.rs index 5582329cf..5456e657e 100644 --- a/src/modules/view/plot_window.rs +++ b/src/modules/view/plot_window.rs @@ -22,6 +22,10 @@ impl CadCommand for PlotWindowCommand { "PLOTWINDOW" } + fn forces_endpoint_snap(&self) -> bool { + true + } + fn prompt(&self) -> String { if self.p1.is_none() { "PLOTWINDOW Specify first corner of plot window:".into() @@ -81,3 +85,14 @@ impl CadCommand for PlotWindowCommand { // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { names: &["PLOTWINDOW", "PW"] }); // PlotWindowCommand + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plot_window_forces_endpoint_snap() { + let cmd = PlotWindowCommand::new(); + assert!(cmd.forces_endpoint_snap()); + } +} diff --git a/src/snap.rs b/src/snap.rs index cac31adde..3daf758f7 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -1355,6 +1355,48 @@ impl Snapper { best } + + /// The exact object-snap mode set used while corner snap is forced. + /// + /// Endpoint only, deliberately: frame corners are line endpoints, so + /// Endpoint (a single O(n) pass over vertices) catches them. Intersection + /// snap is O(n²) over every pair of segments in the hit-test wire set — on a + /// large drawing viewed zoomed-out (exactly when a plot window is picked) + /// that froze the UI, so it is excluded from the forced set. + fn forced_corner_modes() -> HashSet { + let mut modes = HashSet::default(); + modes.insert(SnapType::Endpoint); + modes + } + + /// Run object snap with the active modes replaced by exactly Endpoint, + /// regardless of the global OSNAP master toggle or the user's configured + /// modes, then restore both afterward. Commands that always want corner + /// snapping (PLOTWINDOW) call this so the plot-area corners snap without + /// changing the user's global OSNAP settings. + #[allow(clippy::too_many_arguments)] + pub fn snap_forced_corners( + &mut self, + cursor_world: glam::DVec3, + cursor_screen: Point, + wires: &[WireModel], + view_rot: Mat4, + eye: glam::DVec3, + bounds: Rectangle, + grid_origin: Vec3, + grid_rot: Mat4, + ) -> Option { + let saved_master = self.snap_enabled; + let saved_modes = self.enabled.clone(); + self.snap_enabled = true; + self.enabled = Self::forced_corner_modes(); + let result = self.snap( + cursor_world, cursor_screen, wires, view_rot, eye, bounds, grid_origin, grid_rot, + ); + self.snap_enabled = saved_master; + self.enabled = saved_modes; + result + } } // ── Object-snap priority ─────────────────────────────────────────────────── @@ -1873,3 +1915,47 @@ mod ext_tests { assert!(circle_tangent_points(Vec3::new(1.0, 0.0, 0.0), c, 5.0).is_none()); } } + +#[cfg(test)] +mod forced_corner_tests { + use super::*; + + #[test] + fn snap_forced_corners_restores_state() { + let mut s = Snapper::default(); + // Master off, and a non-default configured mode set. + s.snap_enabled = false; + s.enabled.clear(); + s.enabled.insert(SnapType::Midpoint); + let before = s.enabled.clone(); + + // Empty wire set → no candidate → None; we only assert the state + // is untouched afterward (the risky save/restore path). + let r = s.snap_forced_corners( + glam::DVec3::ZERO, + Point::new(0.0, 0.0), + &[], + Mat4::IDENTITY, + glam::DVec3::ZERO, + Rectangle { x: 0.0, y: 0.0, width: 800.0, height: 600.0 }, + Vec3::ZERO, + Mat4::IDENTITY, + ); + + assert!(r.is_none()); + assert!(!s.snap_enabled, "master toggle must be restored to off"); + assert_eq!(s.enabled, before, "configured modes must be restored"); + } + + #[test] + fn forced_corner_modes_are_endpoint_only() { + let modes = Snapper::forced_corner_modes(); + assert!(modes.contains(&SnapType::Endpoint)); + // Intersection is O(n²) over the wire set and is deliberately excluded — + // forcing it froze the UI on large drawings. + assert!(!modes.contains(&SnapType::Intersection)); + assert!(!modes.contains(&SnapType::Midpoint)); + assert!(!modes.contains(&SnapType::Center)); + assert_eq!(modes.len(), 1); + } +}