feat(snap): parallel object snap (#277)

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 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-07-05 13:24:43 +03:00
commit e85cec8274
3 changed files with 184 additions and 1 deletions

View file

@ -906,6 +906,16 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
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<Message> {
};
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;

View file

@ -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)

View file

@ -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<Vec3>,
/// 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<Vec3>,
/// 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<Vec3>,
view_rot: glam::Mat4,
eye: glam::DVec3,
bounds: iced::Rectangle,
) -> Option<SnapResult> {
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<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<Vec3> = 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;