From 02e0960138d6aab00e5b97114f4d63364e58320b Mon Sep 17 00:00:00 2001 From: gianlucafiore Date: Thu, 20 Aug 2026 18:46:44 -0300 Subject: [PATCH] fix(snap): improve OTRACK and extension snapping --- src/app/update/viewport.rs | 150 +++++++++++++++++++++++++++++++++++-- src/snap.rs | 43 ++++++++++- 2 files changed, 185 insertions(+), 8 deletions(-) diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 089ddaac9..a945905ca 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -253,17 +253,44 @@ impl OpenCADStudio { eye: glam::DVec3, bounds: iced::Rectangle, ) -> Option { - if snap.is_some_and(|hit| hit.snap_type != crate::snap::SnapType::Extension) { + let intersection = snap.filter(|hit| { + hit.snap_type == crate::snap::SnapType::Intersection + }); + + // Ordinary object snaps suppress OTRACK. Extension and Intersection are + // exceptions: both may lie on an active tracking vector. + if snap.is_some_and(|hit| { + !matches!( + hit.snap_type, + crate::snap::SnapType::Extension | crate::snap::SnapType::Intersection + ) + }) { return None; } + let required_crossing_ray = match snap { - Some(extension) => Some((extension.extension_origin?, extension.extension_dir?)), - None => None, + Some(extension) + if extension.snap_type == crate::snap::SnapType::Extension => + { + Some(( + extension.extension_origin?, + extension.extension_dir?, + )) + } + _ => None, }; + + // When Intersection has already won OSNAP selection, test OTRACK at the + // exact intersection rather than at the free cursor position. + let track_cursor = intersection + .map(|hit| hit.world) + .unwrap_or(cursor); + let step = (self.polar_mode && drafting).then_some(self.polar_increment_deg); let (_, (ucs_x, ucs_y, _)) = self.drafting_grid_basis(tab); - let hit = self.snapper.otrack_snap( - cursor, + + let mut hit = self.snapper.otrack_snap( + track_cursor, view_rot, eye, bounds, @@ -274,6 +301,28 @@ impl OpenCADStudio { ucs_x.as_dvec3(), ucs_y.as_dvec3(), )?; + + if let Some(intersection) = intersection { + // Only keep the vector when it really passes through the highlighted + // intersection. This avoids showing an unrelated nearby tracking ray. + let ndc = view_rot.project_point3((hit.aligned - eye).as_vec3()); + let aligned_screen = iced::Point::new( + (ndc.x + 1.0) * 0.5 * bounds.width, + (1.0 - ndc.y) * 0.5 * bounds.height, + ); + + let dx = aligned_screen.x - intersection.screen.x; + let dy = aligned_screen.y - intersection.screen.y; + + if dx * dx + dy * dy > 4.0 { + return None; + } + + // OTRACK is only providing the visual/reference vector here. + // Intersection remains the exact picked point. + hit.aligned = intersection.world; + } + Some(hit) } @@ -1217,7 +1266,14 @@ impl OpenCADStudio { // Keep the entity's original geometry available for self-OSNAP, // Extension and OTRACK while the live grip preview is being deformed. self.grip_snap_wires = snap.clone(); - + // The user explicitly picked this grip, so treat its original position as an + // acquired tracking point immediately. This lets Extension/OTRACK use the + // original edge direction on the very first movement instead of requiring the + // cursor to return to the old grip position and dwell there first. + self.snapper.acquire_grip_tracking_point( + grip.origin_world, + &self.grip_snap_wires, + ); self.grip_text_verts = snap .iter() .flat_map(|w| w.text_verts.iter().copied()) @@ -1756,6 +1812,49 @@ impl OpenCADStudio { self.axis_lock_dir = None; None }; + // An acquired OTRACK ray can cross real drawing geometry even before the + // command has a first point. Probe the active tracking ray and let the normal + // snap engine evaluate that ray against nearby geometry as a construction ray. + // + // This makes OTRACK × entity crossings real Intersection snaps, so the normal + // dwell acquisition can subsequently turn the crossing into a tracking point. + if axis_lock.is_none() && !is_window_corner { + let tracking_probe = self.active_otrack_hit( + i, + cursor_world, + None, + self.last_point, + true, + view_rot, + eye, + bounds, + ); + + if let Some(track) = tracking_probe { + let (go, gr) = self.drafting_grid_basis(i); + + let tracked_snap = self.snapper.snap( + snap_cursor, + p, + &snap_candidates, + view_rot, + eye, + bounds, + go, + gr, + Some((track.base, track.base + track.dir)), + ); + + // The second pass exists only to discover a crossing between the + // active OTRACK ray and real geometry. Keep the original snap result + // for every other kind of snap. + if tracked_snap + .is_some_and(|hit| hit.snap_type == crate::snap::SnapType::Intersection) + { + self.tabs[i].snap_result = tracked_snap; + } + } + } self.snapper.update_otrack_dwell( self.tabs[i].snap_result, &snap_candidates, @@ -3055,7 +3154,7 @@ impl OpenCADStudio { .as_ref() .map(|c| c.needs_entity_pick()) .unwrap_or(false); - let snap_hit = if needs_entity_click { + let mut snap_hit = if needs_entity_click { None } else if needs_tan { self.snapper.snap_tangent_only( @@ -3097,6 +3196,43 @@ impl OpenCADStudio { construction_ray, ) }; + // Mirror the cursor-move OTRACK × geometry intersection pass when the + // point is actually clicked. The move path may already display the + // Intersection marker, but click handling recomputes snapping from scratch. + if !needs_entity_click && !needs_tan && !is_window_corner { + let tracking_probe = self.active_otrack_hit( + i, + raw, + None, + self.last_point, + true, + view_rot, + eye, + bounds, + ); + + if let Some(track) = tracking_probe { + let (go, gr) = self.drafting_grid_basis(i); + + let tracked_snap = self.snapper.snap( + snap_cursor, + p, + &snap_candidates, + view_rot, + eye, + bounds, + go, + gr, + Some((track.base, track.base + track.dir)), + ); + + if tracked_snap + .is_some_and(|hit| hit.snap_type == crate::snap::SnapType::Intersection) + { + snap_hit = tracked_snap; + } + } + } // Snap runs in model space; the result is already model. let mut pt = snap_hit.map(|s| s.world).unwrap_or(raw); // When no UCS is active clamp to world XY; with a UCS the point is diff --git a/src/snap.rs b/src/snap.rs index f6a010281..b34859f7b 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -382,7 +382,24 @@ impl Snapper { } } } + /// Immediately acquire an explicit grip origin as a tracking point. + /// + /// Unlike normal cursor acquisition, a grip was deliberately selected by the + /// user, so it should not require OTRACK dwell before Extension/OTRACK can use it. + pub fn acquire_grip_tracking_point( + &mut self, + p: DVec3, + wires: &W, + ) { + if !self.tracking_active() { + return; + } + // With OTRACK disabled, Extension acquisition must remain endpoint-only. + let endpoints_only = !self.otrack_enabled; + + self.acquire_tracking_point(p, wires, endpoints_only); + } /// Add `p` as a tracking point (capturing its corner edge directions) unless /// it is already tracked; drops the oldest when the 4-point cap is reached. /// Edge directions are scanned once here, at acquisition, so OTRACK can align @@ -1255,7 +1272,31 @@ impl Snapper { } } }; - + // Check engaged extension tracking rays against nearby real geometry. + // OTRACK can align the cursor to an acquired segment extension even when + // Extension OSNAP itself is not the current snap result. Allow Intersection + // to stop that active extension ray where it crosses drawing geometry. + if self.is_on(SnapType::Intersection) && self.otrack_enabled { + for (&origin, dirs) in self.tracking_points.iter().zip(&self.tracking_dirs) { + for &dir in dirs { + // Only test the extension ray while the cursor is actually + // engaged with it, so Intersection does not become Nearest. + if extension_snap( + cursor_world, + origin, + dir, + view_rot, + eye, + bounds, + self.osnap_radius_px, + ) + .is_some() + { + try_ray_intersections(origin, origin + dir); + } + } + } + } if self.is_on(SnapType::Intersection) { if let Some((origin, through)) = construction_ray { try_ray_intersections(origin, through);