diff --git a/src/app/update.rs b/src/app/update.rs index c4778ba72..29f701276 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -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); diff --git a/src/snap/mod.rs b/src/snap/mod.rs index c5ec03b11..f15081a63 100644 --- a/src/snap/mod.rs +++ b/src/snap/mod.rs @@ -101,6 +101,11 @@ pub struct Snapper { pub dwell_since: Option, /// 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, } 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 = 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`.