fix(snap): preserve OTRACK precision

Keep tracking geometry in f64 until screen projection so typed and dynamic distances no longer expose f32 drift.\n\nCloses #439
This commit is contained in:
Hakan Seven committed 2026-07-24 17:56:06 +03:00
1 parent 658aa4a4a5
commit a413f75548
6 files changed
+116 -130

No files matched your search

+1 -1
View File
@@ -314,7 +314,7 @@ pub(super) struct OpenCADStudio {
/// Active OTRACK alignment `(tracking_point, unit_direction)` when the /// Active OTRACK alignment `(tracking_point, unit_direction)` when the
/// cursor is on a tracking ray. Lets a typed distance place a point along /// 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. /// the ray from the tracking point (issue #69). `None` when not aligned.
otrack_active: Option<(glam::Vec3, glam::Vec3)>, otrack_active: Option<(glam::DVec3, glam::DVec3)>,
/// Whether Tangent snap was enabled before a tangent-pick command started. /// Whether Tangent snap was enabled before a tangent-pick command started.
pre_cmd_tangent: Option<bool>, pre_cmd_tangent: Option<bool>,
/// Drawing defaults captured by ADDSELECTED before it adopts the template /// Drawing defaults captured by ADDSELECTED before it adopts the template
+1 -4
View File
@@ -295,10 +295,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
// (issue #69). // (issue #69).
if let Some((base, dir)) = self.otrack_active { if let Some((base, dir)) = self.otrack_active {
if let Some(dist) = crate::app::expr_eval::eval_number(text.trim()) { if let Some(dist) = crate::app::expr_eval::eval_number(text.trim()) {
// Typed distance along the tracking ray: keep the let pt = base + dir * dist;
// magnitude exact in f64 (ray origin/dir are the
// screen-space snapper's f32).
let pt = base.as_dvec3() + dir.as_dvec3() * dist;
self.last_point = Some(pt); self.last_point = Some(pt);
self.dyn_user_reshaped = false; self.dyn_user_reshaped = false;
self.sync_dyn_fields(); self.sync_dyn_fields();
+1 -4
View File
@@ -483,10 +483,7 @@ impl OpenCADStudio {
.find_map(|f| f.buffer.clone()) .find_map(|f| f.buffer.clone())
{ {
if let Some(dist) = crate::app::expr_eval::eval_number(text.trim()) { if let Some(dist) = crate::app::expr_eval::eval_number(text.trim()) {
// Distance is typed, so resolve in f64: the tracking ray let pt = base + dir * dist;
// origin/dir come from the screen-space snapper (f32) but
// the magnitude the user entered stays exact.
let pt = base.as_dvec3() + dir.as_dvec3() * dist;
self.last_point = Some(pt); self.last_point = Some(pt);
for f in self.tabs[i].dyn_fields.iter_mut() { for f in self.tabs[i].dyn_fields.iter_mut() {
f.buffer = None; f.buffer = None;
+31 -39
View File
@@ -981,7 +981,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
// to a tracking ray (and store the alignment so a typed // to a tracking ray (and store the alignment so a typed
// 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.as_vec3()); let snap_world = self.tabs[i].snap_result.map(|s| s.world);
self.snapper.update_otrack_dwell( self.snapper.update_otrack_dwell(
snap_world, snap_world,
&all_wires[..], &all_wires[..],
@@ -1009,16 +1009,17 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
} else { } else {
None None
}; };
let ucs = self.tabs[i].scene.viewcube_ucs_mat(); let (_, ucs_x, ucs_y, _) = self.tabs[i].ucs_xform().axes();
self.snapper.otrack_snap( self.snapper.otrack_snap(
cursor_world.as_vec3(), cursor_world,
view_rot, view_rot,
eye, eye,
bounds, bounds,
step, step,
self.last_point.map(|p| p.as_vec3()), self.last_point,
self.ortho_mode && !is_window_corner, self.ortho_mode && !is_window_corner,
ucs, ucs_x,
ucs_y,
) )
} else { } else {
None None
@@ -1041,7 +1042,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
if let (Some(base), Some((dir, _))) = if let (Some(base), Some((dir, _))) =
(self.last_point, self.snapper.parallel_ref) (self.last_point, self.snapper.parallel_ref)
{ {
self.otrack_active = Some((base.as_vec3(), dir)); self.otrack_active = Some((base, dir.as_dvec3()));
} }
self.tabs[i].snap_result = Some(par); self.tabs[i].snap_result = Some(par);
} }
@@ -1051,7 +1052,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
let mut pt: glam::DVec3 = if let Some(h) = otrack_hit { let mut pt: glam::DVec3 = if let Some(h) = otrack_hit {
// Tracking alignment wins over the free cursor; // Tracking alignment wins over the free cursor;
// ortho/polar don't re-constrain it. // ortho/polar don't re-constrain it.
h.aligned.as_dvec3() h.aligned
} else { } else {
// Snap runs in model space (viewport camera or the // Snap runs in model space (viewport camera or the
// model/paper view), so the result is already model. // model/paper view), so the result is already model.
@@ -1364,39 +1365,21 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
// point along the aligned direction (issue #69). // point along the aligned direction (issue #69).
if let Some(h) = otrack_hit { if let Some(h) = otrack_hit {
if !needs_entity { if !needs_entity {
let far = 1e5_f32; let far = 1e5_f64;
let far_pos = h.base + h.dir * far; let far_pos = h.base + h.dir * far;
let far_neg = h.base - h.dir * far; let far_neg = h.base - h.dir * far;
previews.push(crate::scene::WireModel { let mut guide = crate::scene::WireModel::solid_f64(
taper_widths: Vec::new(), "__otrack_guide__".into(),
world_width: 0.0, vec![
depth_override: None,
fill_is_3d: false,
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: "__otrack_guide__".into(),
points: vec![
[far_neg.x, far_neg.y, far_neg.z], [far_neg.x, far_neg.y, far_neg.z],
[far_pos.x, far_pos.y, far_pos.z], [far_pos.x, far_pos.y, far_pos.z],
], ],
points_low: Vec::new(), [0.2, 0.9, 0.5, 0.6],
color: [0.2, 0.9, 0.5, 0.6], false,
selected: false, );
aci: 0, guide.pattern_length = 0.8;
pattern_length: 0.8, guide.pattern = [0.5, -0.3, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
pattern: [0.5, -0.3, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], previews.push(guide);
line_weight_px: 1.0,
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: vec![],
aabb: crate::scene::WireModel::UNBOUNDED_AABB,
plinegen: true,
fill_tris: vec![],
fill_tris_low: Vec::new(),
});
} }
} }
self.tabs[i].scene.set_preview_wires(previews); self.tabs[i].scene.set_preview_wires(previews);
@@ -2056,14 +2039,23 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
} else { } else {
None None
}; };
let ucs = self.tabs[i].scene.viewcube_ucs_mat(); let (_, ucs_x, ucs_y, _) = self.tabs[i].ucs_xform().axes();
self.snapper self.snapper.otrack_snap(
.otrack_snap(raw.as_vec3(), view_rot, eye, bounds, step, self.last_point.map(|p| p.as_vec3()), self.ortho_mode && !is_window_corner, ucs) raw,
view_rot,
eye,
bounds,
step,
self.last_point,
self.ortho_mode && !is_window_corner,
ucs_x,
ucs_y,
)
} else { } else {
None None
}; };
if let Some(h) = otrack { if let Some(h) = otrack {
pt = h.aligned.as_dvec3(); pt = h.aligned;
if self.tabs[i].active_ucs.is_none() { if self.tabs[i].active_ucs.is_none() {
pt.z = 0.0; pt.z = 0.0;
} }
+4 -4
View File
@@ -418,8 +418,8 @@ impl OpenCADStudio {
None None
}; };
let ost_project = let ost_project =
|w: glam::Vec3, view_rot: glam::Mat4, eye: glam::DVec3, ob: iced::Rectangle| { |w: glam::DVec3, view_rot: glam::Mat4, eye: glam::DVec3, ob: iced::Rectangle| {
let ndc = view_rot.project_point3((w.as_dvec3() - eye).as_vec3()); let ndc = view_rot.project_point3((w - eye).as_vec3());
iced::Point::new( iced::Point::new(
ob.x + (ndc.x + 1.0) * 0.5 * ob.width, ob.x + (ndc.x + 1.0) * 0.5 * ob.width,
ob.y + (1.0 - ndc.y) * 0.5 * ob.height, ob.y + (1.0 - ndc.y) * 0.5 * ob.height,
@@ -446,7 +446,7 @@ impl OpenCADStudio {
match (otrack_proj, self.otrack_active) { match (otrack_proj, self.otrack_active) {
(Some((view_rot, eye, ob)), Some((base, _dir))) => { (Some((view_rot, eye, ob)), Some((base, _dir))) => {
let b = ost_project(base, view_rot, eye, ob); let b = ost_project(base, view_rot, eye, ob);
let a = ost_project(tab.last_cursor_world.as_vec3(), 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)) (b.x.is_finite() && a.x.is_finite()).then_some((b, a))
} }
_ => None, _ => None,
@@ -455,7 +455,7 @@ impl OpenCADStudio {
let parallel_ref_marker: Option<iced::Point> = let parallel_ref_marker: Option<iced::Point> =
match (otrack_proj, self.snapper.parallel_ref) { match (otrack_proj, self.snapper.parallel_ref) {
(Some((view_rot, eye, ob)), Some((_, pt))) => { (Some((view_rot, eye, ob)), Some((_, pt))) => {
let s = ost_project(pt, view_rot, eye, ob); let s = ost_project(pt.as_dvec3(), view_rot, eye, ob);
(s.x.is_finite() && s.y.is_finite()).then_some(s) (s.x.is_finite() && s.y.is_finite()).then_some(s)
} }
_ => None, _ => None,
+78 -78
View File
@@ -4,7 +4,7 @@
//! Endpoint, Midpoint, Center, Node, Quadrant, Intersection, //! Endpoint, Midpoint, Center, Node, Quadrant, Intersection,
//! Extension, Insertion, Perpendicular, Nearest, ApparentIntersection, Grid, Tangent //! Extension, Insertion, Perpendicular, Nearest, ApparentIntersection, Grid, Tangent
use glam::{Mat4, Vec3}; use glam::{DVec3, Mat4, Vec3};
use iced::time::Instant; use iced::time::Instant;
use iced::{Point, Rectangle}; use iced::{Point, Rectangle};
@@ -80,11 +80,11 @@ pub struct SnapResult {
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub struct OtrackHit { pub struct OtrackHit {
/// Cursor projected onto the tracking ray. /// Cursor projected onto the tracking ray.
pub aligned: Vec3, pub aligned: DVec3,
/// Unit ray direction toward the cursor side (for typed-distance entry). /// Unit ray direction toward the cursor side (for typed-distance entry).
pub dir: Vec3, pub dir: DVec3,
/// The tracking point the ray emanates from. /// The tracking point the ray emanates from.
pub base: Vec3, pub base: DVec3,
} }
// ── Snapper ─────────────────────────────────────────────────────────────── // ── Snapper ───────────────────────────────────────────────────────────────
@@ -108,16 +108,16 @@ pub struct Snapper {
/// Object Snap Tracking on/off (F11). /// Object Snap Tracking on/off (F11).
pub otrack_enabled: bool, pub otrack_enabled: bool,
/// Acquired OST points (world XZ, Y=0 plane). /// Acquired OST points (world XZ, Y=0 plane).
pub tracking_points: Vec<Vec3>, pub tracking_points: Vec<DVec3>,
/// Edge directions at each acquired point (parallel to `tracking_points`): /// Edge directions at each acquired point (parallel to `tracking_points`):
/// the line direction of every wire segment meeting at that corner, so /// the line direction of every wire segment meeting at that corner, so
/// OTRACK can offer an alignment ray along a segment's extension, not only /// OTRACK can offer an alignment ray along a segment's extension, not only
/// the ortho/polar axes. Pulling the cursor along an acquired corner's edge /// the ortho/polar axes. Pulling the cursor along an acquired corner's edge
/// then locks to that line (#219). Empty for a point that is not a segment /// then locks to that line (#219). Empty for a point that is not a segment
/// endpoint (e.g. a midpoint or centre acquisition). /// endpoint (e.g. a midpoint or centre acquisition).
pub tracking_dirs: Vec<Vec<Vec3>>, pub tracking_dirs: Vec<Vec<DVec3>>,
/// 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<DVec3>,
/// When the cursor first rested near `last_snap_world`. /// When the cursor first rested near `last_snap_world`.
pub dwell_since: Option<Instant>, pub dwell_since: Option<Instant>,
/// Whether the current dwell already acquired/removed a point (fire once). /// Whether the current dwell already acquired/removed a point (fire once).
@@ -231,14 +231,11 @@ impl Snapper {
if self.tracking_points.is_empty() { if self.tracking_points.is_empty() {
return false; return false;
} }
let pf = p.as_vec3(); let tol = 1e-8_f64.max(2e-12 * p.x.abs().max(p.y.abs()));
let tol = 1e-4_f32.max(4e-7 * pf.x.abs().max(pf.y.abs()));
let tol2 = tol * tol; let tol2 = tol * tol;
self.tracking_points.iter().any(|t| { self.tracking_points
let dx = t.x - pf.x; .iter()
let dy = t.y - pf.y; .any(|t| (*t - p).length_squared() < tol2)
dx * dx + dy * dy < tol2
})
} }
pub fn toggle_global(&mut self) { pub fn toggle_global(&mut self) {
@@ -283,7 +280,7 @@ impl Snapper {
/// `snap_world` is the current snap result world point (if any). /// `snap_world` is the current snap result world point (if any).
pub fn update_otrack_dwell( pub fn update_otrack_dwell(
&mut self, &mut self,
snap_world: Option<Vec3>, snap_world: Option<DVec3>,
wires: &[WireModel], wires: &[WireModel],
view_rot: glam::Mat4, view_rot: glam::Mat4,
eye: glam::DVec3, eye: glam::DVec3,
@@ -325,8 +322,8 @@ impl Snapper {
Some(p) => { Some(p) => {
// Convert to screen to measure pixel distance. // Convert to screen to measure pixel distance.
let is_same = if let Some(prev) = self.last_snap_world { let is_same = if let Some(prev) = self.last_snap_world {
let dp = world_to_screen(p.as_dvec3(), view_rot, eye, bounds); let dp = world_to_screen(p, view_rot, eye, bounds);
let dp2 = world_to_screen(prev.as_dvec3(), view_rot, eye, bounds); let dp2 = world_to_screen(prev, view_rot, eye, bounds);
let dx = dp.x - dp2.x; let dx = dp.x - dp2.x;
let dy = dp.y - dp2.y; let dy = dp.y - dp2.y;
(dx * dx + dy * dy).sqrt() < DWELL_PX (dx * dx + dy * dy).sqrt() < DWELL_PX
@@ -343,7 +340,7 @@ impl Snapper {
// otherwise acquire it. // otherwise acquire it.
let existing = self.tracking_points.iter().position(|t| { let existing = self.tracking_points.iter().position(|t| {
let d = (*t - p).length(); let d = (*t - p).length();
d < self.grid_spacing * 0.1 d < self.grid_spacing as f64 * 0.1
}); });
match existing { match existing {
Some(idx) => { Some(idx) => {
@@ -372,11 +369,11 @@ impl Snapper {
/// it is already tracked; drops the oldest when the 4-point cap is reached. /// 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 /// Edge directions are scanned once here, at acquisition, so OTRACK can align
/// to a segment's extension without rescanning geometry per move (#219). /// to a segment's extension without rescanning geometry per move (#219).
fn acquire_tracking_point(&mut self, p: Vec3, wires: &[WireModel], endpoints_only: bool) { fn acquire_tracking_point(&mut self, p: DVec3, wires: &[WireModel], endpoints_only: bool) {
if self if self
.tracking_points .tracking_points
.iter() .iter()
.any(|t| (*t - p).length() < self.grid_spacing * 0.1) .any(|t| (*t - p).length() < self.grid_spacing as f64 * 0.1)
{ {
return; return;
} }
@@ -440,38 +437,40 @@ impl Snapper {
/// cursor side, used for typed-distance entry), and the originating point. /// cursor side, used for typed-distance entry), and the originating point.
pub fn otrack_snap( pub fn otrack_snap(
&self, &self,
cursor_world: Vec3, cursor_world: DVec3,
view_rot: glam::Mat4, view_rot: glam::Mat4,
eye: glam::DVec3, eye: glam::DVec3,
bounds: iced::Rectangle, bounds: iced::Rectangle,
polar_step_deg: Option<f32>, polar_step_deg: Option<f32>,
last_point: Option<Vec3>, last_point: Option<DVec3>,
// Ortho on: the axis from `last_point` is a hard lock. Only crossings of // Ortho on: the axis from `last_point` is a hard lock. Only crossings of
// an acquired ray with that axis lock; single tracking rays are // an acquired ray with that axis lock; single tracking rays are
// suppressed so the cursor can't leave the ortho axis. (#218) // suppressed so the cursor can't leave the ortho axis. (#218)
ortho: bool, ortho: bool,
// UCS→world rotation: tracking rays run along the UCS axes, matching // UCS axes in world space: tracking rays follow the active UCS while
// ortho/polar. Identity = world-aligned rays. // their world geometry stays in f64.
ucs: glam::Mat4, ucs_x: DVec3,
ucs_y: DVec3,
) -> Option<OtrackHit> { ) -> Option<OtrackHit> {
if !self.otrack_enabled || self.tracking_points.is_empty() { if !self.otrack_enabled || self.tracking_points.is_empty() {
return None; return None;
} }
let cursor_screen = world_to_screen(cursor_world.as_dvec3(), view_rot, eye, bounds); let cursor_screen = world_to_screen(cursor_world, view_rot, eye, bounds);
// Use the same aperture as OSNAP so the catch distance is uniform. // Use the same aperture as OSNAP so the catch distance is uniform.
let r = self.osnap_radius_px; let r = self.osnap_radius_px;
let screen_dist = |w: Vec3| { let screen_dist = |w: DVec3| {
let s = world_to_screen(w.as_dvec3(), view_rot, eye, bounds); let s = world_to_screen(w, view_rot, eye, bounds);
((s.x - cursor_screen.x).powi(2) + (s.y - cursor_screen.y).powi(2)).sqrt() ((s.x - cursor_screen.x).powi(2) + (s.y - cursor_screen.y).powi(2)).sqrt()
}; };
// Candidate angles in [0,180); each ray extends both ways via the // Candidate angles in [0,180); each ray extends both ways via the
// signed projection `t`, so 0°/90° cover horizontal/vertical. // signed projection `t`, so 0°/90° cover horizontal/vertical.
let mut angles: Vec<f32> = Vec::new(); let mut angles: Vec<f64> = Vec::new();
match polar_step_deg.filter(|s| *s > 1e-3) { match polar_step_deg.filter(|s| *s > 1e-3) {
Some(step) => { Some(step) => {
let mut a = 0.0_f32; let step = step as f64;
let mut a = 0.0_f64;
while a < 180.0 - 1e-3 { while a < 180.0 - 1e-3 {
angles.push(a); angles.push(a);
a += step; a += step;
@@ -487,8 +486,8 @@ impl Snapper {
// origin (a parallel pencil that only meets at that origin) are never // origin (a parallel pencil that only meets at that origin) are never
// intersected with each other. // intersected with each other.
struct Ray { struct Ray {
origin: Vec3, origin: DVec3,
dir: Vec3, dir: DVec3,
group: usize, group: usize,
} }
let mut rays: Vec<Ray> = Vec::new(); let mut rays: Vec<Ray> = Vec::new();
@@ -497,7 +496,7 @@ impl Snapper {
let ar = adeg.to_radians(); let ar = adeg.to_radians();
rays.push(Ray { rays.push(Ray {
origin: tp, origin: tp,
dir: ucs.transform_vector3(Vec3::new(ar.cos(), ar.sin(), 0.0)), dir: ucs_x * ar.cos() + ucs_y * ar.sin(),
group: gi, group: gi,
}); });
} }
@@ -523,11 +522,11 @@ impl Snapper {
if let Some(bp) = last_point { if let Some(bp) = last_point {
let d = tp - bp; let d = tp - bp;
let len2 = d.x * d.x + d.y * d.y; let len2 = d.x * d.x + d.y * d.y;
if len2 > 1e-12 { if len2 > 1e-24 {
let inv = 1.0 / len2.sqrt(); let inv = 1.0 / len2.sqrt();
rays.push(Ray { rays.push(Ray {
origin: bp, origin: bp,
dir: Vec3::new(d.x * inv, d.y * inv, 0.0), dir: DVec3::new(d.x * inv, d.y * inv, 0.0),
group: gi, group: gi,
}); });
} }
@@ -540,12 +539,13 @@ impl Snapper {
const POLAR_GROUP: usize = usize::MAX; const POLAR_GROUP: usize = usize::MAX;
const ORTHO_GROUP: usize = usize::MAX - 1; const ORTHO_GROUP: usize = usize::MAX - 1;
if let (Some(step), Some(lp)) = (polar_step_deg.filter(|s| *s > 1e-3), last_point) { if let (Some(step), Some(lp)) = (polar_step_deg.filter(|s| *s > 1e-3), last_point) {
let mut a = 0.0_f32; let step = step as f64;
let mut a = 0.0_f64;
while a < 180.0 - 1e-3 { while a < 180.0 - 1e-3 {
let ar = a.to_radians(); let ar = a.to_radians();
rays.push(Ray { rays.push(Ray {
origin: lp, origin: lp,
dir: ucs.transform_vector3(Vec3::new(ar.cos(), ar.sin(), 0.0)), dir: ucs_x * ar.cos() + ucs_y * ar.sin(),
group: POLAR_GROUP, group: POLAR_GROUP,
}); });
a += step; a += step;
@@ -555,11 +555,11 @@ impl Snapper {
// ortho axis locks on-axis (the useful corner-finding case). (#218) // ortho axis locks on-axis (the useful corner-finding case). (#218)
let ortho_lock = ortho && last_point.is_some(); let ortho_lock = ortho && last_point.is_some();
if let (true, Some(lp)) = (ortho_lock, last_point) { if let (true, Some(lp)) = (ortho_lock, last_point) {
for &adeg in &[0.0_f32, 90.0] { for &adeg in &[0.0_f64, 90.0] {
let ar = adeg.to_radians(); let ar = adeg.to_radians();
rays.push(Ray { rays.push(Ray {
origin: lp, origin: lp,
dir: ucs.transform_vector3(Vec3::new(ar.cos(), ar.sin(), 0.0)), dir: ucs_x * ar.cos() + ucs_y * ar.sin(),
group: ORTHO_GROUP, group: ORTHO_GROUP,
}); });
} }
@@ -621,7 +621,7 @@ impl Snapper {
for ray in rays.iter().take(otrack_ray_count) { for ray in rays.iter().take(otrack_ray_count) {
let t = (cursor_world.x - ray.origin.x) * ray.dir.x let t = (cursor_world.x - ray.origin.x) * ray.dir.x
+ (cursor_world.y - ray.origin.y) * ray.dir.y; + (cursor_world.y - ray.origin.y) * ray.dir.y;
let aligned = Vec3::new( let aligned = DVec3::new(
ray.origin.x + ray.dir.x * t, ray.origin.x + ray.dir.x * t,
ray.origin.y + ray.dir.y * t, ray.origin.y + ray.dir.y * t,
ray.origin.z, ray.origin.z,
@@ -1507,17 +1507,13 @@ fn snap_priority(t: SnapType) -> u8 {
/// alignment ray along each so the cursor can track a segment's extension, not /// alignment ray along each so the cursor can track a segment's extension, not
/// just the ortho/polar axes (#219). Scanned once, at acquisition — not per /// just the ortho/polar axes (#219). Scanned once, at acquisition — not per
/// move. Empty when `p` is not a segment endpoint (midpoint / centre / node). /// move. Empty when `p` is not a segment endpoint (midpoint / centre / node).
fn edge_dirs_at(p: Vec3, wires: &[WireModel]) -> Vec<Vec3> { fn edge_dirs_at(p: DVec3, wires: &[WireModel]) -> Vec<DVec3> {
// The acquired point is an f32 truncation of the true (f64) vertex, so its // Acquired points and reconstructed wire vertices are both f64. Keep a
// error grows with coordinate magnitude (~1 ULP ≈ 1.2e-7·mag). Scale the // small scale-aware window for double-single reconstruction residuals
// endpoint-match tolerance to a few multiples of that, with a tight floor // without allowing unrelated UTM-scale vertices to match.
// near the origin — a fixed fraction would span metres at UTM scale and let tol = 1e-8_f64.max(2e-12 * p.x.abs().max(p.y.abs()));
// match unrelated vertices, while a magnitude-blind floor would miss the let tol2 = tol * tol;
// corner once f32 rounding exceeds it. let mut dirs: Vec<DVec3> = Vec::new();
let tol = 1e-4_f32.max(4e-7 * p.x.abs().max(p.y.abs()));
let tol2 = (tol * tol) as f64;
let pd = p.as_dvec3();
let mut dirs: Vec<Vec3> = Vec::new();
'outer: for wire in wires { 'outer: for wire in wires {
let n = wire.points.len(); let n = wire.points.len();
if n < 2 { if n < 2 {
@@ -1529,7 +1525,7 @@ fn edge_dirs_at(p: Vec3, wires: &[WireModel]) -> Vec<Vec3> {
if !a.x.is_finite() || !b.x.is_finite() { if !a.x.is_finite() || !b.x.is_finite() {
continue; // NaN sentinel separates sub-paths continue; // NaN sentinel separates sub-paths
} }
if (a - pd).length_squared() >= tol2 && (b - pd).length_squared() >= tol2 { if (a - p).length_squared() >= tol2 && (b - p).length_squared() >= tol2 {
continue; // neither endpoint is the acquired corner continue; // neither endpoint is the acquired corner
} }
let seg = b - a; let seg = b - a;
@@ -1537,7 +1533,7 @@ fn edge_dirs_at(p: Vec3, wires: &[WireModel]) -> Vec<Vec3> {
if l < 1e-9 { if l < 1e-9 {
continue; continue;
} }
let d = Vec3::new((seg.x / l) as f32, (seg.y / l) as f32, 0.0); let d = DVec3::new(seg.x / l, seg.y / l, 0.0);
// Skip a direction already present (parallel within ~0.5°); the ray // Skip a direction already present (parallel within ~0.5°); the ray
// is bidirectional, so opposite signs are the same alignment line. // is bidirectional, so opposite signs are the same alignment line.
if dirs.iter().any(|e| (e.x * d.x + e.y * d.y).abs() > 0.99996) { if dirs.iter().any(|e| (e.x * d.x + e.y * d.y).abs() > 0.99996) {
@@ -1632,15 +1628,15 @@ fn seg_intersect_3d(a0: glam::DVec3, a1: glam::DVec3, b0: glam::DVec3, b1: glam:
/// Intersection of two infinite lines in the XY plane, each given by an origin /// Intersection of two infinite lines in the XY plane, each given by an origin
/// and a direction. Returns `None` when the lines are parallel. /// and a direction. Returns `None` when the lines are parallel.
fn line_intersect_xy(o1: Vec3, d1: Vec3, o2: Vec3, d2: Vec3) -> Option<Vec3> { fn line_intersect_xy(o1: DVec3, d1: DVec3, o2: DVec3, d2: DVec3) -> Option<DVec3> {
let cross = d1.x * d2.y - d1.y * d2.x; let cross = d1.x * d2.y - d1.y * d2.x;
if cross.abs() < 1e-9 { if cross.abs() < 1e-15 {
return None; return None;
} }
let ex = o2.x - o1.x; let ex = o2.x - o1.x;
let ey = o2.y - o1.y; let ey = o2.y - o1.y;
let t = (ex * d2.y - ey * d2.x) / cross; let t = (ex * d2.y - ey * d2.x) / cross;
Some(Vec3::new(o1.x + d1.x * t, o1.y + d1.y * t, o1.z)) Some(DVec3::new(o1.x + d1.x * t, o1.y + d1.y * t, o1.z))
} }
/// Screen-space 2D segment intersection. Returns `(t, s)` parameters if found. /// Screen-space 2D segment intersection. Returns `(t, s)` parameters if found.
@@ -1948,14 +1944,9 @@ mod ext_tests {
// Nothing acquired → no endpoint is a live extension source (#262). // Nothing acquired → no endpoint is a live extension source (#262).
assert!(!s.is_tracked_endpoint(glam::DVec3::new(10.0, 0.0, 0.0))); assert!(!s.is_tracked_endpoint(glam::DVec3::new(10.0, 0.0, 0.0)));
// Acquire an endpoint → only that vertex tracks. // Acquire an endpoint → only that vertex tracks.
s.tracking_points.push(Vec3::new(10.0, 0.0, 0.0)); s.tracking_points.push(DVec3::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(10.0, 0.0, 0.0)));
assert!(!s.is_tracked_endpoint(glam::DVec3::new(5.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)));
} }
#[test] #[test]
@@ -1973,13 +1964,13 @@ mod ext_tests {
// Extension-driven acquisition (endpoints_only): a midpoint — like an // Extension-driven acquisition (endpoints_only): a midpoint — like an
// extension foot the cursor paused on — is ignored, so it can't fill the // extension foot the cursor paused on — is ignored, so it can't fill the
// buffer and evict the real endpoint (#262). // buffer and evict the real endpoint (#262).
s.acquire_tracking_point(Vec3::new(5.0, 0.0, 0.0), &wires, true); s.acquire_tracking_point(DVec3::new(5.0, 0.0, 0.0), &wires, true);
assert!(s.tracking_points.is_empty()); assert!(s.tracking_points.is_empty());
// The genuine endpoint is acquired. // The genuine endpoint is acquired.
s.acquire_tracking_point(Vec3::new(10.0, 0.0, 0.0), &wires, true); s.acquire_tracking_point(DVec3::new(10.0, 0.0, 0.0), &wires, true);
assert_eq!(s.tracking_points.len(), 1); assert_eq!(s.tracking_points.len(), 1);
// OTRACK (endpoints_only = false) still acquires any snap point. // OTRACK (endpoints_only = false) still acquires any snap point.
s.acquire_tracking_point(Vec3::new(5.0, 0.0, 0.0), &wires, false); s.acquire_tracking_point(DVec3::new(5.0, 0.0, 0.0), &wires, false);
assert_eq!(s.tracking_points.len(), 2); assert_eq!(s.tracking_points.len(), 2);
} }
@@ -1990,13 +1981,13 @@ mod ext_tests {
s.osnap_radius_px = 10.0; s.osnap_radius_px = 10.0;
// An acquired corner of an existing line, and the base point (first // An acquired corner of an existing line, and the base point (first
// point) of the line currently being drawn. // point) of the line currently being drawn.
let corner = Vec3::new(4.0, 2.0, 0.0); let corner = DVec3::new(8_000.0, 6_000.0, 0.0);
s.tracking_points.push(corner); s.tracking_points.push(corner);
s.tracking_dirs.push(Vec::new()); s.tracking_dirs.push(Vec::new());
let base = Vec3::new(0.0, 0.0, 0.0); let base = DVec3::new(0.0, 0.0, 0.0);
// A plain orthographic camera: world * 0.1 → NDC, eye at the origin. // A plain orthographic camera: world * 0.0001 → NDC.
let view_rot = Mat4::from_scale(Vec3::splat(0.1)); let view_rot = Mat4::from_scale(Vec3::splat(0.0001));
let eye = glam::DVec3::ZERO; let eye = glam::DVec3::ZERO;
let bounds = Rectangle { let bounds = Rectangle {
x: 0.0, x: 0.0,
@@ -2005,12 +1996,11 @@ mod ext_tests {
height: 1000.0, height: 1000.0,
}; };
// Cursor past the corner along base→corner (≈26.57°, not an ortho/polar // Cursor 5000 units along the base→corner ray (3-4-5 direction), nudged
// axis), nudged a hair off the line — only the new base→corner ray can // a hair off it. The OTRACK result must retain that exact distance.
// lock it.
let dir = (corner - base).normalize(); let dir = (corner - base).normalize();
let perp = Vec3::new(-dir.y, dir.x, 0.0); let perp = DVec3::new(-dir.y, dir.x, 0.0);
let cursor = base + dir * 6.0 + perp * 0.05; let cursor = base + dir * 5_000.0 + perp * 0.05;
let hit = s let hit = s
.otrack_snap( .otrack_snap(
@@ -2021,23 +2011,33 @@ mod ext_tests {
None, None,
Some(base), Some(base),
false, false,
Mat4::IDENTITY, DVec3::X,
DVec3::Y,
) )
.expect("base→corner alignment should lock"); .expect("base→corner alignment should lock");
// The aligned point lies on the base→corner line, and the reported base // The aligned point lies on the base→corner line, and the reported base
// is the command's base point (so typed-distance runs from there). // is the command's base point (so typed-distance runs from there).
let off = hit.aligned - base; let off = hit.aligned - base;
let cross = off.x * dir.y - off.y * dir.x; let cross = off.x * dir.y - off.y * dir.x;
assert!((off.length() - 5_000.0).abs() < 1e-10);
assert!( assert!(
cross.abs() < 1e-3, cross.abs() < 1e-12,
"aligned point off the base→corner line: {:?}", "aligned point off the base→corner line: {:?}",
hit.aligned hit.aligned
); );
assert!((hit.base - base).length() < 1e-3, "ray base is not the command base"); assert!((hit.base - base).length() < 1e-12, "ray base is not the command base");
// Without a base point the alignment does not exist (nothing to join). // Without a base point the alignment does not exist (nothing to join).
let none = s.otrack_snap( let none = s.otrack_snap(
cursor, view_rot, eye, bounds, None, None, false, Mat4::IDENTITY, cursor,
view_rot,
eye,
bounds,
None,
None,
false,
DVec3::X,
DVec3::Y,
); );
assert!(none.is_none(), "no base point → no base→corner alignment"); assert!(none.is_none(), "no base point → no base→corner alignment");
} }