fix(snap): priority-based osnap selection + true perpendicular
Object snaps were chosen purely by nearest screen distance, so the "continuous" snaps that sit on the geometry (Nearest, Perpendicular) were almost always closer than a discrete Endpoint/Midpoint/Center and silently suppressed every other enabled snap. Selection is now priority-then-distance: a higher-priority snap inside the aperture wins, distance only breaks ties within a priority. (#118) The aperture gate is `!(d2 < radius2)` rather than `d2 >= radius2` so a NaN distance from degenerate geometry is rejected instead of passing the gate and being chosen on rank alone (which fed a NaN point to the fill tessellator and panicked). Perpendicular now drops its foot from the command's drawing origin (`Snapper::from_point`, set before each snap) instead of the cursor, so the new segment is genuinely perpendicular to the target line rather than just the nearest point on it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
47f74a9b98
commit
e1d128c4dc
2 changed files with 71 additions and 7 deletions
|
|
@ -2507,6 +2507,8 @@ impl OpenCADStudio {
|
||||||
// `hit_test_wires` — snap against the set directly, no clone
|
// `hit_test_wires` — snap against the set directly, no clone
|
||||||
// and no self-snap.
|
// and no self-snap.
|
||||||
let all_wires = self.tabs[i].scene.hit_test_wires();
|
let all_wires = self.tabs[i].scene.hit_test_wires();
|
||||||
|
// Grip drag has no single rubber-band origin for a perp foot.
|
||||||
|
self.snapper.from_point = None;
|
||||||
let snap_hit = self.snapper.snap(raw, p, &all_wires[..], vp_mat, bounds);
|
let snap_hit = self.snapper.snap(raw, p, &all_wires[..], vp_mat, bounds);
|
||||||
let mut snapped = snap_hit.map(|s| s.world).unwrap_or(raw);
|
let mut snapped = snap_hit.map(|s| s.world).unwrap_or(raw);
|
||||||
self.tabs[i].snap_result = snap_hit;
|
self.tabs[i].snap_result = snap_hit;
|
||||||
|
|
@ -2679,6 +2681,7 @@ impl OpenCADStudio {
|
||||||
bounds,
|
bounds,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
self.snapper.from_point = self.last_point;
|
||||||
self.snapper
|
self.snapper
|
||||||
.snap(cursor_world, p, &all_wires[..], view_proj, bounds)
|
.snap(cursor_world, p, &all_wires[..], view_proj, bounds)
|
||||||
};
|
};
|
||||||
|
|
@ -3292,6 +3295,7 @@ impl OpenCADStudio {
|
||||||
self.snapper
|
self.snapper
|
||||||
.snap_tangent_only(raw, p, &all_wires[..], vp_mat, bounds)
|
.snap_tangent_only(raw, p, &all_wires[..], vp_mat, bounds)
|
||||||
} else {
|
} else {
|
||||||
|
self.snapper.from_point = self.last_point;
|
||||||
self.snapper.snap(raw, p, &all_wires[..], vp_mat, bounds)
|
self.snapper.snap(raw, p, &all_wires[..], vp_mat, bounds)
|
||||||
};
|
};
|
||||||
// snap.world is in paper-space (projected wire coords in MSPACE);
|
// snap.world is in paper-space (projected wire coords in MSPACE);
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,11 @@ pub struct Snapper {
|
||||||
pub dwell_since: Option<Instant>,
|
pub dwell_since: Option<Instant>,
|
||||||
/// Whether the current dwell already acquired/removed a point (fire once).
|
/// Whether the current dwell already acquired/removed a point (fire once).
|
||||||
pub dwell_acquired: bool,
|
pub dwell_acquired: bool,
|
||||||
|
/// The point the in-progress command is drawing *from* (the rubber-band
|
||||||
|
/// origin), if any. Perpendicular snap drops its foot from here so the new
|
||||||
|
/// 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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Snapper {
|
impl Default for Snapper {
|
||||||
|
|
@ -123,6 +128,7 @@ impl Default for Snapper {
|
||||||
last_snap_world: None,
|
last_snap_world: None,
|
||||||
dwell_since: None,
|
dwell_since: None,
|
||||||
dwell_acquired: false,
|
dwell_acquired: false,
|
||||||
|
from_point: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -417,6 +423,7 @@ impl Snapper {
|
||||||
last_snap_world: None,
|
last_snap_world: None,
|
||||||
dwell_since: None,
|
dwell_since: None,
|
||||||
dwell_acquired: false,
|
dwell_acquired: false,
|
||||||
|
from_point: None,
|
||||||
};
|
};
|
||||||
tmp.snap(cursor_world, cursor_screen, wires, view_proj, bounds)
|
tmp.snap(cursor_world, cursor_screen, wires, view_proj, bounds)
|
||||||
}
|
}
|
||||||
|
|
@ -433,8 +440,17 @@ impl Snapper {
|
||||||
if !self.snap_enabled {
|
if !self.snap_enabled {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
// Object-snap selection is priority-then-distance, NOT nearest-wins.
|
||||||
|
// "Continuous" snaps (Nearest, Perpendicular, …) sit on the geometry
|
||||||
|
// and are therefore almost always closer to the cursor than a discrete
|
||||||
|
// Endpoint/Midpoint/Center, so a pure-distance pick would let them mask
|
||||||
|
// every other enabled snap. Instead a higher-priority snap inside the
|
||||||
|
// snap circle wins even when a lower-priority one is closer; distance
|
||||||
|
// only breaks ties within the same priority. See #118.
|
||||||
|
let radius2 = self.osnap_radius_px * self.osnap_radius_px;
|
||||||
let mut best: Option<SnapResult> = None;
|
let mut best: Option<SnapResult> = None;
|
||||||
let mut best_d2 = self.osnap_radius_px * self.osnap_radius_px;
|
let mut best_rank = u8::MAX;
|
||||||
|
let mut best_d2 = f32::MAX;
|
||||||
|
|
||||||
// World-space snap radius — derived from the view scale so wires whose
|
// World-space snap radius — derived from the view scale so wires whose
|
||||||
// entire extent is clearly outside the snap circle can be skipped cheaply
|
// entire extent is clearly outside the snap circle can be skipped cheaply
|
||||||
|
|
@ -465,7 +481,16 @@ impl Snapper {
|
||||||
let mut try_pt = |world: Vec3, snap_type: SnapType| {
|
let mut try_pt = |world: Vec3, snap_type: SnapType| {
|
||||||
let screen = world_to_screen(world, view_proj, bounds);
|
let screen = world_to_screen(world, view_proj, bounds);
|
||||||
let d2 = dist2(screen, cursor_screen);
|
let d2 = dist2(screen, cursor_screen);
|
||||||
if d2 < best_d2 {
|
// `!(d2 < radius2)` (not `d2 >= radius2`) so a NaN distance from
|
||||||
|
// degenerate geometry is rejected: with priority selection a NaN
|
||||||
|
// would otherwise pass the gate and be chosen on rank alone,
|
||||||
|
// feeding a NaN snap point to the renderer. (#118)
|
||||||
|
if !(d2 < radius2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let rank = snap_priority(snap_type);
|
||||||
|
if rank < best_rank || (rank == best_rank && d2 < best_d2) {
|
||||||
|
best_rank = rank;
|
||||||
best_d2 = d2;
|
best_d2 = d2;
|
||||||
best = Some(SnapResult {
|
best = Some(SnapResult {
|
||||||
world,
|
world,
|
||||||
|
|
@ -554,16 +579,21 @@ impl Snapper {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Perpendicular — foot of perpendicular from cursor (unclamped) ──
|
// ── Perpendicular — foot of perpendicular from the drawing base ──
|
||||||
|
// Drop the foot from the point the command is drawing *from* (so the
|
||||||
|
// new segment is truly perpendicular to the target). Only when there
|
||||||
|
// is no base point — e.g. picking the very first point — does it fall
|
||||||
|
// back to the cursor (a plain nearest-on-line). The candidate is gated
|
||||||
|
// on its screen distance to the cursor like every other snap, so it
|
||||||
|
// offers when the cursor is near the perpendicular foot. (#118)
|
||||||
if self.is_on(SnapType::Perpendicular) {
|
if self.is_on(SnapType::Perpendicular) {
|
||||||
|
let q = self.from_point.unwrap_or(cursor_world);
|
||||||
for wire in wires {
|
for wire in wires {
|
||||||
if !wire_in_range(wire) {
|
if !wire_in_range(wire) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for seg in wire.points.windows(2) {
|
for seg in wire.points.windows(2) {
|
||||||
if let Some(foot) =
|
if let Some(foot) = perp_foot(q, Vec3::from(seg[0]), Vec3::from(seg[1])) {
|
||||||
perp_foot(cursor_world, Vec3::from(seg[0]), Vec3::from(seg[1]))
|
|
||||||
{
|
|
||||||
try_pt(foot, SnapType::Perpendicular);
|
try_pt(foot, SnapType::Perpendicular);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -739,7 +769,9 @@ impl Snapper {
|
||||||
(w, edge_d * edge_d)
|
(w, edge_d * edge_d)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if d2 < best_d2 {
|
let rank = snap_priority(SnapType::Tangent);
|
||||||
|
if d2 < radius2 && (rank < best_rank || (rank == best_rank && d2 < best_d2)) {
|
||||||
|
best_rank = rank;
|
||||||
best_d2 = d2;
|
best_d2 = d2;
|
||||||
let screen_pt = world_to_screen(world_pt, view_proj, bounds);
|
let screen_pt = world_to_screen(world_pt, view_proj, bounds);
|
||||||
let tangent_obj = match tg {
|
let tangent_obj = match tg {
|
||||||
|
|
@ -767,6 +799,34 @@ impl Snapper {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Object-snap priority ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Selection priority for an object snap — lower wins. Discrete snaps that
|
||||||
|
/// land on a specific feature (Endpoint, Midpoint, Center, …) outrank the
|
||||||
|
/// "continuous" snaps (Perpendicular, Tangent, Nearest) that can sit anywhere
|
||||||
|
/// along the geometry, so enabling a continuous snap can't suppress the
|
||||||
|
/// discrete ones the user also turned on. Mirrors the usual CAD running-osnap
|
||||||
|
/// precedence. See #118.
|
||||||
|
fn snap_priority(t: SnapType) -> u8 {
|
||||||
|
match t {
|
||||||
|
SnapType::Endpoint => 0,
|
||||||
|
SnapType::Intersection => 1,
|
||||||
|
SnapType::ApparentIntersection => 2,
|
||||||
|
SnapType::Midpoint => 3,
|
||||||
|
SnapType::Center => 4,
|
||||||
|
SnapType::Node => 5,
|
||||||
|
SnapType::Quadrant => 6,
|
||||||
|
SnapType::Insertion => 7,
|
||||||
|
SnapType::ObjectPick => 8,
|
||||||
|
SnapType::Perpendicular => 9,
|
||||||
|
SnapType::Tangent => 10,
|
||||||
|
SnapType::Parallel => 11,
|
||||||
|
SnapType::Extension => 12,
|
||||||
|
SnapType::Nearest => 13,
|
||||||
|
SnapType::Grid => 14,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Geometric helpers ─────────────────────────────────────────────────────
|
// ── Geometric helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Closest point on segment [p0, p1] to `query`.
|
/// Closest point on segment [p0, p1] to `query`.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue