From 0322128f48c33080c363a9d5a7a907a0921201c1 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 3 Jul 2026 17:46:08 +0300 Subject: [PATCH] fix(snap): extension guides stay visible at extended intersections (#247, #259) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extension osnap now offers a ray off every polyline vertex, not just the first and last, and snaps to where two segments' extension lines would cross. That crossing is emitted as an Intersection so it outranks the per-segment extension feet on their own lines. When it wins, both contributing extensions are re-detected — off-ray distance measured in screen space so the tolerance holds at UTM coordinates — and the overlay draws a dashed guide from each endpoint through the crossing, so both extension paths remain visible instead of vanishing when caught. Co-Authored-By: Claude Opus 4.8 --- src/app/update/viewport.rs | 1 + src/app/view/mod.rs | 2 + src/snap.rs | 200 +++++++++++++++++++++++++++---------- src/ui/overlay.rs | 32 ++++++ 4 files changed, 183 insertions(+), 52 deletions(-) diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index eb3807e3e..7fee61977 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -1040,6 +1040,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { snap_type: SnapType::ObjectPick, tangent_obj: None, extension_base: None, + extension_base2: None, }); if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { cmd.set_acquisition_hint(Some(pick.label)); diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 29b120bca..a9e4f5de3 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -194,6 +194,7 @@ impl OpenCADStudio { let sel = tab.scene.selection.borrow().clone(); let snap_info = tab.snap_result.map(|s| (s.screen, s.snap_type)); let snap_ext_base = tab.snap_result.and_then(|s| s.extension_base); + let snap_ext_base2 = tab.snap_result.and_then(|s| s.extension_base2); let grips: Vec = if tab.active_cmd.is_none() && !tab.selected_grips.is_empty() { @@ -399,6 +400,7 @@ impl OpenCADStudio { sel, snap_info, snap_ext_base, + snap_ext_base2, grips, ucs_icons, ost_points, diff --git a/src/snap.rs b/src/snap.rs index 7bc292640..5c18198ba 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -68,6 +68,11 @@ pub struct SnapResult { /// overlay can draw the dashed extension guide line back to it. `None` for /// every other snap type. (#238) pub extension_base: Option, + /// Second extension-guide base, set only for an extended intersection + /// (`snap_type == Intersection` where two extension lines cross): the + /// overlay draws a dashed guide from each base to the crossing so both + /// contributing extensions stay visible. `None` otherwise. (#247, #259) + pub extension_base2: Option, } /// Object-snap-tracking alignment: the cursor projected onto a ray from an @@ -528,6 +533,7 @@ impl Snapper { snap_type: SnapType::Grid, tangent_obj: None, extension_base: None, + extension_base2: None, }); best_rank = snap_priority(SnapType::Grid); best_d2 = d2; @@ -602,6 +608,7 @@ impl Snapper { snap_type, tangent_obj: None, extension_base: None, + extension_base2: None, }); } }; @@ -746,20 +753,39 @@ 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) { for wire in wires { let n = wire.points.len(); if n < 2 { continue; } - // Extend beyond the first point. - { - let p0 = wp_f64(wire, 0); - let p1 = wp_f64(wire, 1); + for i in 0..n - 1 { + let a = wp_f64(wire, i); + let b = wp_f64(wire, i + 1); + // NaN sentinels separate sub-paths — skip a segment spanning one. + if !a.x.is_finite() || !b.x.is_finite() || (a - b).length_squared() < 1e-18 { + continue; + } + // Beyond `a`, away from `b`. if let Some(ext) = extension_snap( cursor_world, - p0, - p0 - p1, + 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, @@ -768,21 +794,58 @@ impl Snapper { try_pt(ext, SnapType::Extension); } } - // Extend beyond the last point. - { - let p_last = wp_f64(wire, n - 1); - let p_prev = wp_f64(wire, n - 2); - if let Some(ext) = extension_snap( - cursor_world, - p_last, - p_last - p_prev, - view_rot, - eye, - bounds, - self.osnap_radius_px, - ) { - try_pt(ext, SnapType::Extension); + } + + // Extended intersection: where two segments would cross if their + // lines were extended. That crossing can be far from both segments, + // so `wire_in_range` (near-cursor segment) is the wrong gate — gather + // segments whose *infinite line* passes near the cursor instead, then + // pair them. A crossing inside both segments is a real Intersection, + // so skip it here (#247). + let filter2 = (self.osnap_radius_px * 2.0).powi(2); + let mut cand: Vec<(glam::DVec3, glam::DVec3)> = Vec::new(); + for wire in wires { + for k in 0..wire.points.len().saturating_sub(1) { + let a0 = wp_f64(wire, k); + let a1 = wp_f64(wire, k + 1); + if !a0.x.is_finite() || !a1.x.is_finite() { + 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; + let ey = s1.y - s0.y; + let l2 = ex * ex + ey * ey; + if l2 < 1e-6 { + continue; + } + // Perpendicular screen distance² from the cursor to the line. + let cross = ex * (cursor_screen.y - s0.y) - ey * (cursor_screen.x - s0.x); + if cross * cross / l2 <= filter2 { + cand.push((a0, a1)); + } + } + } + for i in 0..cand.len() { + let (a0, a1) = cand[i]; + let d1 = a1 - a0; + for &(b0, b1) in cand.iter().skip(i + 1) { + let d2 = b1 - b0; + let denom = d1.x * d2.y - d1.y * d2.x; + if denom.abs() < 1e-12 { + continue; // parallel + } + let t1 = ((b0.x - a0.x) * d2.y - (b0.y - a0.y) * d2.x) / denom; + let t2 = ((b0.x - a0.x) * d1.y - (b0.y - a0.y) * d1.x) / denom; + if (0.0..=1.0).contains(&t1) && (0.0..=1.0).contains(&t2) { + continue; // real crossing — handled by Intersection + } + // Emit as an Intersection, not an Extension: the crossing is + // a distinct point and must outrank the per-segment extension + // feet (which sit closer to the cursor on their own lines), + // or the cursor would snap to a line instead of the crossing. + let pt = glam::DVec3::new(a0.x + t1 * d1.x, a0.y + t1 * d1.y, a0.z); + try_pt(pt, SnapType::Intersection); } } } @@ -893,6 +956,7 @@ impl Snapper { snap_type: SnapType::Tangent, tangent_obj: Some(tangent_obj), extension_base: None, + extension_base2: None, }); } } @@ -944,18 +1008,22 @@ impl Snapper { snap_type: SnapType::Center, tangent_obj: None, extension_base: None, + extension_base2: None, }); } } } - // If an Extension snap won, re-find the endpoint whose ray it lies on - // so the overlay can draw the dashed extension guide line back to it. - // The snapped point is on some endpoint's extension by construction, so - // this just identifies which one. (#238) + // If an Extension snap or an extended intersection won, re-find the + // endpoint(s) whose ray(s) it lies on so the overlay can draw the dashed + // guide line(s) back to them. An Extension yields one base; an extended + // intersection yields both crossing extensions. A genuine on-segment + // intersection yields none, so its guides simply don't draw. (#238, #247, #259) if let Some(b) = best.as_mut() { - if b.snap_type == SnapType::Extension { - b.extension_base = extension_base_screen(b.world, wires, view_rot, eye, bounds); + if matches!(b.snap_type, SnapType::Extension | SnapType::Intersection) { + let (b1, b2) = extension_bases_screen(b.world, wires, view_rot, eye, bounds); + b.extension_base = b1; + b.extension_base2 = b2; } } @@ -1117,47 +1185,75 @@ fn extension_snap( Some(world_pt) } -/// Given a finalized Extension snap point, return the screen position of the -/// endpoint whose extension ray it lies on — for the dashed guide line (#238). -/// Mirrors [`extension_snap`]'s endpoint/direction choice (beyond the first or -/// last vertex); the nearest exactly-on-ray endpoint wins on the rare tie. -fn extension_base_screen( +/// Find the endpoint(s) whose outward extension the snapped point lies on, and +/// return their screen positions so the overlay can draw a dashed guide from +/// each back to the snap point. A lone Extension snap yields one base; an +/// extended intersection (two extension lines crossing) yields both — so both +/// contributing extensions stay drawn when the crossing is caught. A genuine +/// on-segment intersection yields none: its crossing is between the endpoints, +/// never past them (`t < 0.05`). (#238, #247, #259) +fn extension_bases_screen( snapped: glam::DVec3, wires: &[WireModel], view_rot: Mat4, eye: glam::DVec3, bounds: Rectangle, -) -> Option { - let mut best: Option<(f64, glam::DVec3)> = None; +) -> (Option, Option) { + let snapped_screen = world_to_screen(snapped, view_rot, eye, bounds); + // Collect qualifying endpoints, off-ray distance measured in screen space so + // the tolerance stays scale-independent at UTM coordinates (a world² test + // would reject the crossing base once coordinates reach ~1e7). + let mut found: Vec<(f32, glam::DVec3, Point)> = Vec::new(); for wire in wires { let n = wire.points.len(); if n < 2 { continue; } - for (origin, prev) in [ - (wp_f64(wire, 0), wp_f64(wire, 1)), - (wp_f64(wire, n - 1), wp_f64(wire, n - 2)), - ] { - let dir = origin - prev; - let len2 = dir.x * dir.x + dir.y * dir.y; - if len2 < 1e-12 { + // Match the extension snap: every segment can be extended past either + // endpoint, so scan them all to find the base(s) the snapped point sits on. + for i in 0..n - 1 { + let a = wp_f64(wire, i); + let b = wp_f64(wire, i + 1); + if !a.x.is_finite() || !b.x.is_finite() { continue; } - let t = ((snapped.x - origin.x) * dir.x + (snapped.y - origin.y) * dir.y) / len2; - if t < 0.05 { - continue; // must be beyond the endpoint, matching extension_snap - } - // Off-ray distance²: ~0 means the snapped point genuinely lies on - // this endpoint's extension. - let on_x = origin.x + t * dir.x; - let on_y = origin.y + t * dir.y; - let off = (on_x - snapped.x).powi(2) + (on_y - snapped.y).powi(2); - if off < 1e-9 && best.map_or(true, |(bo, _)| off < bo) { - best = Some((off, origin)); + for (origin, other) in [(a, b), (b, a)] { + let dir = origin - other; + let len2 = dir.x * dir.x + dir.y * dir.y; + if len2 < 1e-12 { + continue; + } + let t = ((snapped.x - origin.x) * dir.x + (snapped.y - origin.y) * dir.y) / len2; + if t < 0.05 { + continue; // must be beyond the endpoint, matching extension_snap + } + let on = glam::DVec3::new(origin.x + t * dir.x, origin.y + t * dir.y, origin.z); + let off = dist2(world_to_screen(on, view_rot, eye, bounds), snapped_screen); + if off <= 4.0 { + let base = world_to_screen(origin, view_rot, eye, bounds); + found.push((off, origin, base)); + } } } } - best.map(|(_, origin)| world_to_screen(origin, view_rot, eye, bounds)) + // Nearest-fit first, then keep up to two with distinct origins (collinear + // segments sharing an endpoint must not draw the same guide twice). + found.sort_by(|x, y| x.0.partial_cmp(&y.0).unwrap_or(std::cmp::Ordering::Equal)); + let mut bases: [Option; 2] = [None, None]; + let mut origins: Vec = Vec::new(); + for (_, origin, base) in found { + if origins.iter().any(|o| (*o - origin).length_squared() < 1e-12) { + continue; + } + origins.push(origin); + if bases[0].is_none() { + bases[0] = Some(base); + } else { + bases[1] = Some(base); + break; + } + } + (bases[0], bases[1]) } // ── Projection helpers ──────────────────────────────────────────────────── diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index eac35fb7f..bae579367 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -181,6 +181,7 @@ pub fn selection_overlay<'a>( selection: SelectionState, snap: Option<(Point, SnapType)>, snap_ext_base: Option, + snap_ext_base2: Option, grips: Vec, ucs_icons: Vec, ost_points: Vec, @@ -197,6 +198,7 @@ pub fn selection_overlay<'a>( selection, snap, snap_ext_base, + snap_ext_base2, grips, ucs_icons, ost_points, @@ -220,6 +222,9 @@ struct SelectionCanvas { /// Screen position of the endpoint an active Extension snap extends from, /// so the dashed extension guide line can be drawn back to it. (#238) snap_ext_base: Option, + /// Second extension-guide base, present only for an extended intersection so + /// both crossing extensions stay drawn when the crossing is caught. (#247, #259) + snap_ext_base2: Option, grips: Vec, /// One UCS icon per Model pane (each viewport shows its own at its origin); /// a single entry for paper / floating-viewport. Only the active pane's @@ -706,6 +711,33 @@ impl canvas::Program for SelectionCanvas { frame.stroke(&path, stroke); } SnapType::Intersection => { + // An extended intersection sets one or two extension bases; + // draw a dashed guide from each endpoint through the crossing + // so both contributing extension paths stay visible. A real + // on-segment crossing carries no bases, so nothing draws here. + // (#247, #259) + for base in [self.snap_ext_base, self.snap_ext_base2] + .into_iter() + .flatten() + { + let dx = sp.x - base.x; + let dy = sp.y - base.y; + let len = (dx * dx + dy * dy).sqrt(); + if len > 1e-3 { + let dash = canvas::Stroke { + line_dash: canvas::LineDash { + segments: &[4.0, 4.0], + offset: 0, + }, + ..canvas::Stroke::default().with_color(marker).with_width(1.0) + }; + let tip = Point::new( + sp.x + dx / len * 18.0, + sp.y + dy / len * 18.0, + ); + frame.stroke(&canvas::Path::line(base, tip), dash); + } + } let r = 5.0_f32; let p1 = canvas::Path::new(|b| { b.move_to(Point::new(sp.x - r, sp.y - r));