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:
parent
8ad0c8918a
commit
e85cec8274
3 changed files with 184 additions and 1 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
152
src/snap.rs
152
src/snap.rs
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue