From 99fca8b74bfe8f211a0acbad361806c9b24d6904 Mon Sep 17 00:00:00 2001 From: Afonsop Date: Thu, 17 Sep 2026 15:26:34 +0100 Subject: [PATCH] fix(otrack): draw every tracking vector, not just the locked one (#1313) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit otrack_snap reports a single active ray, and the overlay painted only that — with two acquired points the second vector was invisible even while the cursor locked to their intersection. Each acquired point now gets its own infinite dashed vector (active full strength, others at half alpha along their cursor-relevant direction), and when a non- active vector passes through the locked point an intersection X marks the crossing, mirroring the OSNAP intersection glyph. Pure view-layer change; snap math and state machines untouched. --- src/app/view/mod.rs | 181 ++++++++++++++++++++++++++++++++++++++++++-- src/ui/overlay.rs | 110 +++++++++++++++++++++++---- 2 files changed, 271 insertions(+), 20 deletions(-) diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 5f6aac6b2..d45aabcfd 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -746,17 +746,82 @@ bg={bg_ms:.1}ms n={view_count}" } else { vec![] }; - // The active alignment vector: a dashed guide from the acquired - // tracking point through the locked cursor, so the user sees the - // extension / tracking line they are snapped to (#219). - let otrack_line: Option<(iced::Point, iced::Point)> = + // The tracking vectors — one per acquired tracking point: the + // active alignment from the acquired point through the locked + // cursor (#219), plus each remaining acquired point's ray whose + // direction best matches the cursor. At a crossing lock those are + // exactly the two vectors intersecting at the cursor, which + // previously went undrawn (#1313). + let otrack_lines: Vec = match (otrack_proj, self.otrack_active) { (Some((view_rot, eye, ob)), Some((base, _dir))) => { let b = ost_project(base, view_rot, eye, ob); let a = ost_project(tab.last_cursor_world, view_rot, eye, ob); - (b.x.is_finite() && a.x.is_finite()).then_some((b, a)) + if !(b.x.is_finite() && a.x.is_finite()) { + vec![] + } else { + let mut lines = vec![crate::ui::overlay::OtrackLine { + base: b, + tip: a, + active: true, + }]; + // Candidate rays mirror the snap-side per-point set + // (polar/ortho quantized angle, edge extensions, + // perpendicular ray) — read-only, the snap math and + // state machine stay untouched. + let (_, (ux, uy, _)) = self.drafting_grid_basis(i); + let (ux, uy) = (ux.as_dvec3(), uy.as_dvec3()); + let step = self.polar_mode.then_some(self.polar_increment_deg); + let perp_on = + self.snapper.is_on(crate::snap::SnapType::Perpendicular); + let no_dirs: Vec = Vec::new(); + for (gi, &wp) in self.snapper.tracking_points.iter().enumerate() { + if wp == base { + continue; // its vector is the active one + } + let to_cursor = tab.last_cursor_world - wp; + let dist = (to_cursor.x * to_cursor.x + + to_cursor.y * to_cursor.y) + .sqrt(); + let edges = self + .snapper + .tracking_dirs + .get(gi) + .unwrap_or(&no_dirs); + let perps = if perp_on { + self.snapper + .tracking_perp_dirs + .get(gi) + .unwrap_or(&no_dirs) + } else { + &no_dirs + }; + let Some(d) = otrack_guide_dir( + to_cursor, ux, uy, step, edges, perps, + ) else { + continue; + }; + // Walk the unit direction out to the cursor's + // distance before projecting: a unit offset on + // UTM-scale coordinates cancels in f32. + let t = ost_project(wp + d * dist, view_rot, eye, ob); + let s = ost_project(wp, view_rot, eye, ob); + if t.x.is_finite() + && t.y.is_finite() + && s.x.is_finite() + && s.y.is_finite() + { + lines.push(crate::ui::overlay::OtrackLine { + base: s, + tip: t, + active: false, + }); + } + } + lines + } } - _ => None, + _ => vec![], }; // The acquired Parallel-snap reference, marked on its line (#277). let parallel_ref_marker: Option = @@ -857,7 +922,7 @@ bg={bg_ms:.1}ms n={view_count}" grip_clip, ucs_icons, ost_points, - otrack_line, + otrack_lines, parallel_ref_marker, // ViewCube hover region matches the drawn cube — gone when hidden. !is_paper && viewcube_visible, @@ -4155,6 +4220,68 @@ pub(super) fn recent_files_panel<'a>( .into() } +/// The OTRACK guide direction for one acquired (non-active) tracking point: +/// among the candidate rays the snap engine would offer from it — the +/// polar/ortho angle quantized toward the cursor, the acquired edge +/// extensions, and the perpendicular ray — the one whose angle best matches +/// the direction to the cursor. At a crossing lock that is exactly the second +/// vector through the cursor (#1313). View-side read only: the snap math and +/// its state machine are untouched. +fn otrack_guide_dir( + to_cursor: glam::DVec3, + ucs_x: glam::DVec3, + ucs_y: glam::DVec3, + polar_step_deg: Option, + edge_dirs: &[glam::DVec3], + perp_dirs: &[glam::DVec3], +) -> Option { + let len = (to_cursor.x * to_cursor.x + to_cursor.y * to_cursor.y).sqrt(); + if len < 1e-9 { + return None; // cursor sits on the acquired point: no direction + } + let toward = glam::DVec3::new(to_cursor.x / len, to_cursor.y / len, 0.0); + let mut best: Option<(f64, glam::DVec3)> = None; + let mut consider = |mut d: glam::DVec3| { + // A tracking ray reads as a line through the point: orient each + // candidate toward the cursor side before comparing angles. + if d.x * toward.x + d.y * toward.y < 0.0 { + d = -d; + } + let n = (d.x * d.x + d.y * d.y).sqrt(); + if n < 1e-9 { + return; + } + let d = d / n; + let alignment = d.x * toward.x + d.y * toward.y; + if best.map_or(true, |(b, _)| alignment > b) { + best = Some((alignment, d)); + } + }; + // Polar (quantized to the increment) or ortho (0°/90°) candidates, in + // the active UCS frame like the snap side. + let ux = to_cursor.dot(ucs_x); + let uy = to_cursor.dot(ucs_y); + match polar_step_deg.filter(|s| *s > 1e-3) { + Some(step) => { + let step = step as f64; + let deg = (uy.atan2(ux).to_degrees() / step).round() * step; + let rad = deg.to_radians(); + consider(ucs_x * rad.cos() + ucs_y * rad.sin()); + } + None => { + consider(ucs_x); + consider(ucs_y); + } + } + for &d in edge_dirs { + consider(d); + } + for &d in perp_dirs { + consider(d); + } + best.map(|(_, d)| d) +} + #[cfg(test)] mod tests { use super::*; @@ -4166,4 +4293,44 @@ mod tests { assert_eq!(bg, tab.scene.paper_bg_color); assert!(crate::ui::style::common::canvas_is_light(bg)); } + + #[test] + fn otrack_guide_dir_picks_the_closest_candidate() { + let x = glam::DVec3::X; + let y = glam::DVec3::Y; + let dir_at = |deg: f64| { + let r = deg.to_radians(); + glam::DVec3::new(r.cos(), r.sin(), 0.0) + }; + + // Cursor 10° above +X with no polar step: the X axis wins over Y. + let d = + otrack_guide_dir(dir_at(10.0) * 100.0, x, y, None, &[], &[]).unwrap(); + assert!(d.dot(x) > 0.99); + + // A 45° polar step quantizes a 50° cursor direction to 45°. + let d = + otrack_guide_dir(dir_at(50.0) * 100.0, x, y, Some(45.0), &[], &[]).unwrap(); + assert!((d.y.atan2(d.x).to_degrees() - 45.0).abs() < 1e-6); + + // An edge extension closer than either axis wins over them. + let d = otrack_guide_dir(dir_at(4.0) * 100.0, x, y, None, &[dir_at(5.0)], &[]) + .unwrap(); + assert!(d.dot(dir_at(5.0)) > 0.999); + + // A candidate pointing away from the cursor is oriented toward it. + let d = otrack_guide_dir( + glam::DVec3::new(100.0, 0.0, 0.0), + x, + y, + None, + &[-x], + &[], + ) + .unwrap(); + assert!(d.x > 0.99); + + // Degenerate: the cursor sits exactly on the acquired point. + assert!(otrack_guide_dir(glam::DVec3::ZERO, x, y, None, &[], &[]).is_none()); + } } diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 001dc39d5..e35865933 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -573,6 +573,19 @@ pub struct GridCanvasState { /// least `MIN_GRID_PX` pixels apart at the camera pivot. Both orthographic and /// perspective cameras have the same vertical scale there. Camera rotation is /// intentionally absent so orbiting cannot rescale the visible grid or snap. +/// Perpendicular screen distance (px) from `p` to the infinite line through +/// `a` and `b`. Degenerate (`a`≈`b`) falls back to the point distance. Used +/// to tell whether a tracking vector really passes through the locked cursor +/// — a genuine OTRACK crossing lock (#1313). +fn point_inf_line_dist(p: Point, a: Point, b: Point) -> f32 { + let (dx, dy) = (b.x - a.x, b.y - a.y); + let len2 = dx * dx + dy * dy; + if len2 < 1e-12 { + return ((p.x - a.x).powi(2) + (p.y - a.y).powi(2)).sqrt(); + } + ((p.x - a.x) * dy - (p.y - a.y) * dx).abs() / len2.sqrt() +} + /// Clip the screen segment `p0`→`p1` to `bounds` (Liang–Barsky), returning the /// visible part, or `None` when it misses entirely. /// @@ -662,6 +675,18 @@ pub struct OstTrackPoint { pub screen: Point, } +/// One OTRACK alignment vector, in screen space: an infinite dashed guide +/// through `base` along (`tip` − `base`). `active` marks the vector the +/// cursor is currently locked to; every other acquired tracking point +/// contributes its own (fainter) vector so a crossing lock shows BOTH +/// guides through the cursor (#1313). +#[derive(Clone, Debug)] +pub struct OtrackLine { + pub base: Point, + pub tip: Point, + pub active: bool, +} + pub fn grid_overlay<'a>( grid: Vec, style: GridStyle, @@ -754,7 +779,7 @@ pub fn selection_overlay<'a>( grip_clip: Option, ucs_icons: Vec, ost_points: Vec, - otrack_line: Option<(Point, Point)>, + otrack_lines: Vec, parallel_ref_marker: Option, show_viewcube: bool, dividers: Vec, @@ -778,7 +803,7 @@ pub fn selection_overlay<'a>( grip_clip, ucs_icons, ost_points, - otrack_line, + otrack_lines, parallel_ref_marker, show_viewcube, dividers, @@ -817,11 +842,12 @@ struct SelectionCanvas { /// entry carries hover/selected (grips). ucs_icons: Vec, ost_points: Vec, - /// Active OTRACK alignment: (acquired tracking point, locked cursor), both - /// in screen space. Drawn as a dashed guide extended a little past the - /// cursor so the extension / tracking line the user snapped to is visible. - /// (#219) - otrack_line: Option<(Point, Point)>, + /// OTRACK alignment vectors, screen space: the one the cursor is locked + /// to (`active`) plus one per other acquired tracking point, so a + /// crossing lock shows both guides that intersect at the cursor (#1313). + /// Each is drawn as a dashed guide extended a little past the cursor so + /// the extension / tracking line reads as a full construction line. (#219) + otrack_lines: Vec, /// The acquired Parallel-snap reference point (screen), marked with a small /// ∥ glyph so the user sees which line is the parallel reference. (#277) parallel_ref_marker: Option, @@ -1708,11 +1734,16 @@ impl canvas::Program for SelectionCanvas { // ── Object Snap Tracking ───────────────────────────────────────────── let track_color = theme.palette().primary.base.color.scale_alpha(0.7); - // The alignment line the cursor is currently locked to — drawn at its - // real angle from the acquired point through the lock and a little - // beyond, dashed so it reads as a construction guide. This covers the - // ortho (0°/90°), polar, and edge-extension cases uniformly (#219). - if let Some((base, tip)) = self.otrack_line { + // One alignment vector per acquired tracking point, each drawn at its + // real angle through the acquired point and a little beyond, dashed so + // it reads as a construction guide. The vector the cursor is locked + // to keeps full strength; the other acquired points' vectors fade to + // half so a crossing lock shows BOTH guides through the cursor — + // previously only the locked one drew (#1313). The angle cases are the + // same for every vector: ortho (0°/90°), polar, edge-extension (#219). + let mut crossing_lock = None; + for line in &self.otrack_lines { + let (base, tip) = (line.base, line.tip); let dx = tip.x - base.x; let dy = tip.y - base.y; let len = (dx * dx + dy * dy).sqrt(); @@ -1733,13 +1764,46 @@ impl canvas::Program for SelectionCanvas { offset: 0, }, ..canvas::Stroke::default() - .with_color(track_color) + .with_color(if line.active { + track_color + } else { + track_color.scale_alpha(0.5) + }) .with_width(1.0) }; if let Some((a, b)) = clip_seg(p0, p1, bounds) { frame.stroke(&canvas::Path::line(a, b), dash); } } + // The locked cursor position (the active vector's tip) — kept for + // the crossing check below. + if line.active { + crossing_lock = Some(tip); + } + } + // Crossing lock: an intersection X at the locked cursor (classic + // yellow OSNAP marker, same glyph as `SnapType::Intersection`) so the + // point both vectors meet at is unambiguous (#1313). + if let Some(lock) = crossing_lock { + let crossed = self.otrack_lines.iter().any(|l| { + !l.active && point_inf_line_dist(lock, l.base, l.tip) <= 2.5 + }); + if crossed { + let r = 5.0_f32; + let stroke = canvas::Stroke::default() + .with_color(Color::from_rgb(1.0, 0.9, 0.1)) + .with_width(1.5); + let p1 = canvas::Path::new(|b| { + b.move_to(Point::new(lock.x - r, lock.y - r)); + b.line_to(Point::new(lock.x + r, lock.y + r)); + }); + let p2 = canvas::Path::new(|b| { + b.move_to(Point::new(lock.x - r, lock.y + r)); + b.line_to(Point::new(lock.x + r, lock.y - r)); + }); + frame.stroke(&p1, stroke.clone()); + frame.stroke(&p2, stroke); + } } // The acquired Parallel-snap reference — a small ∥ glyph on its line so // the user sees which line the parallel is measured from. (#277) @@ -3501,6 +3565,26 @@ mod clip_tests { } } +#[cfg(test)] +mod otrack_tests { + use super::*; + + #[test] + fn point_inf_line_dist_measures_perpendicular_offset() { + // A horizontal line through the origin: distance is |y| anywhere on it. + let a = Point::new(-1000.0, 0.0); + let b = Point::new(1000.0, 0.0); + assert!((point_inf_line_dist(Point::new(400.0, 3.0), a, b) - 3.0).abs() < 1e-4); + // Off the segment's span the infinite line still applies. + assert!((point_inf_line_dist(Point::new(5000.0, -2.0), a, b) - 2.0).abs() < 1e-4); + // On the line exactly. + assert!(point_inf_line_dist(Point::new(123.0, 0.0), a, b).abs() < 1e-4); + // Degenerate segment: falls back to point distance. + let p = Point::new(3.0, 4.0); + assert!((point_inf_line_dist(p, Point::new(0.0, 0.0), Point::new(0.0, 0.0)) - 5.0).abs() < 1e-4); + } +} + #[cfg(test)] mod constraint_glyph_tests { use super::*;