From 6cd422e5b943a459b1a2be42c71ae9587847aed7 Mon Sep 17 00:00:00 2001 From: sLuCHa <142535358+sLuCHaa@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:38:56 +0200 Subject: [PATCH] feat(plot): snap plot-window corners to geometry endpoints PLOTWINDOW / PW now forces Endpoint object-snap while picking its two corners, independent of the global OSNAP toggle, so the plot area lands exactly on frame corners. The snapper's master toggle and configured modes are saved and restored around the pick, so the user's OSNAP settings are unchanged afterward. - CadCommand::forces_endpoint_snap() opt-in (default false; PlotWindowCommand true). - Snapper::snap_forced_corners() forces Endpoint only, then restores state. Intersection is deliberately excluded: it is O(n^2) over the hit-test wire set and froze the UI on large drawings when a plot window is picked zoomed-out. Frame corners are endpoints, so Endpoint alone suffices. - the cursor-move handler routes the plot-window pick through the forced path. Works in model and paper space (shared pick path). Tests: opt-in flag; forced mode set is endpoint-only; snapper state is restored after the call. --- src/app/update/viewport.rs | 15 +++++- src/command.rs | 7 +++ src/modules/view/plot_window.rs | 15 ++++++ src/snap.rs | 86 +++++++++++++++++++++++++++++++++ 4 files changed, 121 insertions(+), 2 deletions(-) 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); + } +}