diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 7fee61977..93bc97eb6 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -900,6 +900,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { 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 { bounds, step, self.last_point, + self.ortho_mode, ucs, ) } else { @@ -1803,7 +1805,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { }; 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 }; diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index a9e4f5de3..e2451fb76 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -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 = 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 = + 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, diff --git a/src/snap.rs b/src/snap.rs index 5c18198ba..aac77cf19 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -109,6 +109,13 @@ pub struct Snapper { pub otrack_enabled: bool, /// Acquired OST points (world XZ, Y=0 plane). pub tracking_points: Vec, + /// 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>, /// Last snap world position (for dwell detection). pub last_snap_world: Option, /// 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, + 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, last_point: Option, + // 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 { + // 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 = 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 diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index bae579367..432acc4c0 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -185,7 +185,7 @@ pub fn selection_overlay<'a>( grips: Vec, ucs_icons: Vec, ost_points: Vec, - cursor_screen: Point, + otrack_line: Option<(Point, Point)>, show_viewcube: bool, dividers: Vec, pane_move_rect: Option, @@ -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, ost_points: Vec, - 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 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 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()]