refactor(snap): measure typed distance along the guide that is drawn

Extension answered "which acquired ray is this snap on?" twice, from two
different tests: the overlay picked its guide by off-ray distance in
screen pixels, while typed-distance entry re-searched the tracking set in
world space. Two tests can disagree, and the ways they disagree are both
bad -- a guide on screen that typing ignores, or a distance running off
along a ray the user cannot see. The second search could also come up
empty on its own, since only four acquisitions are kept and the oldest is
dropped as new ones arrive.

Ask once. The search that draws the guide now also reports the endpoint it
drew from, the snap carries it, and typed distance reads the ray straight
off that -- the direction is just the way from the endpoint to the snap,
which sits beyond it by construction. The screen-space test stays the only
one, which is what the guide wanted anyway: it stays scale-independent
where a world test would not.

Also correct the tracking-point doc: it claims XZ with Y=0, but
`edge_dirs_at` builds every direction from X/Y with Z zeroed and the rays
follow it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hakan SevenandClaude Opus 5 committed 2026-08-07 11:29:15 +03:00
1 parent b8f9727a27
commit 0af6b470ed
3 files changed
+32 -73

No files matched your search

+12 -7
View File
@@ -31,15 +31,20 @@ impl OpenCADStudio {
return Some((base, dir));
}
// Extension stores the snapped point. Recover the acquired endpoint
// and outward segment direction that produced that snap.
// Extension measures along the ray its guide is drawn on. The snap
// carries the acquired endpoint that guide starts from, so read the ray
// off that rather than searching the tracking set again — a second
// search can disagree with the guide the user is looking at, and the
// acquisition it needs may already have aged out of the set.
let snap = self.tabs[i].snap_result?;
if snap.snap_type == crate::snap::SnapType::Extension {
return self.snapper.extension_input_ray(snap.world);
if snap.snap_type != crate::snap::SnapType::Extension {
return None;
}
None
let origin = snap.extension_origin?;
// The snap sits beyond its endpoint by construction, so the outward
// direction is simply the way from one to the other.
let dir = snap.world - origin;
Some((origin, dir.try_normalize()?))
}
/// Rebuild the active tab's dynamic-input field set to match what the
/// command is currently asking for. Called on cursor move and after
+1
View File
@@ -1647,6 +1647,7 @@ impl OpenCADStudio {
tangent_obj: None,
extension_base: None,
extension_base2: None,
extension_origin: None,
});
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
cmd.set_acquisition_hint(Some(pick.label));
+19 -66
View File
@@ -74,6 +74,12 @@ pub struct SnapResult {
/// 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<Point>,
/// World-space twin of `extension_base`: the acquired endpoint the guide is
/// drawn from. Typed-distance entry measures along this ray, so taking it
/// from the same decision that drew the guide keeps the two from
/// disagreeing — the guide showing while typing does nothing, or the
/// distance running off along a ray the user cannot see.
pub extension_origin: Option<glam::DVec3>,
}
/// Object-snap-tracking alignment: the cursor projected onto a ray from an
@@ -108,7 +114,8 @@ pub struct Snapper {
pub osnap_radius_px: f32,
/// Object Snap Tracking on/off (F11).
pub otrack_enabled: bool,
/// Acquired OST points (world XZ, Y=0 plane).
/// Acquired OST points, world space. Tracking is planar: `edge_dirs_at`
/// builds every direction from X/Y with Z zeroed, and the rays follow.
pub tracking_points: Vec<DVec3>,
/// Edge directions at each acquired point (parallel to `tracking_points`):
/// the line direction of every wire segment meeting at that corner, so
@@ -743,6 +750,7 @@ impl Snapper {
tangent_obj: None,
extension_base: None,
extension_base2: None,
extension_origin: None,
})
}
@@ -856,6 +864,7 @@ impl Snapper {
tangent_obj: None,
extension_base: None,
extension_base2: None,
extension_origin: None,
});
best_rank = snap_tier(SnapType::Grid);
best_sub = snap_priority(SnapType::Grid);
@@ -954,6 +963,7 @@ impl Snapper {
tangent_obj: None,
extension_base: None,
extension_base2: None,
extension_origin: None,
});
}
};
@@ -1500,6 +1510,7 @@ impl Snapper {
tangent_obj: Some(tangent_obj),
extension_base: None,
extension_base2: None,
extension_origin: None,
});
}
}
@@ -1548,6 +1559,7 @@ impl Snapper {
tangent_obj: None,
extension_base: None,
extension_base2: None,
extension_origin: None,
});
}
};
@@ -1605,7 +1617,7 @@ impl Snapper {
// intersection yields none, so its guides simply don't draw. (#238, #247, #259)
if let Some(b) = best.as_mut() {
if matches!(b.snap_type, SnapType::Extension | SnapType::Intersection) {
let (b1, b2) = extension_bases_screen(
let (b1, b2, origin) = extension_bases_screen(
b.world,
&self.tracking_points,
&self.tracking_dirs,
@@ -1615,73 +1627,12 @@ impl Snapper {
);
b.extension_base = b1;
b.extension_base2 = b2;
b.extension_origin = origin;
}
}
best
}
pub fn extension_input_ray(
&self,
snapped: glam::DVec3,
) -> Option<(glam::DVec3, glam::DVec3)> {
let mut best: Option<(f64, glam::DVec3, glam::DVec3)> = None;
for (&origin, dirs) in self.tracking_points.iter().zip(&self.tracking_dirs) {
for &dir in dirs {
let len2 = dir.x * dir.x + dir.y * dir.y;
if len2 < 1e-12 {
continue;
}
let dx = snapped.x - origin.x;
let dy = snapped.y - origin.y;
let t = (dx * dir.x + dy * dir.y) / len2;
// Extension only exists beyond the acquired endpoint.
if t < 0.05 {
continue;
}
let projected = glam::DVec3::new(
origin.x + dir.x * t,
origin.y + dir.y * t,
origin.z,
);
let ex = snapped.x - projected.x;
let ey = snapped.y - projected.y;
let err2 = ex * ex + ey * ey;
let len = len2.sqrt();
let unit_dir =
glam::DVec3::new(dir.x / len, dir.y / len, 0.0);
// The ray has to actually pass through the snapped point. Only
// four acquisitions are kept and the oldest is dropped as new
// ones come in, so by the time a typed distance arrives the ray
// that produced this snap may be gone — without this the nearest
// surviving ray wins by default and the distance flies off along
// it. Failing here instead leaves the caller on plain point input.
//
// `extension_snap` builds the point by exact f64 projection onto
// this same ray, so a match sits on it to rounding: the tolerance
// only has to clear f64 noise, and scaling it with the coordinate
// keeps that true at UTM magnitudes. (`extension_bases_screen`
// answers the same question in screen pixels because it also has
// to catch a *crossing* base, which is a looser fit than this.)
let tol = 1e-9_f64.max(2e-12 * origin.x.abs().max(origin.y.abs()));
if err2 > tol * tol {
continue;
}
if best.as_ref().map_or(true, |(best_err, _, _)| err2 < *best_err) {
best = Some((err2, origin, unit_dir));
}
}
}
best.map(|(_, origin, dir)| (origin, dir))
}
}
// ── Object-snap priority ───────────────────────────────────────────────────
@@ -2095,7 +2046,7 @@ fn extension_bases_screen(
view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle,
) -> (Option<Point>, Option<Point>) {
) -> (Option<Point>, Option<Point>, Option<glam::DVec3>) {
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
@@ -2139,7 +2090,9 @@ fn extension_bases_screen(
break;
}
}
(bases[0], bases[1])
// `origins` is sorted nearest-fit first, so its head is the endpoint the
// primary guide is drawn from — the one a typed distance measures along.
(bases[0], bases[1], origins.first().copied())
}
// ── Projection helpers ────────────────────────────────────────────────────