diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 6de0b3768..959b6e8cf 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -104,6 +104,7 @@ impl OpenCADStudio { self.snapper.from_point = None; self.snapper.clear_tracking(); self.otrack_active = None; + self.otrack_cross = None; self.otrack_kind = None; self.axis_lock_dir = None; self.dyn_user_reshaped = false; diff --git a/src/app/mod.rs b/src/app/mod.rs index a33e7e081..768e2a730 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -501,6 +501,11 @@ pub(super) struct OpenCADStudio { /// cursor is on a tracking ray. Lets a typed distance place a point along /// the ray from the tracking point (issue #69). `None` when not aligned. otrack_active: Option<(glam::DVec3, glam::DVec3)>, + /// The vector `otrack_active` crosses, when the lock is the meeting of two + /// tracking vectors. Drawn beside the first so the user can see that the + /// point is their intersection; a typed distance still runs along + /// `otrack_active` alone. `None` for a single-ray alignment. (#1313) + otrack_cross: Option<(glam::DVec3, glam::DVec3)>, /// Active OTRACK ray kind, separate from typed-distance geometry. otrack_kind: Option, /// Whether Tangent snap was enabled before a tangent-pick command started. @@ -3834,6 +3839,7 @@ impl OpenCADStudio { annotation_auto_scale: -4, last_saved_config: None, otrack_active: None, + otrack_cross: None, otrack_kind: None, clean_screen: false, quick_properties: false, diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 480608dcd..49bb02a30 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -354,6 +354,7 @@ impl OpenCADStudio { { self.snapper.clear_tracking(); self.otrack_active = None; + self.otrack_cross = None; self.otrack_kind = None; } if let Some((started, tab_id, before)) = perf_edit_before { @@ -390,6 +391,7 @@ impl OpenCADStudio { pub(in crate::app) fn reset_tracking_after_point(&mut self) { self.snapper.clear_tracking(); self.otrack_active = None; + self.otrack_cross = None; self.otrack_kind = None; } @@ -3969,6 +3971,7 @@ impl OpenCADStudio { if !self.snapper.otrack_enabled { self.snapper.clear_tracking(); self.otrack_active = None; + self.otrack_cross = None; self.otrack_kind = None; } Task::none() diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 6241fde5b..28ce74414 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -1479,6 +1479,7 @@ impl OpenCADStudio { Some((base, dir)) }); self.otrack_active = otrack_hit.map(|hit| (hit.base, hit.dir)).or(drafting_guide); + self.otrack_cross = otrack_hit.and_then(|hit| hit.cross); self.otrack_kind = otrack_hit.map(|hit| hit.kind); @@ -2190,6 +2191,7 @@ impl OpenCADStudio { None }; self.otrack_active = otrack_hit.map(|h| (h.base, h.dir)); + self.otrack_cross = otrack_hit.and_then(|h| h.cross); self.otrack_kind = otrack_hit.map(|h| h.kind); // Parallel snap: with nothing else snapped or tracked, lock @@ -2208,6 +2210,7 @@ impl OpenCADStudio { (self.last_point, self.snapper.parallel_ref) { self.otrack_active = Some((base, dir.as_dvec3())); + self.otrack_cross = None; } self.tabs[i].snap_result = Some(par); } diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 52d4e18cb..65a74c0df 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -770,15 +770,22 @@ bg={bg_ms:.1}ms n={view_count}" // 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)> = - 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)) - } - _ => None, - }; + // An intersection lock is the meeting of two tracking vectors, so + // both are drawn — one guide alone hides what the point is (#1313). + let otrack_lines: Vec<(iced::Point, iced::Point)> = match otrack_proj { + Some((view_rot, eye, ob)) => { + let a = ost_project(tab.last_cursor_world, view_rot, eye, ob); + self.otrack_active + .into_iter() + .chain(self.otrack_cross) + .filter_map(|(base, _dir)| { + let b = ost_project(base, view_rot, eye, ob); + (b.x.is_finite() && a.x.is_finite()).then_some((b, a)) + }) + .collect() + } + None => vec![], + }; // The acquired Parallel-snap reference, marked on its line (#277). let parallel_ref_marker: Option = match (otrack_proj, self.snapper.parallel_ref) { @@ -894,7 +901,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, diff --git a/src/snap.rs b/src/snap.rs index eee33903f..e50ca2919 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -177,6 +177,12 @@ pub struct OtrackHit { pub dir: DVec3, /// The tracking point the ray emanates from. pub base: DVec3, + /// The second ray of an intersection lock, as `(base, outward direction)`. + /// A crossing is the meeting of two tracking vectors, and the user has to + /// see both of them to read what the lock means; `base`/`dir` above carry + /// only the one a typed distance is measured along. `None` for a + /// single-ray alignment, which has no second vector. (#1313) + pub cross: Option<(DVec3, DVec3)>, pub kind: TrackingKind, } @@ -917,19 +923,35 @@ impl Snapper { if sd < r && best_x.as_ref().map_or(true, |(bd, _)| sd < *bd) { // Report an acquired tracking ray (not an auxiliary // last_point ray) as base/dir for typed-distance entry. - let ot = if rays[i].group != POLAR_GROUP && rays[i].group != ORTHO_GROUP { - &rays[i] + let (ot, other) = if rays[i].group != POLAR_GROUP + && rays[i].group != ORTHO_GROUP + { + (&rays[i], &rays[j]) } else { - &rays[j] + (&rays[j], &rays[i]) }; - let t = (x.x - ot.origin.x) * ot.dir.x + (x.y - ot.origin.y) * ot.dir.y; - let dir_out = if t >= 0.0 { ot.dir } else { -ot.dir }; + // Point each ray the way the crossing lies from its own + // origin, so the guide drawn for it runs through the lock + // rather than away from it. + let outward = |ray: &Ray| { + let t = (x.x - ray.origin.x) * ray.dir.x + + (x.y - ray.origin.y) * ray.dir.y; + if t >= 0.0 { + ray.dir + } else { + -ray.dir + } + }; + let dir_out = outward(ot); best_x = Some(( sd, OtrackHit { aligned: x, dir: dir_out, base: ot.origin, + // The vector the reported one crosses. Both are + // drawn, so the intersection reads as one. (#1313) + cross: Some((other.origin, outward(other))), kind: ot.kind, } )); @@ -970,6 +992,8 @@ impl Snapper { aligned, dir: dir_out, base: ray.origin, + // A single-ray alignment has no second vector. + cross: None, kind: ray.kind, }, )); @@ -3647,6 +3671,121 @@ mod ext_tests { assert!(none.is_none(), "no base point → no base→corner alignment"); } + /// #1313: an intersection lock must report both of the vectors it is the + /// crossing of, so the overlay can draw both. Reporting only the one a + /// typed distance runs along leaves the user with a single guide and no + /// sign of what the point actually is. + #[test] + fn intersection_lock_reports_both_crossing_vectors() { + let mut s = Snapper::default(); + s.otrack_enabled = true; + s.osnap_radius_px = 10.0; + // Two acquired corners. With no polar step each offers a horizontal and + // a vertical ray, so their rays cross at (10, 0) and at (0, 5). + let first = DVec3::new(0.0, 0.0, 0.0); + let second = DVec3::new(10.0, 5.0, 0.0); + for corner in [first, second] { + s.tracking_points.push(corner); + s.tracking_dirs.push(Vec::new()); + } + + let view_rot = Mat4::from_scale(Vec3::splat(0.0001)); + let eye = glam::DVec3::ZERO; + let bounds = Rectangle { + x: 0.0, + y: 0.0, + width: 1000.0, + height: 1000.0, + }; + + // Cursor a hair off the crossing of the first corner's horizontal ray + // and the second corner's vertical one. + let crossing = DVec3::new(10.0, 0.0, 0.0); + let hit = s + .otrack_snap( + crossing + DVec3::new(0.02, 0.02, 0.0), + view_rot, + eye, + bounds, + None, + None, + None, + false, + DVec3::X, + DVec3::Y, + ) + .expect("the two rays cross inside the aperture"); + assert!( + (hit.aligned - crossing).length() < 1e-9, + "locked off the crossing: {:?}", + hit.aligned + ); + + let (cross_base, cross_dir) = hit.cross.expect("a crossing reports its second vector"); + let bases = [hit.base, cross_base]; + for corner in [first, second] { + assert!( + bases.iter().any(|b| (*b - corner).length() < 1e-9), + "{corner:?} is not one of the two reported vectors: {bases:?}" + ); + } + + // Each vector runs from its own corner through the crossing, pointing + // at it — the guides are drawn along these. + for (base, dir) in [(hit.base, hit.dir), (cross_base, cross_dir)] { + let off = crossing - base; + assert!( + (off.x * dir.y - off.y * dir.x).abs() < 1e-9, + "the crossing is off the vector from {base:?} along {dir:?}" + ); + assert!( + off.dot(dir) > 0.0, + "vector from {base:?} points away from the crossing" + ); + } + } + + /// The second vector belongs to a crossing alone: a plain single-ray + /// alignment has nothing to cross, and must not draw a second guide. + #[test] + fn single_ray_alignment_reports_no_crossing_vector() { + let mut s = Snapper::default(); + s.otrack_enabled = true; + s.osnap_radius_px = 10.0; + let corner = DVec3::new(10.0, 5.0, 0.0); + s.tracking_points.push(corner); + s.tracking_dirs.push(Vec::new()); + + let view_rot = Mat4::from_scale(Vec3::splat(0.0001)); + let eye = glam::DVec3::ZERO; + let bounds = Rectangle { + x: 0.0, + y: 0.0, + width: 1000.0, + height: 1000.0, + }; + + let hit = s + .otrack_snap( + DVec3::new(60.0, 5.02, 0.0), + view_rot, + eye, + bounds, + None, + None, + None, + false, + DVec3::X, + DVec3::Y, + ) + .expect("the corner's horizontal ray catches the cursor"); + assert!( + hit.cross.is_none(), + "a single-ray alignment reported a crossing vector: {:?}", + hit.cross + ); + } + #[test] fn tangent_points_are_perpendicular_to_the_radius() { let c = Vec3::new(0.0, 0.0, 0.0); diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 1aecaba9e..0d70642e5 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -851,7 +851,7 @@ pub fn selection_overlay<'a>( grip_clip: Option, ucs_icons: Vec, ost_points: Vec, - otrack_line: Option<(Point, Point)>, + otrack_lines: Vec<(Point, Point)>, parallel_ref_marker: Option, show_viewcube: bool, dividers: Vec, @@ -877,7 +877,7 @@ pub fn selection_overlay<'a>( grip_clip, ucs_icons, ost_points, - otrack_line, + otrack_lines, parallel_ref_marker, show_viewcube, dividers, @@ -918,11 +918,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)>, + /// Active OTRACK alignments, each (acquired tracking point, locked cursor) + /// in screen space. Drawn as dashed guides extended a little past the + /// cursor so the extension / tracking line the user snapped to is visible + /// (#219). An intersection lock contributes both of its crossing vectors, + /// so the point reads as their meeting rather than a lone guide (#1313). + otrack_lines: Vec<(Point, Point)>, /// 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, @@ -1902,7 +1903,7 @@ impl canvas::Program for SelectionCanvas { // 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 { + for &(base, tip) in &self.otrack_lines { let dx = tip.x - base.x; let dy = tip.y - base.y; let len = (dx * dx + dy * dy).sqrt();