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:
parent
e85cec8274
commit
3bcb5a04c5
4 changed files with 114 additions and 43 deletions
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
121
src/snap.rs
121
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<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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue