From d93bcda889811d3ef5fcd6bcbccdfe5c6c9820c5 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 4 Jul 2026 23:30:12 +0300 Subject: [PATCH] fix(snap): OSNAP Extension tracks only acquired endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extension snap extended every segment's line in the whole drawing, so in a larger project an extension foot was live almost everywhere and the cursor snapped to it constantly. Extension now behaves like a temporary tracking point: an endpoint must first be acquired by dwelling on it, and only then does that segment's line extend. Acquisition — and the tracking-point marker — is active whenever OTRACK is on OR the Extension snap is on, so Extension works independently of OTRACK's on/off state (and is not tied to POLAR). Both the per-segment extension and the extended-intersection candidates are gated on an acquired endpoint, and the whole scan is skipped when nothing is acquired (the common case), keeping large drawings responsive. Closes #262 Co-Authored-By: Claude Opus 4.8 --- src/app/view/mod.rs | 2 +- src/snap.rs | 133 ++++++++++++++++++++++++++++++++++++-------- 2 files changed, 111 insertions(+), 24 deletions(-) diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index b1ff7434c..69483e3e7 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -340,7 +340,7 @@ impl OpenCADStudio { // Shared OTRACK projection basis: the active pane's camera + rect // (canvas offset included), matching the grips / UCS icon above. let otrack_proj: Option<(glam::Mat4, glam::DVec3, iced::Rectangle)> = - if self.snapper.otrack_enabled { + if self.snapper.tracking_active() { Some( if let Some((vp_cam, full)) = tab.scene.viewport_edit_frame((vw, vh)) { (vp_cam.view_proj_rte(full), vp_cam.eye(), full) diff --git a/src/snap.rs b/src/snap.rs index aac77cf19..55ada206c 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -166,6 +166,34 @@ impl Snapper { self.enabled.contains(&t) } + /// Whether temporary tracking points are being acquired and drawn: OTRACK + /// on, or the Extension object snap on. Extension tracks a segment's line + /// only from an acquired endpoint, and works independently of OTRACK's + /// on/off state (#262). + pub fn tracking_active(&self) -> bool { + self.otrack_enabled || (self.snap_enabled && self.is_on(SnapType::Extension)) + } + + /// True when `p` coincides with one of the acquired temporary tracking + /// points. Extension snaps a segment's line only from such acquired + /// endpoints (#262), so extensions aren't live for every object in the + /// drawing. The tolerance mirrors `edge_dirs_at`: acquired points are f32 + /// truncations of the true vertices, so the match window scales with + /// coordinate magnitude with a tight floor near the origin. + fn is_tracked_endpoint(&self, p: glam::DVec3) -> bool { + if self.tracking_points.is_empty() { + return false; + } + let pf = p.as_vec3(); + let tol = 1e-4_f32.max(4e-7 * pf.x.abs().max(pf.y.abs())); + let tol2 = tol * tol; + self.tracking_points.iter().any(|t| { + let dx = t.x - pf.x; + let dy = t.y - pf.y; + dx * dx + dy * dy < tol2 + }) + } + pub fn toggle_global(&mut self) { self.snap_enabled = !self.snap_enabled; } @@ -215,7 +243,11 @@ impl Snapper { bounds: iced::Rectangle, now: Instant, ) { - if !self.otrack_enabled { + // Temporary tracking points are acquired when OTRACK is on, OR when the + // Extension object snap is on: Extension tracks a segment's line only + // from an endpoint the user has acquired, independently of OTRACK's + // on/off state (#262). + if !self.tracking_active() { self.last_snap_world = None; self.dwell_since = None; self.dwell_acquired = false; @@ -861,8 +893,11 @@ impl Snapper { // ── Extension — along the extension of a segment beyond endpoints ── // Every segment's line can be extended past either endpoint, so a // polyline offers an extension off each of its vertices, not just the - // first and last (#259). - if self.is_on(SnapType::Extension) { + // first and last (#259). Extension is live only from endpoints the user + // has acquired as temporary tracking points (#262), so with none + // acquired there is nothing to extend — skip the whole scan. That is the + // common case and keeps a large drawing responsive. + if self.is_on(SnapType::Extension) && !self.tracking_points.is_empty() { for wire in wires { let n = wire.points.len(); if n < 2 { @@ -875,29 +910,36 @@ impl Snapper { if !a.x.is_finite() || !b.x.is_finite() || (a - b).length_squared() < 1e-18 { continue; } + // Only extend from an endpoint the user has acquired as a + // temporary tracking point, so the extension isn't live for + // every object in the drawing (#262). // Beyond `a`, away from `b`. - if let Some(ext) = extension_snap( - cursor_world, - a, - a - b, - view_rot, - eye, - bounds, - self.osnap_radius_px, - ) { - try_pt(ext, SnapType::Extension); + if self.is_tracked_endpoint(a) { + if let Some(ext) = extension_snap( + cursor_world, + a, + a - b, + view_rot, + eye, + bounds, + self.osnap_radius_px, + ) { + try_pt(ext, SnapType::Extension); + } } // Beyond `b`, away from `a`. - if let Some(ext) = extension_snap( - cursor_world, - b, - b - a, - view_rot, - eye, - bounds, - self.osnap_radius_px, - ) { - try_pt(ext, SnapType::Extension); + if self.is_tracked_endpoint(b) { + if let Some(ext) = extension_snap( + cursor_world, + b, + b - a, + view_rot, + eye, + bounds, + self.osnap_radius_px, + ) { + try_pt(ext, SnapType::Extension); + } } } } @@ -917,6 +959,11 @@ impl Snapper { if !a0.x.is_finite() || !a1.x.is_finite() { continue; } + // Only lines with an acquired endpoint contribute an extended + // crossing, matching the per-segment extension gate (#262). + if !self.is_tracked_endpoint(a0) && !self.is_tracked_endpoint(a1) { + continue; + } let s0 = world_to_screen(a0, view_rot, eye, bounds); let s1 = world_to_screen(a1, view_rot, eye, bounds); let ex = s1.x - s0.x; @@ -1461,3 +1508,43 @@ fn t_on_segment(p: Point, a: Point, b: Point) -> f32 { } (((p.x - a.x) * dx + (p.y - a.y) * dy) / len2).clamp(0.0, 1.0) } + +#[cfg(test)] +mod ext_tests { + use super::*; + + #[test] + fn tracking_active_covers_otrack_and_extension() { + let mut s = Snapper::default(); + // OTRACK off, Extension not enabled → no acquisition. + s.snap_enabled = true; + s.otrack_enabled = false; + assert!(!s.tracking_active()); + // Extension on with the snap master on → acquire, independent of OTRACK. + s.enabled.insert(SnapType::Extension); + assert!(s.tracking_active()); + // Extension is gated by the snap master. + s.snap_enabled = false; + assert!(!s.tracking_active()); + // OTRACK acquires regardless of the object-snap master. + s.enabled.remove(&SnapType::Extension); + s.otrack_enabled = true; + assert!(s.tracking_active()); + } + + #[test] + fn extension_only_tracks_acquired_endpoints() { + let mut s = Snapper::default(); + // Nothing acquired → no endpoint is a live extension source (#262). + assert!(!s.is_tracked_endpoint(glam::DVec3::new(10.0, 0.0, 0.0))); + // Acquire an endpoint → only that vertex tracks. + s.tracking_points.push(Vec3::new(10.0, 0.0, 0.0)); + assert!(s.is_tracked_endpoint(glam::DVec3::new(10.0, 0.0, 0.0))); + assert!(!s.is_tracked_endpoint(glam::DVec3::new(5.0, 0.0, 0.0))); + // The match tolerance scales with coordinate magnitude, so an acquired + // vertex at UTM scale still matches its f32-truncated tracking point. + let big = 1_234_567.0_f64; + s.tracking_points.push(Vec3::new(big as f32, 0.0, 0.0)); + assert!(s.is_tracked_endpoint(glam::DVec3::new(big, 0.0, 0.0))); + } +}