fix(otrack): add 250ms dwell timeout before acquiring a tracking point

A tracking point was created as soon as a snap point was highlighted,
so moving the mouse across the view spawned accidental tracking points.
Acquisition now requires the cursor to rest near the snap point for
250ms (wall-clock) rather than a fixed number of move events.

Closes #110

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-06-17 04:59:06 +03:00
commit ca8d249173
2 changed files with 33 additions and 13 deletions

View file

@ -2481,8 +2481,12 @@ impl OpenCADStudio {
// distance can place a point along it — issue #69). // distance can place a point along it — issue #69).
let otrack_hit = { let otrack_hit = {
let snap_world = self.tabs[i].snap_result.map(|s| s.world); let snap_world = self.tabs[i].snap_result.map(|s| s.world);
self.snapper self.snapper.update_otrack_dwell(
.update_otrack_dwell(snap_world, view_proj, bounds); snap_world,
view_proj,
bounds,
Instant::now(),
);
if self.tabs[i].snap_result.is_none() { if self.tabs[i].snap_result.is_none() {
let step = if self.polar_mode { let step = if self.polar_mode {
Some(self.polar_increment_deg) Some(self.polar_increment_deg)

View file

@ -5,6 +5,7 @@
//! Extension, Insertion, Perpendicular, Nearest, ApparentIntersection, Grid, Tangent //! Extension, Insertion, Perpendicular, Nearest, ApparentIntersection, Grid, Tangent
use glam::{Mat4, Vec3}; use glam::{Mat4, Vec3};
use iced::time::Instant;
use iced::{Point, Rectangle}; use iced::{Point, Rectangle};
use crate::command::TangentObject; use crate::command::TangentObject;
@ -98,8 +99,10 @@ pub struct Snapper {
pub tracking_points: Vec<Vec3>, pub tracking_points: Vec<Vec3>,
/// Last snap world position (for dwell detection). /// Last snap world position (for dwell detection).
pub last_snap_world: Option<Vec3>, pub last_snap_world: Option<Vec3>,
/// How many consecutive moves the cursor has been near last_snap_world. /// When the cursor first rested near `last_snap_world`.
pub dwell_count: u32, pub dwell_since: Option<Instant>,
/// Whether the current dwell already acquired/removed a point (fire once).
pub dwell_acquired: bool,
} }
impl Default for Snapper { impl Default for Snapper {
@ -121,7 +124,8 @@ impl Default for Snapper {
otrack_enabled: false, otrack_enabled: false,
tracking_points: Vec::new(), tracking_points: Vec::new(),
last_snap_world: None, last_snap_world: None,
dwell_count: 0, dwell_since: None,
dwell_acquired: false,
} }
} }
} }
@ -172,19 +176,25 @@ impl Snapper {
snap_world: Option<Vec3>, snap_world: Option<Vec3>,
view_proj: glam::Mat4, view_proj: glam::Mat4,
bounds: iced::Rectangle, bounds: iced::Rectangle,
now: Instant,
) { ) {
if !self.otrack_enabled { if !self.otrack_enabled {
self.dwell_count = 0;
self.last_snap_world = None; self.last_snap_world = None;
self.dwell_since = None;
self.dwell_acquired = false;
return; return;
} }
const DWELL_THRESHOLD: u32 = 4; // 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.
const DWELL_MS: u128 = 250;
const DWELL_PX: f32 = 8.0; const DWELL_PX: f32 = 8.0;
match snap_world { match snap_world {
None => { None => {
self.dwell_count = 0;
self.last_snap_world = None; self.last_snap_world = None;
self.dwell_since = None;
self.dwell_acquired = false;
} }
Some(p) => { Some(p) => {
// Convert to screen to measure pixel distance. // Convert to screen to measure pixel distance.
@ -198,8 +208,11 @@ impl Snapper {
false false
}; };
if is_same { if is_same {
self.dwell_count += 1; let elapsed = self
if self.dwell_count == DWELL_THRESHOLD { .dwell_since
.map_or(0, |t| now.duration_since(t).as_millis());
if !self.dwell_acquired && elapsed >= DWELL_MS {
self.dwell_acquired = true;
// Dwelling over an already-acquired point removes it; // Dwelling over an already-acquired point removes it;
// otherwise acquire it (max 4 tracked points). // otherwise acquire it (max 4 tracked points).
let existing = self.tracking_points.iter().position(|t| { let existing = self.tracking_points.iter().position(|t| {
@ -219,8 +232,9 @@ impl Snapper {
} }
} }
} else { } else {
self.dwell_count = 1;
self.last_snap_world = Some(p); self.last_snap_world = Some(p);
self.dwell_since = Some(now);
self.dwell_acquired = false;
} }
} }
} }
@ -291,8 +305,9 @@ impl Snapper {
/// Clear all acquired tracking points (e.g. when command ends). /// Clear all acquired tracking points (e.g. when command ends).
pub fn clear_tracking(&mut self) { pub fn clear_tracking(&mut self) {
self.tracking_points.clear(); self.tracking_points.clear();
self.dwell_count = 0;
self.last_snap_world = None; self.last_snap_world = None;
self.dwell_since = None;
self.dwell_acquired = false;
} }
/// Only runs Tangent snap — used when a command needs object picks via tangent. /// Only runs Tangent snap — used when a command needs object picks via tangent.
@ -317,7 +332,8 @@ impl Snapper {
otrack_enabled: false, otrack_enabled: false,
tracking_points: Vec::new(), tracking_points: Vec::new(),
last_snap_world: None, last_snap_world: None,
dwell_count: 0, dwell_since: None,
dwell_acquired: false,
}; };
tmp.snap(cursor_world, cursor_screen, wires, view_proj, bounds) tmp.snap(cursor_world, cursor_screen, wires, view_proj, bounds)
} }