feat(snap): OTRACK ortho lock + edge-extension tracking (#218, #219)

#218: with Ortho on and a base point the axis is now a hard lock — a lone
tracking ray can't pull the cursor off it. Only a tracking ray crossing
the ortho axis locks (the corner-finding case); otherwise the ortho
constraint applies as the fallback, so Ortho reliably locks when OTRACK
is also on.

#219: an acquired corner now contributes an alignment ray along its own
edge direction(s) (captured once at acquisition), so OTRACK follows a
segment's extension, not just the ortho/polar axes. Acquisition is also
captured on leave — a perfectly still cursor emits no move events, so
"pause on a corner, then drag" now reliably acquires it. The acquired
marker projects through the active pane's camera + rect (canvas offset
included) instead of vanishing, and the active alignment path is drawn
as a full-length dashed guide through the corner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-03 20:34:20 +03:00
1 parent 0322128f48
commit c3e86d08cc
4 files changed
+271 -61

No files matched your search

+3 -1
View File
@@ -900,6 +900,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
let snap_world = self.tabs[i].snap_result.map(|s| s.world.as_vec3());
self.snapper.update_otrack_dwell(
snap_world,
&all_wires[..],
view_rot,
eye,
bounds,
@@ -919,6 +920,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
bounds,
step,
self.last_point,
self.ortho_mode,
ucs,
)
} else {
@@ -1803,7 +1805,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
};
let ucs = self.tabs[i].scene.viewcube_ucs_mat();
self.snapper
.otrack_snap(raw.as_vec3(), view_rot, eye, bounds, step, self.last_point, ucs)
.otrack_snap(raw.as_vec3(), view_rot, eye, bounds, step, self.last_point, self.ortho_mode, ucs)
} else {
None
};
+54 -22
View File
@@ -332,28 +332,60 @@ impl OpenCADStudio {
// OST tracking points → screen positions, projected relative-to-eye
// so they stay precise at UTM-scale coordinates (the full
// view-projection cancels catastrophically in f32).
let ost_points: Vec<crate::ui::overlay::OstTrackPoint> = if self.snapper.otrack_enabled {
let (view_rot, eye) = {
let cam = tab.scene.camera.borrow();
(cam.view_proj_rte(vp_bounds), cam.eye())
// view-projection cancels catastrophically in f32). Must project
// through the active pane's camera at its rect (canvas offset
// included) — like the grips and UCS icon above — or the marker
// lands in the wrong place (tiled layout) or off-screen (floating
// viewport). Without the `ob.x/ob.y` offset it silently vanishes.
// 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 {
Some(
if let Some((vp_cam, full)) = tab.scene.viewport_edit_frame((vw, vh)) {
(vp_cam.view_proj_rte(full), vp_cam.eye(), full)
} else {
let cam = tab.scene.camera.borrow();
(cam.view_proj_rte(vp_bounds), cam.eye(), vp_bounds)
},
)
} else {
None
};
let ost_project =
|w: glam::Vec3, view_rot: glam::Mat4, eye: glam::DVec3, ob: iced::Rectangle| {
let ndc = view_rot.project_point3((w.as_dvec3() - eye).as_vec3());
iced::Point::new(
ob.x + (ndc.x + 1.0) * 0.5 * ob.width,
ob.y + (1.0 - ndc.y) * 0.5 * ob.height,
)
};
let ost_points: Vec<crate::ui::overlay::OstTrackPoint> =
if let Some((view_rot, eye, ob)) = otrack_proj {
self.snapper
.tracking_points
.iter()
.filter_map(|&wp| {
let s = ost_project(wp, view_rot, eye, ob);
(s.x.is_finite() && s.y.is_finite())
.then_some(crate::ui::overlay::OstTrackPoint { screen: s })
})
.collect()
} else {
vec![]
};
// The active alignment vector: a dashed guide from the acquired
// tracking point through the locked cursor, so the user sees the
// extension / tracking line they are snapped to (#219).
let otrack_line: Option<(iced::Point, iced::Point)> =
match (otrack_proj, self.otrack_active) {
(Some((view_rot, eye, ob)), Some((base, _dir))) => {
let b = ost_project(base, 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))
}
_ => None,
};
self.snapper
.tracking_points
.iter()
.map(|&wp| {
let ndc = view_rot.project_point3((wp.as_dvec3() - eye).as_vec3());
crate::ui::overlay::OstTrackPoint {
screen: iced::Point::new(
(ndc.x + 1.0) * 0.5 * vp_bounds.width,
(1.0 - ndc.y) * 0.5 * vp_bounds.height,
),
}
})
.collect()
} else {
vec![]
};
// Model-space pane dividers (none in paper / single-pane layouts).
let dividers = if !is_paper {
@@ -404,7 +436,7 @@ impl OpenCADStudio {
grips,
ucs_icons,
ost_points,
tab.last_cursor_screen,
otrack_line,
!is_paper && self.show_viewcube,
dividers,
pane_move_rect,
+167 -11
View File
@@ -109,6 +109,13 @@ pub struct Snapper {
pub otrack_enabled: bool,
/// Acquired OST points (world XZ, Y=0 plane).
pub tracking_points: Vec<Vec3>,
/// Edge directions at each acquired point (parallel to `tracking_points`):
/// 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
/// 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
/// endpoint (e.g. a midpoint or centre acquisition).
pub tracking_dirs: Vec<Vec<Vec3>>,
/// Last snap world position (for dwell detection).
pub last_snap_world: Option<Vec3>,
/// When the cursor first rested near `last_snap_world`.
@@ -140,6 +147,7 @@ impl Default for Snapper {
osnap_radius_px: CROSSHAIR_ARM * 0.25,
otrack_enabled: false,
tracking_points: Vec::new(),
tracking_dirs: Vec::new(),
last_snap_world: None,
dwell_since: None,
dwell_acquired: false,
@@ -201,6 +209,7 @@ impl Snapper {
pub fn update_otrack_dwell(
&mut self,
snap_world: Option<Vec3>,
wires: &[WireModel],
view_rot: glam::Mat4,
eye: glam::DVec3,
bounds: iced::Rectangle,
@@ -220,6 +229,9 @@ impl Snapper {
match snap_world {
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.last_snap_world = None;
self.dwell_since = None;
self.dwell_acquired = false;
@@ -242,7 +254,7 @@ impl Snapper {
if !self.dwell_acquired && elapsed >= DWELL_MS {
self.dwell_acquired = true;
// Dwelling over an already-acquired point removes it;
// otherwise acquire it (max 4 tracked points).
// otherwise acquire it.
let existing = self.tracking_points.iter().position(|t| {
let d = (*t - p).length();
d < self.grid_spacing * 0.1
@@ -250,16 +262,18 @@ impl Snapper {
match existing {
Some(idx) => {
self.tracking_points.remove(idx);
}
None => {
if self.tracking_points.len() >= 4 {
self.tracking_points.remove(0);
if idx < self.tracking_dirs.len() {
self.tracking_dirs.remove(idx);
}
self.tracking_points.push(p);
}
None => self.acquire_tracking_point(p, wires),
}
}
} 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.last_snap_world = Some(p);
self.dwell_since = Some(now);
self.dwell_acquired = false;
@@ -268,10 +282,54 @@ impl Snapper {
}
}
/// Add `p` as a tracking point (capturing its corner edge directions) unless
/// 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]) {
if self
.tracking_points
.iter()
.any(|t| (*t - p).length() < self.grid_spacing * 0.1)
{
return;
}
if self.tracking_points.len() >= 4 {
self.tracking_points.remove(0);
if !self.tracking_dirs.is_empty() {
self.tracking_dirs.remove(0);
}
}
self.tracking_points.push(p);
self.tracking_dirs.push(edge_dirs_at(p, wires));
}
/// If the cursor dwelt on a snap point long enough but the in-place check
/// never fired (a perfectly still cursor emits no move events, so the timer
/// 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]) {
if self.dwell_acquired {
return; // already handled by the in-place branch
}
let Some(prev) = self.last_snap_world else {
return;
};
let elapsed = self
.dwell_since
.map_or(0, |t| now.duration_since(t).as_millis());
if elapsed >= dwell_ms {
self.acquire_tracking_point(prev, wires);
}
}
/// Project the cursor onto a tracking ray emanating from one of the
/// acquired tracking points, in the XY plane. Without `polar_step_deg` the
/// rays are horizontal / vertical (0° / 90°); with it, every polar
/// increment is a candidate so the user can track along POLAR angles.
/// increment is a candidate so the user can track along POLAR angles. Each
/// acquired corner also contributes a ray along its own edge directions, so
/// pulling the cursor along a segment's extension locks to that line (#219).
///
/// When the cursor sits near the crossing of two active vectors from
/// different origins the intersection point wins, so the cursor locks onto
@@ -289,6 +347,10 @@ impl Snapper {
bounds: iced::Rectangle,
polar_step_deg: Option<f32>,
last_point: Option<Vec3>,
// 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
// suppressed so the cursor can't leave the ortho axis. (#218)
ortho: bool,
// UCS→world rotation: tracking rays run along the UCS axes, matching
// ortho/polar. Identity = world-aligned rays.
ucs: glam::Mat4,
@@ -340,11 +402,26 @@ impl Snapper {
group: gi,
});
}
// Extension rays along the corner's own edges (world-space geometry
// directions — already oriented, no UCS rotation). Included in the
// single-ray set so pulling the cursor along a segment's extension
// locks to it. (#219)
if let Some(edirs) = self.tracking_dirs.get(gi) {
for &d in edirs {
rays.push(Ray {
origin: tp,
dir: d,
group: gi,
});
}
}
}
// OTRACK rays come first; polar rays (appended below) only participate
// in intersection locking, never in single-ray fallback.
// OTRACK rays come first; the auxiliary rays appended below (polar from
// last_point, ortho axis from last_point) only participate in
// intersection locking, never in single-ray fallback.
let otrack_ray_count = rays.len();
const POLAR_GROUP: usize = usize::MAX;
const ORTHO_GROUP: usize = usize::MAX - 1;
if let (Some(step), Some(lp)) = (polar_step_deg.filter(|s| *s > 1e-3), last_point) {
let mut a = 0.0_f32;
while a < 180.0 - 1e-3 {
@@ -357,6 +434,19 @@ impl Snapper {
a += step;
}
}
// Ortho axis rays from `last_point`, so a tracking ray crossing the
// ortho axis locks on-axis (the useful corner-finding case). (#218)
let ortho_lock = ortho && last_point.is_some();
if let (true, Some(lp)) = (ortho_lock, last_point) {
for &adeg in &[0.0_f32, 90.0] {
let ar = adeg.to_radians();
rays.push(Ray {
origin: lp,
dir: ucs.transform_vector3(Vec3::new(ar.cos(), ar.sin(), 0.0)),
group: ORTHO_GROUP,
});
}
}
// ── Intersection lock — crossing of two vectors from distinct origins.
let mut best_x: Option<(f32, OtrackHit)> = None;
@@ -365,6 +455,11 @@ impl Snapper {
if rays[i].group == rays[j].group {
continue;
}
// Under an ortho lock only crossings that involve the ortho axis
// are valid — every other crossing lies off it. (#218)
if ortho_lock && rays[i].group != ORTHO_GROUP && rays[j].group != ORTHO_GROUP {
continue;
}
let Some(x) =
line_intersect_xy(rays[i].origin, rays[i].dir, rays[j].origin, rays[j].dir)
else {
@@ -372,8 +467,9 @@ impl Snapper {
};
let sd = screen_dist(x);
if sd < r && best_x.as_ref().map_or(true, |(bd, _)| sd < *bd) {
// Report an OTRACK ray as base/dir for typed-distance entry.
let ot = if rays[i].group != POLAR_GROUP {
// Report an acquired tracking ray (not an auxiliary
// last_point ray) as base/dir for typed-distance entry.
let ot = if rays[i].group != POLAR_GROUP && rays[i].group != ORTHO_GROUP {
&rays[i]
} else {
&rays[j]
@@ -395,6 +491,14 @@ impl Snapper {
return Some(h);
}
// With Ortho on and a base point, the axis is a hard lock: no single
// tracking ray may pull the cursor off it. Only crossings with the
// ortho axis (handled above) lock; otherwise defer to the caller's
// ortho constraint. (#218)
if ortho_lock {
return None;
}
// ── Single-ray alignment (OTRACK rays only) ──
let mut best: Option<(f32, OtrackHit)> = None;
for ray in rays.iter().take(otrack_ray_count) {
@@ -424,6 +528,7 @@ impl Snapper {
/// Clear all acquired tracking points (e.g. when command ends).
pub fn clear_tracking(&mut self) {
self.tracking_points.clear();
self.tracking_dirs.clear();
self.last_snap_world = None;
self.dwell_since = None;
self.dwell_acquired = false;
@@ -451,6 +556,7 @@ impl Snapper {
osnap_radius_px: self.osnap_radius_px,
otrack_enabled: false,
tracking_points: Vec::new(),
tracking_dirs: Vec::new(),
last_snap_world: None,
dwell_since: None,
dwell_acquired: false,
@@ -1061,6 +1167,56 @@ fn snap_priority(t: SnapType) -> u8 {
// ── Geometric helpers ─────────────────────────────────────────────────────
/// Line directions of every wire segment that has an endpoint at `p` (an
/// acquired corner), deduped by near-parallelism and capped. OTRACK offers an
/// 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
/// move. Empty when `p` is not a segment endpoint (midpoint / centre / node).
fn edge_dirs_at(p: Vec3, wires: &[WireModel]) -> Vec<Vec3> {
// The acquired point is an f32 truncation of the true (f64) vertex, so its
// error grows with coordinate magnitude (~1 ULP ≈ 1.2e-7·mag). Scale the
// endpoint-match tolerance to a few multiples of that, with a tight floor
// near the origin — a fixed fraction would span metres at UTM scale and
// match unrelated vertices, while a magnitude-blind floor would miss the
// corner once f32 rounding exceeds it.
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 {
let n = wire.points.len();
if n < 2 {
continue;
}
for i in 0..n - 1 {
let a = wp_f64(wire, i);
let b = wp_f64(wire, i + 1);
if !a.x.is_finite() || !b.x.is_finite() {
continue; // NaN sentinel separates sub-paths
}
if (a - pd).length_squared() >= tol2 && (b - pd).length_squared() >= tol2 {
continue; // neither endpoint is the acquired corner
}
let seg = b - a;
let l = (seg.x * seg.x + seg.y * seg.y).sqrt();
if l < 1e-9 {
continue;
}
let d = Vec3::new((seg.x / l) as f32, (seg.y / l) as f32, 0.0);
// Skip a direction already present (parallel within ~0.5°); the ray
// 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) {
continue;
}
dirs.push(d);
if dirs.len() >= 6 {
break 'outer;
}
}
}
dirs
}
/// Reconstruct the absolute f64 position of wire vertex `i` from its
/// double-single high/low pair. At UTM-scale coordinates the `points` (high)
/// f32 alone is ~0.5 m off; adding the low residual restores f64 precision so
+47 -27
View File
@@ -185,7 +185,7 @@ pub fn selection_overlay<'a>(
grips: Vec<GripMarker>,
ucs_icons: Vec<UcsIconParams>,
ost_points: Vec<OstTrackPoint>,
cursor_screen: Point,
otrack_line: Option<(Point, Point)>,
show_viewcube: bool,
dividers: Vec<iced::Rectangle>,
pane_move_rect: Option<iced::Rectangle>,
@@ -202,7 +202,7 @@ pub fn selection_overlay<'a>(
grips,
ucs_icons,
ost_points,
cursor_screen,
otrack_line,
show_viewcube,
dividers,
pane_move_rect,
@@ -231,7 +231,11 @@ struct SelectionCanvas {
/// entry carries hover/selected (grips).
ucs_icons: Vec<UcsIconParams>,
ost_points: Vec<OstTrackPoint>,
cursor_screen: Point,
/// Active OTRACK alignment: (acquired tracking point, locked cursor), both
/// in screen space. Drawn as a dashed guide extended a little past the
/// cursor so the extension / tracking line the user snapped to is visible.
/// (#219)
otrack_line: Option<(Point, Point)>,
show_viewcube: bool,
/// Divider bars (pixel rects, canvas-relative) between Model panes — drawn
/// as filled lines and used to suppress the crosshair over a divider.
@@ -1003,32 +1007,48 @@ impl canvas::Program<Message> for SelectionCanvas {
);
}
// ── Object Snap Tracking lines ────────────────────────────────────
// ── Object Snap Tracking ─────────────────────────────────────────────
let track_color = Color {
r: 0.15,
g: 0.85,
b: 0.95,
a: 0.7,
};
// The alignment line the cursor is currently locked to — drawn at its
// real angle from the acquired point through the lock and a little
// beyond, dashed so it reads as a construction guide. This covers the
// ortho (0°/90°), polar, and edge-extension cases uniformly (#219).
if let Some((base, tip)) = self.otrack_line {
let dx = tip.x - base.x;
let dy = tip.y - base.y;
let len = (dx * dx + dy * dy).sqrt();
if len > 1e-3 {
// Extend the alignment path well past both ends along its
// direction (the canvas clips it to the viewport) so it reads
// as a full construction line through the acquired point, not a
// stub between the corner and the cursor. (#219)
let (ux, uy) = (dx / len, dy / len);
const L: f32 = 5000.0;
let p0 = Point::new(base.x - ux * L, base.y - uy * L);
let p1 = Point::new(tip.x + ux * L, tip.y + uy * L);
let dash = canvas::Stroke {
line_dash: canvas::LineDash {
segments: &[6.0, 4.0],
offset: 0,
},
..canvas::Stroke::default()
.with_color(track_color)
.with_width(1.0)
};
frame.stroke(&canvas::Path::line(p0, p1), dash);
}
}
// Small cross at each acquired tracking point.
for ost in &self.ost_points {
let tp = ost.screen;
let cx = self.cursor_screen.x;
let cy = self.cursor_screen.y;
let track_color = Color {
r: 0.15,
g: 0.85,
b: 0.95,
a: 0.7,
};
let dash_stroke = canvas::Stroke::default()
let stroke = canvas::Stroke::default()
.with_color(track_color)
.with_width(1.0);
// Draw horizontal line from tracking point to cursor.
if (cy - tp.y).abs() < 8.0 {
let path = canvas::Path::line(tp, Point { x: cx, y: tp.y });
frame.stroke(&path, dash_stroke.clone());
}
// Draw vertical line.
if (cx - tp.x).abs() < 8.0 {
let path = canvas::Path::line(tp, Point { x: tp.x, y: cy });
frame.stroke(&path, dash_stroke.clone());
}
// Small cross at the tracking point.
let sz = 5.0_f32;
let h = canvas::Path::line(
Point {
@@ -1050,8 +1070,8 @@ impl canvas::Program<Message> for SelectionCanvas {
y: tp.y + sz,
},
);
frame.stroke(&h, dash_stroke.clone());
frame.stroke(&v, dash_stroke);
frame.stroke(&h, stroke.clone());
frame.stroke(&v, stroke);
}
vec![frame.into_geometry()]