fix(snap): OSNAP Extension tracks only acquired endpoints

Extension snap extended every segment's line in the whole drawing, so in
a larger project an extension foot was live almost everywhere and the
cursor snapped to it constantly.

Extension now behaves like a temporary tracking point: an endpoint must
first be acquired by dwelling on it, and only then does that segment's
line extend. Acquisition — and the tracking-point marker — is active
whenever OTRACK is on OR the Extension snap is on, so Extension works
independently of OTRACK's on/off state (and is not tied to POLAR). Both
the per-segment extension and the extended-intersection candidates are
gated on an acquired endpoint, and the whole scan is skipped when nothing
is acquired (the common case), keeping large drawings responsive.

Closes #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:30:12 +03:00
1 parent 2bbd3cfcd2
commit d93bcda889
2 files changed
+111 -24

No files matched your search

+1 -1
View File
@@ -340,7 +340,7 @@ impl OpenCADStudio {
// Shared OTRACK projection basis: the active pane's camera + rect
// (canvas offset included), matching the grips / UCS icon above.
let otrack_proj: Option<(glam::Mat4, glam::DVec3, iced::Rectangle)> =
if self.snapper.otrack_enabled {
if self.snapper.tracking_active() {
Some(
if let Some((vp_cam, full)) = tab.scene.viewport_edit_frame((vw, vh)) {
(vp_cam.view_proj_rte(full), vp_cam.eye(), full)
+110 -23
View File
@@ -166,6 +166,34 @@ impl Snapper {
self.enabled.contains(&t)
}
/// Whether temporary tracking points are being acquired and drawn: OTRACK
/// on, or the Extension object snap on. Extension tracks a segment's line
/// only from an acquired endpoint, and works independently of OTRACK's
/// on/off state (#262).
pub fn tracking_active(&self) -> bool {
self.otrack_enabled || (self.snap_enabled && self.is_on(SnapType::Extension))
}
/// True when `p` coincides with one of the acquired temporary tracking
/// points. Extension snaps a segment's line only from such acquired
/// endpoints (#262), so extensions aren't live for every object in the
/// drawing. The tolerance mirrors `edge_dirs_at`: acquired points are f32
/// truncations of the true vertices, so the match window scales with
/// coordinate magnitude with a tight floor near the origin.
fn is_tracked_endpoint(&self, p: glam::DVec3) -> bool {
if self.tracking_points.is_empty() {
return false;
}
let pf = p.as_vec3();
let tol = 1e-4_f32.max(4e-7 * pf.x.abs().max(pf.y.abs()));
let tol2 = tol * tol;
self.tracking_points.iter().any(|t| {
let dx = t.x - pf.x;
let dy = t.y - pf.y;
dx * dx + dy * dy < tol2
})
}
pub fn toggle_global(&mut self) {
self.snap_enabled = !self.snap_enabled;
}
@@ -215,7 +243,11 @@ impl Snapper {
bounds: iced::Rectangle,
now: Instant,
) {
if !self.otrack_enabled {
// Temporary tracking points are acquired when OTRACK is on, OR when the
// Extension object snap is on: Extension tracks a segment's line only
// from an endpoint the user has acquired, independently of OTRACK's
// on/off state (#262).
if !self.tracking_active() {
self.last_snap_world = None;
self.dwell_since = None;
self.dwell_acquired = false;
@@ -861,8 +893,11 @@ impl Snapper {
// ── Extension — along the extension of a segment beyond endpoints ──
// Every segment's line can be extended past either endpoint, so a
// polyline offers an extension off each of its vertices, not just the
// first and last (#259).
if self.is_on(SnapType::Extension) {
// first and last (#259). Extension is live only from endpoints the user
// has acquired as temporary tracking points (#262), so with none
// acquired there is nothing to extend — skip the whole scan. That is the
// common case and keeps a large drawing responsive.
if self.is_on(SnapType::Extension) && !self.tracking_points.is_empty() {
for wire in wires {
let n = wire.points.len();
if n < 2 {
@@ -875,29 +910,36 @@ impl Snapper {
if !a.x.is_finite() || !b.x.is_finite() || (a - b).length_squared() < 1e-18 {
continue;
}
// Only extend from an endpoint the user has acquired as a
// temporary tracking point, so the extension isn't live for
// every object in the drawing (#262).
// Beyond `a`, away from `b`.
if let Some(ext) = extension_snap(
cursor_world,
a,
a - b,
view_rot,
eye,
bounds,
self.osnap_radius_px,
) {
try_pt(ext, SnapType::Extension);
if self.is_tracked_endpoint(a) {
if let Some(ext) = extension_snap(
cursor_world,
a,
a - b,
view_rot,
eye,
bounds,
self.osnap_radius_px,
) {
try_pt(ext, SnapType::Extension);
}
}
// Beyond `b`, away from `a`.
if let Some(ext) = extension_snap(
cursor_world,
b,
b - a,
view_rot,
eye,
bounds,
self.osnap_radius_px,
) {
try_pt(ext, SnapType::Extension);
if self.is_tracked_endpoint(b) {
if let Some(ext) = extension_snap(
cursor_world,
b,
b - a,
view_rot,
eye,
bounds,
self.osnap_radius_px,
) {
try_pt(ext, SnapType::Extension);
}
}
}
}
@@ -917,6 +959,11 @@ impl Snapper {
if !a0.x.is_finite() || !a1.x.is_finite() {
continue;
}
// Only lines with an acquired endpoint contribute an extended
// crossing, matching the per-segment extension gate (#262).
if !self.is_tracked_endpoint(a0) && !self.is_tracked_endpoint(a1) {
continue;
}
let s0 = world_to_screen(a0, view_rot, eye, bounds);
let s1 = world_to_screen(a1, view_rot, eye, bounds);
let ex = s1.x - s0.x;
@@ -1461,3 +1508,43 @@ fn t_on_segment(p: Point, a: Point, b: Point) -> f32 {
}
(((p.x - a.x) * dx + (p.y - a.y) * dy) / len2).clamp(0.0, 1.0)
}
#[cfg(test)]
mod ext_tests {
use super::*;
#[test]
fn tracking_active_covers_otrack_and_extension() {
let mut s = Snapper::default();
// OTRACK off, Extension not enabled → no acquisition.
s.snap_enabled = true;
s.otrack_enabled = false;
assert!(!s.tracking_active());
// Extension on with the snap master on → acquire, independent of OTRACK.
s.enabled.insert(SnapType::Extension);
assert!(s.tracking_active());
// Extension is gated by the snap master.
s.snap_enabled = false;
assert!(!s.tracking_active());
// OTRACK acquires regardless of the object-snap master.
s.enabled.remove(&SnapType::Extension);
s.otrack_enabled = true;
assert!(s.tracking_active());
}
#[test]
fn extension_only_tracks_acquired_endpoints() {
let mut s = Snapper::default();
// Nothing acquired → no endpoint is a live extension source (#262).
assert!(!s.is_tracked_endpoint(glam::DVec3::new(10.0, 0.0, 0.0)));
// Acquire an endpoint → only that vertex tracks.
s.tracking_points.push(Vec3::new(10.0, 0.0, 0.0));
assert!(s.is_tracked_endpoint(glam::DVec3::new(10.0, 0.0, 0.0)));
assert!(!s.is_tracked_endpoint(glam::DVec3::new(5.0, 0.0, 0.0)));
// The match tolerance scales with coordinate magnitude, so an acquired
// vertex at UTM scale still matches its f32-truncated tracking point.
let big = 1_234_567.0_f64;
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)));
}
}