diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 3f7933f2e..fa833b75f 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -951,7 +951,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { eye, bounds, ) { - if let (Some(base), Some(dir)) = + if let (Some(base), Some((dir, _))) = (self.last_point, self.snapper.parallel_ref) { self.otrack_active = Some((base, dir)); diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 1c2b8e245..cb2aec467 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -386,6 +386,15 @@ impl OpenCADStudio { } _ => None, }; + // The acquired Parallel-snap reference, marked on its line (#277). + let parallel_ref_marker: Option = + match (otrack_proj, self.snapper.parallel_ref) { + (Some((view_rot, eye, ob)), Some((_, pt))) => { + let s = ost_project(pt, view_rot, eye, ob); + (s.x.is_finite() && s.y.is_finite()).then_some(s) + } + _ => None, + }; // Model-space pane dividers (none in paper / single-pane layouts). let dividers = if !is_paper { @@ -437,6 +446,7 @@ impl OpenCADStudio { ucs_icons, ost_points, otrack_line, + parallel_ref_marker, !is_paper && self.show_viewcube, dividers, pane_move_rect, diff --git a/src/snap.rs b/src/snap.rs index 0ecd5bfac..cac31adde 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -127,14 +127,16 @@ pub struct Snapper { /// segment is genuinely perpendicular to the target — without it, perp /// would just give the nearest point on the line. Set before each `snap`. pub from_point: Option, - /// Parallel snap: the acquired reference line direction (unit, XY plane). - /// When the cursor's direction from the command's start point runs parallel - /// to this, the point locks onto that parallel line. Works with only the - /// Parallel object snap on — independent of OTRACK (#277). - pub parallel_ref: Option, - /// Dwell state for acquiring `parallel_ref`: the candidate line direction - /// under the cursor and when it was first hovered. - parallel_dwell: Option<(Vec3, Instant)>, + /// Parallel snap: the acquired reference as (unit direction, a point on the + /// line). When the cursor's direction from the command's start point runs + /// parallel to this, the point locks onto that parallel line; the point half + /// marks the reference on screen. Works with only the Parallel object snap + /// on — independent of OTRACK (#277). + pub parallel_ref: Option<(Vec3, Vec3)>, + /// Dwell state for acquiring/removing `parallel_ref`: the candidate line + /// direction + point under the cursor, when it was first hovered, and + /// whether this dwell has already fired (so it acquires/toggles once). + parallel_dwell: Option<(Vec3, Vec3, Instant, bool)>, } impl Default for Snapper { @@ -606,11 +608,12 @@ impl Snapper { } /// Parallel snap acquisition. When the Parallel object snap is on, hovering - /// a line (or polyline segment) for a short dwell acquires its direction as - /// the parallel reference; the reference persists once the cursor moves off - /// so the user can then draw parallel to it. Curves (circle/arc/ellipse) are - /// ignored — "parallel to a curve" is undefined. Call on every viewport move. - /// (#277) + /// a line (or polyline segment) for a short dwell acquires it as the parallel + /// reference (its direction + a point on it, which marks it on screen); the + /// reference persists once the cursor moves off so the user can then draw + /// parallel to it. Dwelling on the SAME reference line again removes it + /// (toggle). Curves (circle/arc/ellipse) are ignored — "parallel to a curve" + /// is undefined. Call on every viewport move. (#277) pub fn update_parallel( &mut self, cursor_world: Vec3, @@ -626,26 +629,35 @@ impl Snapper { return; } const PAR_DWELL_MS: u128 = 150; - let hovered = - nearest_segment_dir(cursor_world, wires, view_rot, eye, bounds, self.osnap_radius_px); - match hovered { - Some(d) => { - // A line and its reverse are the same alignment. - let same = self - .parallel_dwell - .map_or(false, |(pd, _)| (pd.x * d.x + pd.y * d.y).abs() > 0.9998); - match self.parallel_dwell { - Some((pd, since)) if same => { - if now.duration_since(since).as_millis() >= PAR_DWELL_MS { - self.parallel_ref = Some(pd); - } - } - _ => self.parallel_dwell = Some((d, now)), + let parallel = |a: Vec3, b: Vec3| (a.x * b.x + a.y * b.y).abs() > 0.9998; + let Some((dir, pt)) = + nearest_segment(cursor_world, wires, view_rot, eye, bounds, self.osnap_radius_px) + else { + // Off all lines: drop the in-progress candidate, keep the reference. + self.parallel_dwell = None; + return; + }; + // Restart the dwell when the hovered line changes (different direction, + // or a parallel line far from the candidate's point on screen). + let same_candidate = self.parallel_dwell.map_or(false, |(cd, cp, _, _)| { + parallel(cd, dir) + && screen_perp_dist(pt, cp, cd, view_rot, eye, bounds) < self.osnap_radius_px + }); + match self.parallel_dwell { + Some((cd, cp, since, fired)) if same_candidate => { + if !fired && now.duration_since(since).as_millis() >= PAR_DWELL_MS { + // Dwelt long enough: acquire this line, or remove it if it is + // already the reference (hovering it a second time toggles). + let is_ref = self.parallel_ref.map_or(false, |(rd, rp)| { + parallel(rd, dir) + && screen_perp_dist(pt, rp, rd, view_rot, eye, bounds) + < self.osnap_radius_px + }); + self.parallel_ref = if is_ref { None } else { Some((dir, pt)) }; + self.parallel_dwell = Some((cd, cp, since, true)); } } - // Off all lines: stop tracking a new candidate but keep the acquired - // reference so the user can draw parallel to it. - None => self.parallel_dwell = None, + _ => self.parallel_dwell = Some((dir, pt, now, false)), } } @@ -664,7 +676,7 @@ impl Snapper { if !(self.snap_enabled && self.is_on(SnapType::Parallel)) { return None; } - let dir = self.parallel_ref?; + let (dir, _) = self.parallel_ref?; let base = base?; let d = cursor_world - base; // Need a bit of travel from the base, and the cursor must be pulling @@ -1675,22 +1687,22 @@ fn dist2(a: Point, b: Point) -> f32 { dx * dx + dy * dy } -/// Direction (unit, XY) of the nearest line / polyline segment under the -/// cursor, within `aperture_px` in screen space, or None. Tessellated curves -/// (circle / arc / ellipse) are skipped — they carry a Center snap hint and -/// "parallel to a curve" is meaningless. Used to acquire the Parallel-snap -/// reference. (#277) -fn nearest_segment_dir( +/// The nearest line / polyline segment under the cursor as (unit direction, +/// world point on it), within `aperture_px` in screen space, or None. +/// Tessellated curves (circle / arc / ellipse) are skipped — they carry a +/// Center snap hint and "parallel to a curve" is meaningless. Used to acquire +/// the Parallel-snap reference. (#277) +fn nearest_segment( cursor_world: Vec3, wires: &[WireModel], view_rot: Mat4, eye: glam::DVec3, bounds: Rectangle, aperture_px: f32, -) -> Option { +) -> Option<(Vec3, Vec3)> { let cs = world_to_screen(cursor_world.as_dvec3(), view_rot, eye, bounds); let mut best_d2 = aperture_px * aperture_px; - let mut best_dir: Option = None; + let mut best: Option<(Vec3, Vec3)> = None; for wire in wires { if wire .snap_pts @@ -1714,12 +1726,37 @@ fn nearest_segment_dir( let l = (dx * dx + dy * dy).sqrt(); if l > 1e-9 { best_d2 = d2; - best_dir = Some(Vec3::new((dx / l) as f32, (dy / l) as f32, 0.0)); + let dir = Vec3::new((dx / l) as f32, (dy / l) as f32, 0.0); + let np = nearest_on_segment(cursor_world.as_dvec3(), a, b).as_vec3(); + best = Some((dir, np)); } } } } - best_dir + best +} + +/// Perpendicular screen-space distance (px) from world point `q` to the infinite +/// line through `line_pt` along `line_dir`. Used to tell whether a hovered line +/// is the acquired parallel reference (same line) regardless of zoom. (#277) +fn screen_perp_dist( + q: Vec3, + line_pt: Vec3, + line_dir: Vec3, + view_rot: Mat4, + eye: glam::DVec3, + bounds: Rectangle, +) -> f32 { + let sq = world_to_screen(q.as_dvec3(), view_rot, eye, bounds); + let s0 = world_to_screen(line_pt.as_dvec3(), view_rot, eye, bounds); + let s1 = world_to_screen((line_pt + line_dir).as_dvec3(), view_rot, eye, bounds); + let ex = s1.x - s0.x; + let ey = s1.y - s0.y; + let l = (ex * ex + ey * ey).sqrt(); + if l < 1e-6 { + return dist2(sq, s0).sqrt(); + } + (ex * (sq.y - s0.y) - ey * (sq.x - s0.x)).abs() / l } /// Squared distance from point p to line segment [a, b] in screen space. diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 432acc4c0..629881714 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -186,6 +186,7 @@ pub fn selection_overlay<'a>( ucs_icons: Vec, ost_points: Vec, otrack_line: Option<(Point, Point)>, + parallel_ref_marker: Option, show_viewcube: bool, dividers: Vec, pane_move_rect: Option, @@ -203,6 +204,7 @@ pub fn selection_overlay<'a>( ucs_icons, ost_points, otrack_line, + parallel_ref_marker, show_viewcube, dividers, pane_move_rect, @@ -236,6 +238,9 @@ struct SelectionCanvas { /// cursor so the extension / tracking line the user snapped to is visible. /// (#219) otrack_line: Option<(Point, Point)>, + /// The acquired Parallel-snap reference point (screen), marked with a small + /// ∥ glyph so the user sees which line is the parallel reference. (#277) + parallel_ref_marker: Option, 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. @@ -1043,6 +1048,25 @@ impl canvas::Program for SelectionCanvas { frame.stroke(&canvas::Path::line(p0, p1), dash); } } + // The acquired Parallel-snap reference — a small ∥ glyph on its line so + // the user sees which line the parallel is measured from. (#277) + if let Some(m) = self.parallel_ref_marker { + let stroke = canvas::Stroke::default() + .with_color(track_color) + .with_width(1.5); + let r = 6.0_f32; + let off = 3.0_f32; + let b1 = canvas::Path::new(|b| { + b.move_to(Point::new(m.x - r - off, m.y + r)); + b.line_to(Point::new(m.x + r - off, m.y - r)); + }); + let b2 = canvas::Path::new(|b| { + b.move_to(Point::new(m.x - r + off, m.y + r)); + b.line_to(Point::new(m.x + r + off, m.y - r)); + }); + frame.stroke(&b1, stroke.clone()); + frame.stroke(&b2, stroke); + } // Small cross at each acquired tracking point. for ost in &self.ost_points { let tp = ost.screen;