feat(snap): mark the parallel reference line + toggle it off on re-hover

Two refinements to Parallel snap. The acquired reference now keeps a
point on the line and draws a ∥ marker there, so the user sees which line
the parallel is measured from. And dwelling on that same reference line
again removes it (toggle), matching how OTRACK tracking points clear;
dwelling on a different line switches the reference. Same-line detection
uses parallel direction plus perpendicular screen distance, so it holds
across zoom.

Refs #277

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-07-05 14:50:33 +03:00
commit 3bcb5a04c5
4 changed files with 114 additions and 43 deletions

View file

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

View file

@ -386,6 +386,15 @@ impl OpenCADStudio {
}
_ => None,
};
// The acquired Parallel-snap reference, marked on its line (#277).
let parallel_ref_marker: Option<iced::Point> =
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,

View file

@ -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<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)>,
/// 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<Vec3> {
) -> 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<Vec3> = 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.

View file

@ -186,6 +186,7 @@ pub fn selection_overlay<'a>(
ucs_icons: Vec<UcsIconParams>,
ost_points: Vec<OstTrackPoint>,
otrack_line: Option<(Point, Point)>,
parallel_ref_marker: Option<Point>,
show_viewcube: bool,
dividers: Vec<iced::Rectangle>,
pane_move_rect: Option<iced::Rectangle>,
@ -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<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.
@ -1043,6 +1048,25 @@ impl canvas::Program<Message> 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;