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, eye,
bounds, bounds,
) { ) {
if let (Some(base), Some(dir)) = if let (Some(base), Some((dir, _))) =
(self.last_point, self.snapper.parallel_ref) (self.last_point, self.snapper.parallel_ref)
{ {
self.otrack_active = Some((base, dir)); self.otrack_active = Some((base, dir));

View file

@ -386,6 +386,15 @@ impl OpenCADStudio {
} }
_ => None, _ => 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). // Model-space pane dividers (none in paper / single-pane layouts).
let dividers = if !is_paper { let dividers = if !is_paper {
@ -437,6 +446,7 @@ impl OpenCADStudio {
ucs_icons, ucs_icons,
ost_points, ost_points,
otrack_line, otrack_line,
parallel_ref_marker,
!is_paper && self.show_viewcube, !is_paper && self.show_viewcube,
dividers, dividers,
pane_move_rect, pane_move_rect,

View file

@ -127,14 +127,16 @@ pub struct Snapper {
/// segment is genuinely perpendicular to the target — without it, perp /// segment is genuinely perpendicular to the target — without it, perp
/// would just give the nearest point on the line. Set before each `snap`. /// would just give the nearest point on the line. Set before each `snap`.
pub from_point: Option<Vec3>, pub from_point: Option<Vec3>,
/// Parallel snap: the acquired reference line direction (unit, XY plane). /// Parallel snap: the acquired reference as (unit direction, a point on the
/// When the cursor's direction from the command's start point runs parallel /// line). When the cursor's direction from the command's start point runs
/// to this, the point locks onto that parallel line. Works with only the /// parallel to this, the point locks onto that parallel line; the point half
/// Parallel object snap on — independent of OTRACK (#277). /// marks the reference on screen. Works with only the Parallel object snap
pub parallel_ref: Option<Vec3>, /// on — independent of OTRACK (#277).
/// Dwell state for acquiring `parallel_ref`: the candidate line direction pub parallel_ref: Option<(Vec3, Vec3)>,
/// under the cursor and when it was first hovered. /// Dwell state for acquiring/removing `parallel_ref`: the candidate line
parallel_dwell: Option<(Vec3, Instant)>, /// 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 { impl Default for Snapper {
@ -606,11 +608,12 @@ impl Snapper {
} }
/// Parallel snap acquisition. When the Parallel object snap is on, hovering /// Parallel snap acquisition. When the Parallel object snap is on, hovering
/// a line (or polyline segment) for a short dwell acquires its direction as /// a line (or polyline segment) for a short dwell acquires it as the parallel
/// the parallel reference; the reference persists once the cursor moves off /// reference (its direction + a point on it, which marks it on screen); the
/// so the user can then draw parallel to it. Curves (circle/arc/ellipse) are /// reference persists once the cursor moves off so the user can then draw
/// ignored — "parallel to a curve" is undefined. Call on every viewport move. /// parallel to it. Dwelling on the SAME reference line again removes it
/// (#277) /// (toggle). Curves (circle/arc/ellipse) are ignored — "parallel to a curve"
/// is undefined. Call on every viewport move. (#277)
pub fn update_parallel( pub fn update_parallel(
&mut self, &mut self,
cursor_world: Vec3, cursor_world: Vec3,
@ -626,26 +629,35 @@ impl Snapper {
return; return;
} }
const PAR_DWELL_MS: u128 = 150; const PAR_DWELL_MS: u128 = 150;
let hovered = let parallel = |a: Vec3, b: Vec3| (a.x * b.x + a.y * b.y).abs() > 0.9998;
nearest_segment_dir(cursor_world, wires, view_rot, eye, bounds, self.osnap_radius_px); let Some((dir, pt)) =
match hovered { nearest_segment(cursor_world, wires, view_rot, eye, bounds, self.osnap_radius_px)
Some(d) => { else {
// A line and its reverse are the same alignment. // Off all lines: drop the in-progress candidate, keep the reference.
let same = self self.parallel_dwell = None;
.parallel_dwell return;
.map_or(false, |(pd, _)| (pd.x * d.x + pd.y * d.y).abs() > 0.9998); };
// 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 { match self.parallel_dwell {
Some((pd, since)) if same => { Some((cd, cp, since, fired)) if same_candidate => {
if now.duration_since(since).as_millis() >= PAR_DWELL_MS { if !fired && now.duration_since(since).as_millis() >= PAR_DWELL_MS {
self.parallel_ref = Some(pd); // 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));
} }
} }
_ => self.parallel_dwell = Some((d, now)), _ => self.parallel_dwell = Some((dir, pt, now, false)),
}
}
// 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,
} }
} }
@ -664,7 +676,7 @@ impl Snapper {
if !(self.snap_enabled && self.is_on(SnapType::Parallel)) { if !(self.snap_enabled && self.is_on(SnapType::Parallel)) {
return None; return None;
} }
let dir = self.parallel_ref?; let (dir, _) = self.parallel_ref?;
let base = base?; let base = base?;
let d = cursor_world - base; let d = cursor_world - base;
// Need a bit of travel from the base, and the cursor must be pulling // 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 dx * dx + dy * dy
} }
/// Direction (unit, XY) of the nearest line / polyline segment under the /// The nearest line / polyline segment under the cursor as (unit direction,
/// cursor, within `aperture_px` in screen space, or None. Tessellated curves /// world point on it), within `aperture_px` in screen space, or None.
/// (circle / arc / ellipse) are skipped — they carry a Center snap hint and /// Tessellated curves (circle / arc / ellipse) are skipped — they carry a
/// "parallel to a curve" is meaningless. Used to acquire the Parallel-snap /// Center snap hint and "parallel to a curve" is meaningless. Used to acquire
/// reference. (#277) /// the Parallel-snap reference. (#277)
fn nearest_segment_dir( fn nearest_segment(
cursor_world: Vec3, cursor_world: Vec3,
wires: &[WireModel], wires: &[WireModel],
view_rot: Mat4, view_rot: Mat4,
eye: glam::DVec3, eye: glam::DVec3,
bounds: Rectangle, bounds: Rectangle,
aperture_px: f32, aperture_px: f32,
) -> Option<Vec3> { ) -> Option<(Vec3, Vec3)> {
let cs = world_to_screen(cursor_world.as_dvec3(), view_rot, eye, bounds); let cs = world_to_screen(cursor_world.as_dvec3(), view_rot, eye, bounds);
let mut best_d2 = aperture_px * aperture_px; 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 { for wire in wires {
if wire if wire
.snap_pts .snap_pts
@ -1714,12 +1726,37 @@ fn nearest_segment_dir(
let l = (dx * dx + dy * dy).sqrt(); let l = (dx * dx + dy * dy).sqrt();
if l > 1e-9 { if l > 1e-9 {
best_d2 = d2; 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. /// 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>, ucs_icons: Vec<UcsIconParams>,
ost_points: Vec<OstTrackPoint>, ost_points: Vec<OstTrackPoint>,
otrack_line: Option<(Point, Point)>, otrack_line: Option<(Point, Point)>,
parallel_ref_marker: Option<Point>,
show_viewcube: bool, show_viewcube: bool,
dividers: Vec<iced::Rectangle>, dividers: Vec<iced::Rectangle>,
pane_move_rect: Option<iced::Rectangle>, pane_move_rect: Option<iced::Rectangle>,
@ -203,6 +204,7 @@ pub fn selection_overlay<'a>(
ucs_icons, ucs_icons,
ost_points, ost_points,
otrack_line, otrack_line,
parallel_ref_marker,
show_viewcube, show_viewcube,
dividers, dividers,
pane_move_rect, pane_move_rect,
@ -236,6 +238,9 @@ struct SelectionCanvas {
/// cursor so the extension / tracking line the user snapped to is visible. /// cursor so the extension / tracking line the user snapped to is visible.
/// (#219) /// (#219)
otrack_line: Option<(Point, Point)>, 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, show_viewcube: bool,
/// Divider bars (pixel rects, canvas-relative) between Model panes — drawn /// Divider bars (pixel rects, canvas-relative) between Model panes — drawn
/// as filled lines and used to suppress the crosshair over a divider. /// 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); 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. // Small cross at each acquired tracking point.
for ost in &self.ost_points { for ost in &self.ost_points {
let tp = ost.screen; let tp = ost.screen;