feat(otrack): draw both vectors of an intersection lock

An OTRACK crossing is the meeting of two tracking vectors, but
`OtrackHit` carried only the one a typed distance is measured along, so
the overlay had a single guide to draw. Snapping to the crossing of two
vectors worked and looked like an unexplained point on one line.

Carry the crossed ray out of `otrack_snap` as `OtrackHit::cross`, keep it
on the app beside `otrack_active`, and let the overlay take a list of
guides rather than one. `otrack_active` still drives typed-distance
entry on its own, so distance entry is unchanged.

Fixes HakanSeven12/OpenCADStudio#1313

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQzYZsXBwPZ5HwBaGitEUV
(cherry picked from commit 2f6caacece)
This commit is contained in:
Claude authored and Hakan Seven committed 2026-09-20 21:29:15 +03:00
1 parent 41101efaa5
commit 033319a4e3
7 files changed
+183 -23

No files matched your search

+1
View File
@@ -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;
+6
View File
@@ -502,6 +502,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<crate::snap::TrackingKind>,
/// Whether Tangent snap was enabled before a tangent-pick command started.
@@ -3868,6 +3873,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,
+3
View File
@@ -366,6 +366,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 {
@@ -402,6 +403,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;
}
@@ -3982,6 +3984,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()
+3
View File
@@ -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);
}
+17 -10
View File
@@ -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<iced::Point> =
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,
+144 -5
View File
@@ -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,
},
));
@@ -3660,6 +3684,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);
+9 -8
View File
@@ -962,7 +962,7 @@ pub fn selection_overlay<'a>(
grip_clip: Option<iced::Rectangle>,
ucs_icons: Vec<UcsIconParams>,
ost_points: Vec<OstTrackPoint>,
otrack_line: Option<(Point, Point)>,
otrack_lines: Vec<(Point, Point)>,
parallel_ref_marker: Option<Point>,
show_viewcube: bool,
dividers: Vec<iced::Rectangle>,
@@ -988,7 +988,7 @@ pub fn selection_overlay<'a>(
grip_clip,
ucs_icons,
ost_points,
otrack_line,
otrack_lines,
parallel_ref_marker,
show_viewcube,
dividers,
@@ -1029,11 +1029,12 @@ struct SelectionCanvas {
/// entry carries hover/selected (grips).
ucs_icons: Vec<UcsIconParams>,
ost_points: Vec<OstTrackPoint>,
/// 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<Point>,
@@ -2013,7 +2014,7 @@ impl canvas::Program<Message> 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();