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,
|
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));
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
119
src/snap.rs
119
src/snap.rs
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue