forked from 1b-agent/opencadstudio-upstream
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:
1 parent
47f74a9b98
commit
e1d128c4dc
2 files changed
+71
-7
No files matched your search
@@ -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
@@ -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`.
|
||||
|
||||
Reference in new issue
Block a user