From e85cec827469ff0e95697f27242e09b905331242 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 5 Jul 2026 13:24:43 +0300 Subject: [PATCH] feat(snap): parallel object snap (#277) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parallel snap did nothing — the type was in the osnap set, priority and marker, but there was no logic behind it. Implement it: hovering a line (or polyline segment) for a short dwell acquires its direction as a reference; then while drawing from the last point, when the cursor runs parallel to that reference the point locks onto the line through the last point parallel to it, showing the ∥ marker and the alignment guide. Curves (circle/arc/ellipse) are ignored — parallel to a curve is undefined. Works with only the Parallel object snap on, independent of OTRACK; the reference clears with the command's tracking state. Closes #277 Co-Authored-By: Claude Opus 4.8 --- src/app/update/viewport.rs | 31 ++++++++ src/app/view/mod.rs | 2 +- src/snap.rs | 152 +++++++++++++++++++++++++++++++++++++ 3 files changed, 184 insertions(+), 1 deletion(-) diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index e1327c429..3f7933f2e 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -906,6 +906,16 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { bounds, Instant::now(), ); + // Parallel snap: acquire the reference line under the + // cursor (independent of OTRACK). (#277) + self.snapper.update_parallel( + cursor_world.as_vec3(), + &all_wires[..], + view_rot, + eye, + bounds, + Instant::now(), + ); if self.tabs[i].snap_result.is_none() { let step = if self.polar_mode { Some(self.polar_increment_deg) @@ -929,6 +939,27 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { }; self.otrack_active = otrack_hit.map(|h| (h.base, h.dir)); + // Parallel snap: with nothing else snapped or tracked, lock + // the point onto the line through last_point parallel to the + // acquired reference, and drive the alignment guide off it. + // (#277) + if self.tabs[i].snap_result.is_none() && otrack_hit.is_none() { + if let Some(par) = self.snapper.parallel_snap( + cursor_world.as_vec3(), + self.last_point, + view_rot, + eye, + bounds, + ) { + if let (Some(base), Some(dir)) = + (self.last_point, self.snapper.parallel_ref) + { + self.otrack_active = Some((base, dir)); + } + self.tabs[i].snap_result = Some(par); + } + } + let effective = { let mut pt: glam::DVec3 = if let Some(h) = otrack_hit { // Tracking alignment wins over the free cursor; diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 84772aa0b..1c2b8e245 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -340,7 +340,7 @@ impl OpenCADStudio { // 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.tracking_active() { + if self.snapper.alignment_active() { Some( if let Some((vp_cam, full)) = tab.scene.viewport_edit_frame((vw, vh)) { (vp_cam.view_proj_rte(full), vp_cam.eye(), full) diff --git a/src/snap.rs b/src/snap.rs index 330181a32..0ecd5bfac 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -127,6 +127,14 @@ 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)>, } impl Default for Snapper { @@ -152,6 +160,8 @@ impl Default for Snapper { dwell_since: None, dwell_acquired: false, from_point: None, + parallel_ref: None, + parallel_dwell: None, } } } @@ -174,6 +184,13 @@ impl Snapper { self.otrack_enabled || (self.snap_enabled && self.is_on(SnapType::Extension)) } + /// Whether an alignment guide may be on screen — tracking (above) OR a + /// Parallel lock. Used to gate the guide-line projection; Parallel draws its + /// alignment guide without acquiring tracking points. (#277) + pub fn alignment_active(&self) -> bool { + self.tracking_active() || (self.snap_enabled && self.is_on(SnapType::Parallel)) + } + /// True when `p` coincides with one of the acquired temporary tracking /// points. Extension snaps a segment's line only from such acquired /// endpoints (#262), so extensions aren't live for every object in the @@ -581,11 +598,97 @@ impl Snapper { pub fn clear_tracking(&mut self) { self.tracking_points.clear(); self.tracking_dirs.clear(); + self.parallel_ref = None; + self.parallel_dwell = None; self.last_snap_world = None; self.dwell_since = None; self.dwell_acquired = false; } + /// 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) + pub fn update_parallel( + &mut self, + cursor_world: Vec3, + wires: &[WireModel], + view_rot: glam::Mat4, + eye: glam::DVec3, + bounds: iced::Rectangle, + now: Instant, + ) { + if !(self.snap_enabled && self.is_on(SnapType::Parallel)) { + self.parallel_ref = None; + self.parallel_dwell = None; + 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)), + } + } + // 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, + } + } + + /// Parallel lock: if the cursor's direction from `base` runs parallel to the + /// acquired reference, snap the point onto the line through `base` parallel + /// to the reference (locking when the cursor sits within the snap aperture + /// of that line). Independent of OTRACK. (#277) + pub fn parallel_snap( + &self, + cursor_world: Vec3, + base: Option, + view_rot: glam::Mat4, + eye: glam::DVec3, + bounds: iced::Rectangle, + ) -> Option { + if !(self.snap_enabled && self.is_on(SnapType::Parallel)) { + return None; + } + 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 + // roughly along the reference (not backward-only noise near the base). + if (d.x * d.x + d.y * d.y).sqrt() < self.grid_spacing * 0.01 { + return None; + } + let t = d.x * dir.x + d.y * dir.y; + let locked = base + dir * t; + let sl = world_to_screen(locked.as_dvec3(), view_rot, eye, bounds); + let sc = world_to_screen(cursor_world.as_dvec3(), view_rot, eye, bounds); + if dist2(sl, sc) > self.osnap_radius_px * self.osnap_radius_px { + return None; // cursor not near the parallel line — don't lock + } + Some(SnapResult { + world: locked.as_dvec3(), + screen: sl, + snap_type: SnapType::Parallel, + tangent_obj: None, + extension_base: None, + extension_base2: None, + }) + } + /// Only runs Tangent snap — used when a command needs object picks via tangent. pub fn snap_tangent_only( &self, @@ -613,6 +716,8 @@ impl Snapper { dwell_since: None, dwell_acquired: false, from_point: None, + parallel_ref: None, + parallel_dwell: None, }; // Tangent-only: Grid is disabled here, so the grid basis is irrelevant. tmp.snap( @@ -1570,6 +1675,53 @@ 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( + cursor_world: Vec3, + wires: &[WireModel], + view_rot: Mat4, + eye: glam::DVec3, + bounds: Rectangle, + aperture_px: f32, +) -> Option { + 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; + for wire in wires { + if wire + .snap_pts + .iter() + .any(|(_, h)| matches!(h, SnapHint::Center)) + { + continue; // circle / arc / ellipse — no meaningful parallel + } + for i in 0..wire.points.len().saturating_sub(1) { + let a = wp_f64(wire, i); + let b = wp_f64(wire, i + 1); + if !a.x.is_finite() || !b.x.is_finite() { + continue; + } + let sa = world_to_screen(a, view_rot, eye, bounds); + let sb = world_to_screen(b, view_rot, eye, bounds); + let d2 = dist2_to_segment(cs, sa, sb); + if d2 < best_d2 { + let dx = b.x - a.x; + let dy = b.y - a.y; + 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)); + } + } + } + } + best_dir +} + /// Squared distance from point p to line segment [a, b] in screen space. fn dist2_to_segment(p: Point, a: Point, b: Point) -> f32 { let dx = b.x - a.x;