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 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-18 16:30:37 +03:00
1 parent 47f74a9b98
commit e1d128c4dc
2 files changed
+71 -7

No files matched your search

+4
View File
@@ -2507,6 +2507,8 @@ impl OpenCADStudio {
// `hit_test_wires` — snap against the set directly, no clone
// and no self-snap.
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 mut snapped = snap_hit.map(|s| s.world).unwrap_or(raw);
self.tabs[i].snap_result = snap_hit;
@@ -2679,6 +2681,7 @@ impl OpenCADStudio {
bounds,
)
} else {
self.snapper.from_point = self.last_point;
self.snapper
.snap(cursor_world, p, &all_wires[..], view_proj, bounds)
};
@@ -3292,6 +3295,7 @@ impl OpenCADStudio {
self.snapper
.snap_tangent_only(raw, p, &all_wires[..], vp_mat, bounds)
} else {
self.snapper.from_point = self.last_point;
self.snapper.snap(raw, p, &all_wires[..], vp_mat, bounds)
};
// snap.world is in paper-space (projected wire coords in MSPACE);
+67 -7
View File
@@ -101,6 +101,11 @@ pub struct Snapper {
pub dwell_since: Option<Instant>,
/// Whether the current dwell already acquired/removed a point (fire once).
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 {
@@ -123,6 +128,7 @@ impl Default for Snapper {
last_snap_world: None,
dwell_since: None,
dwell_acquired: false,
from_point: None,
}
}
}
@@ -417,6 +423,7 @@ impl Snapper {
last_snap_world: None,
dwell_since: None,
dwell_acquired: false,
from_point: None,
};
tmp.snap(cursor_world, cursor_screen, wires, view_proj, bounds)
}
@@ -433,8 +440,17 @@ impl Snapper {
if !self.snap_enabled {
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_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
// 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 screen = world_to_screen(world, view_proj, bounds);
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 = Some(SnapResult {
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) {
let q = self.from_point.unwrap_or(cursor_world);
for wire in wires {
if !wire_in_range(wire) {
continue;
}
for seg in wire.points.windows(2) {
if let Some(foot) =
perp_foot(cursor_world, Vec3::from(seg[0]), Vec3::from(seg[1]))
{
if let Some(foot) = perp_foot(q, Vec3::from(seg[0]), Vec3::from(seg[1])) {
try_pt(foot, SnapType::Perpendicular);
}
}
@@ -739,7 +769,9 @@ impl Snapper {
(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;
let screen_pt = world_to_screen(world_pt, view_proj, bounds);
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 ─────────────────────────────────────────────────────
/// Closest point on segment [p0, p1] to `query`.