fix(snap): stop Extension tracking points from vanishing while in use

Extension-driven acquisition (OTRACK off) acquired any dwelled snap
point, including the extension foot the cursor paused on while tracing an
extension. Each such spurious acquisition filled the 4-point buffer and
evicted, oldest-first, the endpoint the user had actually acquired — so
after a few pauses its + marker and its extension disappeared.

Restrict Extension-driven acquisition to real segment endpoints (points
with an incident edge); midpoints, centres, intersections and extension
feet are ignored. OTRACK still acquires any snap point.

Refs #262

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-04 23:42:40 +03:00
1 parent d93bcda889
commit f491e8436b
1 file changed
+51 -7
+51 -7
View File
@@ -253,6 +253,13 @@ impl Snapper {
self.dwell_acquired = false;
return;
}
// With OTRACK off, acquisition is Extension-driven, and Extension tracks
// a line only from a real segment endpoint — so acquire endpoints only.
// This stops a paused cursor on an extension foot (or a midpoint/centre)
// from being acquired and evicting, through the 4-point cap, the very
// endpoint the user acquired — the reason the marker vanished after a
// few pauses (#262). OTRACK keeps acquiring any snap point.
let endpoints_only = !self.otrack_enabled;
// The cursor must rest near a snap point for this long before it is
// acquired, so that brushing past snap points while moving the mouse
// does not create accidental tracking points.
@@ -263,7 +270,7 @@ impl Snapper {
None => {
// Leaving all geometry: capture the point we were dwelling on if
// it qualified, before the reset loses it.
self.acquire_on_leave(now, DWELL_MS, wires);
self.acquire_on_leave(now, DWELL_MS, wires, endpoints_only);
self.last_snap_world = None;
self.dwell_since = None;
self.dwell_acquired = false;
@@ -298,14 +305,14 @@ impl Snapper {
self.tracking_dirs.remove(idx);
}
}
None => self.acquire_tracking_point(p, wires),
None => self.acquire_tracking_point(p, wires, endpoints_only),
}
}
} else {
// Moved to a different snap point: capture the previous one
// first if it was dwelt on long enough, so a pause-then-drag
// gesture reliably acquires it even without in-place events.
self.acquire_on_leave(now, DWELL_MS, wires);
self.acquire_on_leave(now, DWELL_MS, wires, endpoints_only);
self.last_snap_world = Some(p);
self.dwell_since = Some(now);
self.dwell_acquired = false;
@@ -318,7 +325,7 @@ impl Snapper {
/// it is already tracked; drops the oldest when the 4-point cap is reached.
/// Edge directions are scanned once here, at acquisition, so OTRACK can align
/// to a segment's extension without rescanning geometry per move (#219).
fn acquire_tracking_point(&mut self, p: Vec3, wires: &[WireModel]) {
fn acquire_tracking_point(&mut self, p: Vec3, wires: &[WireModel], endpoints_only: bool) {
if self
.tracking_points
.iter()
@@ -326,6 +333,13 @@ impl Snapper {
{
return;
}
// Edge directions double as an endpoint test: a point with no incident
// segment — a midpoint, centre, intersection or extension foot — has
// none. Extension-driven acquisition (#262) keeps only endpoints.
let dirs = edge_dirs_at(p, wires);
if endpoints_only && dirs.is_empty() {
return;
}
if self.tracking_points.len() >= 4 {
self.tracking_points.remove(0);
if !self.tracking_dirs.is_empty() {
@@ -333,7 +347,7 @@ impl Snapper {
}
}
self.tracking_points.push(p);
self.tracking_dirs.push(edge_dirs_at(p, wires));
self.tracking_dirs.push(dirs);
}
/// If the cursor dwelt on a snap point long enough but the in-place check
@@ -341,7 +355,13 @@ impl Snapper {
/// is only re-examined once the cursor moves off), acquire it now as the
/// cursor leaves. This makes "pause on a corner, then drag along its edge"
/// reliably capture the corner (#219).
fn acquire_on_leave(&mut self, now: Instant, dwell_ms: u128, wires: &[WireModel]) {
fn acquire_on_leave(
&mut self,
now: Instant,
dwell_ms: u128,
wires: &[WireModel],
endpoints_only: bool,
) {
if self.dwell_acquired {
return; // already handled by the in-place branch
}
@@ -352,7 +372,7 @@ impl Snapper {
.dwell_since
.map_or(0, |t| now.duration_since(t).as_millis());
if elapsed >= dwell_ms {
self.acquire_tracking_point(prev, wires);
self.acquire_tracking_point(prev, wires, endpoints_only);
}
}
@@ -1547,4 +1567,28 @@ mod ext_tests {
s.tracking_points.push(Vec3::new(big as f32, 0.0, 0.0));
assert!(s.is_tracked_endpoint(glam::DVec3::new(big, 0.0, 0.0)));
}
#[test]
fn extension_acquisition_keeps_only_endpoints() {
let mut s = Snapper::default();
// A single line segment (0,0)-(10,0): its endpoints are vertices, its
// midpoint and any extension foot are not.
let wire = WireModel {
points: vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]],
..Default::default()
};
let wires = [wire];
// Extension-driven acquisition (endpoints_only): a midpoint — like an
// extension foot the cursor paused on — is ignored, so it can't fill the
// buffer and evict the real endpoint (#262).
s.acquire_tracking_point(Vec3::new(5.0, 0.0, 0.0), &wires, true);
assert!(s.tracking_points.is_empty());
// The genuine endpoint is acquired.
s.acquire_tracking_point(Vec3::new(10.0, 0.0, 0.0), &wires, true);
assert_eq!(s.tracking_points.len(), 1);
// OTRACK (endpoints_only = false) still acquires any snap point.
s.acquire_tracking_point(Vec3::new(5.0, 0.0, 0.0), &wires, false);
assert_eq!(s.tracking_points.len(), 2);
}
}