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::*;