perf: expand spatial interaction indexing

Share subprimitive broad-phase queries across snap, hover, selection, hatch, and mesh paths while preserving exact hit tests.\n\nRefs #433
This commit is contained in:
Hakan Seven 2026-07-24 22:00:40 +03:00
commit 90714d34b2
5 changed files with 2629 additions and 680 deletions

View file

@ -776,6 +776,7 @@ impl OpenCADStudio {
all_wires, all_wires,
raw, raw,
view_rot, view_rot,
eye,
bounds, bounds,
self.snapper.osnap_radius_px, self.snapper.osnap_radius_px,
); );
@ -941,6 +942,7 @@ impl OpenCADStudio {
all_wires, all_wires,
snap_cursor, snap_cursor,
view_rot, view_rot,
eye,
bounds, bounds,
self.snapper.osnap_radius_px, self.snapper.osnap_radius_px,
); );
@ -1547,6 +1549,7 @@ impl OpenCADStudio {
all_wires, all_wires,
raw, raw,
view_rot, view_rot,
eye,
bounds, bounds,
self.snapper.osnap_radius_px, self.snapper.osnap_radius_px,
); );
@ -2034,6 +2037,7 @@ impl OpenCADStudio {
all_wires, all_wires,
snap_cursor, snap_cursor,
view_rot, view_rot,
eye,
bounds, bounds,
self.snapper.osnap_radius_px, self.snapper.osnap_radius_px,
); );
@ -2206,10 +2210,11 @@ impl OpenCADStudio {
.unwrap_or(false) .unwrap_or(false)
{ {
let (view_rot2, eye2, all_wires2) = self.pick_view(i, &edit_cam, bounds); let (view_rot2, eye2, all_wires2) = self.pick_view(i, &edit_cam, bounds);
let click_candidates = self.tabs[i].scene.interaction_candidates_near( let click_candidates = self.tabs[i].scene.interaction_pick_candidates_near(
all_wires2, all_wires2,
world_pt, world_pt,
view_rot2, view_rot2,
eye2,
bounds, bounds,
scene::pick::hit_test::CLICK_THRESHOLD_PX * 2.0, scene::pick::hit_test::CLICK_THRESHOLD_PX * 2.0,
); );
@ -2445,9 +2450,14 @@ impl OpenCADStudio {
aabb aabb
}, },
); );
let area_candidates = self.tabs[i] let area_candidates = self.tabs[i].scene.interaction_candidates_in_aabb(
.scene all_wires,
.interaction_candidates_in_aabb(all_wires, world_aabb, view_rot); world_aabb,
[a.x.min(p.x), a.y.min(p.y), a.x.max(p.x), a.y.max(p.y)],
view_rot,
eye,
bounds,
);
let candidate_handles = self.tabs[i] let candidate_handles = self.tabs[i]
.scene .scene
.interaction_candidate_handles(&area_candidates); .interaction_candidate_handles(&area_candidates);
@ -2475,6 +2485,16 @@ impl OpenCADStudio {
bounds, bounds,
candidate_handles.as_ref(), candidate_handles.as_ref(),
)); ));
handles.extend(scene::pick::hit_test::box_hit_insert_hatch(
a,
p,
crossing,
self.tabs[i].scene.insert_hatches_for_click().as_ref(),
view_rot,
eye,
bounds,
candidate_handles.as_ref(),
));
handles.extend(self.tabs[i].scene.mesh_box_hit( handles.extend(self.tabs[i].scene.mesh_box_hit(
a, a,
p, p,
@ -2548,9 +2568,28 @@ impl OpenCADStudio {
aabb aabb
}, },
); );
let area_candidates = self.tabs[i] let area_candidates = self.tabs[i].scene.interaction_candidates_in_aabb(
.scene all_wires,
.interaction_candidates_in_aabb(all_wires, world_aabb, view_rot); world_aabb,
poly_pts.iter().fold(
[
f32::INFINITY,
f32::INFINITY,
f32::NEG_INFINITY,
f32::NEG_INFINITY,
],
|mut rect, point| {
rect[0] = rect[0].min(point.x);
rect[1] = rect[1].min(point.y);
rect[2] = rect[2].max(point.x);
rect[3] = rect[3].max(point.y);
rect
},
),
view_rot,
eye,
bounds,
);
let candidate_handles = self.tabs[i] let candidate_handles = self.tabs[i]
.scene .scene
.interaction_candidate_handles(&area_candidates); .interaction_candidate_handles(&area_candidates);
@ -2576,6 +2615,15 @@ impl OpenCADStudio {
bounds, bounds,
candidate_handles.as_ref(), candidate_handles.as_ref(),
)); ));
handles.extend(scene::pick::hit_test::poly_hit_insert_hatch(
&poly_pts,
crossing,
self.tabs[i].scene.insert_hatches_for_click().as_ref(),
view_rot,
eye,
bounds,
candidate_handles.as_ref(),
));
handles.extend(self.tabs[i].scene.mesh_poly_hit( handles.extend(self.tabs[i].scene.mesh_poly_hit(
&poly_pts, &poly_pts,
crossing, crossing,
@ -2630,10 +2678,11 @@ impl OpenCADStudio {
if box_anchor.is_none() { if box_anchor.is_none() {
let (view_rot, eye, all_wires) = self.pick_view(i, &edit_cam, bounds); let (view_rot, eye, all_wires) = self.pick_view(i, &edit_cam, bounds);
let click_world = self.cursor_model_point(i, &edit_cam, p, bounds); let click_world = self.cursor_model_point(i, &edit_cam, p, bounds);
let click_candidates = self.tabs[i].scene.interaction_candidates_near( let click_candidates = self.tabs[i].scene.interaction_pick_candidates_near(
all_wires, all_wires,
click_world, click_world,
view_rot, view_rot,
eye,
bounds, bounds,
scene::pick::hit_test::CLICK_THRESHOLD_PX * 2.0, scene::pick::hit_test::CLICK_THRESHOLD_PX * 2.0,
); );
@ -2695,7 +2744,7 @@ impl OpenCADStudio {
// parent Insert (AutoCAD behaviour). // parent Insert (AutoCAD behaviour).
scene::pick::hit_test::click_hit_insert_hatch( scene::pick::hit_test::click_hit_insert_hatch(
p, p,
&self.tabs[i].scene.insert_hatches_for_click()[..], self.tabs[i].scene.insert_hatches_for_click().as_ref(),
view_rot, view_rot,
eye, eye,
bounds, bounds,
@ -2801,9 +2850,14 @@ impl OpenCADStudio {
aabb aabb
}, },
); );
let area_candidates = self.tabs[i] let area_candidates = self.tabs[i].scene.interaction_candidates_in_aabb(
.scene all_wires,
.interaction_candidates_in_aabb(all_wires, world_aabb, view_rot); world_aabb,
[a.x.min(p.x), a.y.min(p.y), a.x.max(p.x), a.y.max(p.y)],
view_rot,
eye,
bounds,
);
let candidate_handles = self.tabs[i] let candidate_handles = self.tabs[i]
.scene .scene
.interaction_candidate_handles(&area_candidates); .interaction_candidate_handles(&area_candidates);
@ -2831,6 +2885,16 @@ impl OpenCADStudio {
bounds, bounds,
candidate_handles.as_ref(), candidate_handles.as_ref(),
)); ));
handles.extend(scene::pick::hit_test::box_hit_insert_hatch(
a,
p,
crossing,
self.tabs[i].scene.insert_hatches_for_click().as_ref(),
view_rot,
eye,
bounds,
candidate_handles.as_ref(),
));
handles.extend(self.tabs[i].scene.mesh_box_hit( handles.extend(self.tabs[i].scene.mesh_box_hit(
a, a,
p, p,
@ -2937,10 +3001,11 @@ impl OpenCADStudio {
}; };
let all_wires = self.tabs[i].scene.hit_test_wires(); let all_wires = self.tabs[i].scene.hit_test_wires();
let click_world = self.cursor_model_point(i, &edit_cam, p, bounds); let click_world = self.cursor_model_point(i, &edit_cam, p, bounds);
let click_candidates = self.tabs[i].scene.interaction_candidates_near( let click_candidates = self.tabs[i].scene.interaction_pick_candidates_near(
all_wires, all_wires,
click_world, click_world,
view_rot, view_rot,
eye,
bounds, bounds,
scene::pick::hit_test::CLICK_THRESHOLD_PX * 2.0, scene::pick::hit_test::CLICK_THRESHOLD_PX * 2.0,
); );
@ -3058,10 +3123,11 @@ impl OpenCADStudio {
}; };
let all_wires = self.tabs[i].scene.hit_test_wires(); let all_wires = self.tabs[i].scene.hit_test_wires();
let click_world = self.cursor_model_point(i, &edit_cam, p, bounds); let click_world = self.cursor_model_point(i, &edit_cam, p, bounds);
let click_candidates = self.tabs[i].scene.interaction_candidates_near( let click_candidates = self.tabs[i].scene.interaction_pick_candidates_near(
all_wires, all_wires,
click_world, click_world,
view_rot, view_rot,
eye,
bounds, bounds,
scene::pick::hit_test::CLICK_THRESHOLD_PX * 2.0, scene::pick::hit_test::CLICK_THRESHOLD_PX * 2.0,
); );
@ -3384,10 +3450,11 @@ impl OpenCADStudio {
.map(|(cam, _)| cam); .map(|(cam, _)| cam);
let (view_rot, eye, all_wires) = self.pick_view(i, &edit_cam, bounds); let (view_rot, eye, all_wires) = self.pick_view(i, &edit_cam, bounds);
let hover_world = self.cursor_model_point(i, &edit_cam, p, bounds); let hover_world = self.cursor_model_point(i, &edit_cam, p, bounds);
let hover_candidates = self.tabs[i].scene.interaction_candidates_near( let hover_candidates = self.tabs[i].scene.interaction_pick_candidates_near(
all_wires, all_wires,
hover_world, hover_world,
view_rot, view_rot,
eye,
bounds, bounds,
scene::pick::hit_test::CLICK_THRESHOLD_PX * 2.0, scene::pick::hit_test::CLICK_THRESHOLD_PX * 2.0,
); );
@ -3421,7 +3488,7 @@ impl OpenCADStudio {
.or_else(|| { .or_else(|| {
scene::pick::hit_test::click_hit_insert_hatch( scene::pick::hit_test::click_hit_insert_hatch(
p, p,
&self.tabs[i].scene.insert_hatches_for_click()[..], self.tabs[i].scene.insert_hatches_for_click().as_ref(),
view_rot, view_rot,
eye, eye,
bounds, bounds,

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -90,7 +90,7 @@ pub struct OtrackHit {
// ── Snapper ─────────────────────────────────────────────────────────────── // ── Snapper ───────────────────────────────────────────────────────────────
use rustc_hash::FxHashSet as HashSet; use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
pub struct Snapper { pub struct Snapper {
/// Global snap on/off toggle. When false, all snapping is bypassed /// Global snap on/off toggle. When false, all snapping is bypassed
@ -926,7 +926,7 @@ impl Snapper {
} }
} }
} }
let allow_pairwise = wires.segments().is_some() || in_range_pts <= MAX_PAIRWISE_POINTS; let allow_unindexed_pairwise = in_range_pts <= MAX_PAIRWISE_POINTS;
let local_segments = indexed_segments(wires); let local_segments = indexed_segments(wires);
let mut try_pt = |world: glam::DVec3, snap_type: SnapType| { let mut try_pt = |world: glam::DVec3, snap_type: SnapType| {
@ -959,56 +959,87 @@ impl Snapper {
}; };
// ── Pre-baked snap points (Center, Node, Quadrant, Insertion) ────── // ── Pre-baked snap points (Center, Node, Quadrant, Insertion) ──────
for wire in wires.iter() { let mut try_snap_hint = |world: DVec3, hint: SnapHint| {
for &(world, hint) in &wire.snap_pts { let snap_type = match hint {
let snap_type = match hint { SnapHint::Center => SnapType::Center,
SnapHint::Center => SnapType::Center, SnapHint::Node => SnapType::Node,
SnapHint::Node => SnapType::Node, SnapHint::Quadrant => SnapType::Quadrant,
SnapHint::Quadrant => SnapType::Quadrant, SnapHint::Insertion => SnapType::Insertion,
SnapHint::Insertion => SnapType::Insertion, SnapHint::Midpoint => SnapType::Midpoint,
SnapHint::Midpoint => SnapType::Midpoint, };
if self.is_on(snap_type) {
try_pt(world, snap_type);
}
};
if let Some(points) = wires.snap_points() {
for point_ref in points {
let Some(&(world, hint)) = wires
.source_wire(point_ref.wire)
.and_then(|wire| wire.snap_pts.get(point_ref.index as usize))
else {
continue;
}; };
if self.is_on(snap_type) { try_snap_hint(world, hint);
try_pt(world, snap_type); }
} else {
for wire in wires.iter() {
for &(world, hint) in &wire.snap_pts {
try_snap_hint(world, hint);
} }
} }
} }
drop(try_snap_hint);
// ── Endpoint ─────────────────────────────────────────────────────── // ── Endpoint ───────────────────────────────────────────────────────
if self.is_on(SnapType::Endpoint) { if self.is_on(SnapType::Endpoint) {
for wire in wires.iter() { if let Some(vertices) = wires.key_vertices() {
if !wire_in_range(wire) { for vertex_ref in vertices {
continue; let Some(&point) = wires
.source_wire(vertex_ref.wire)
.and_then(|wire| wire.key_vertices.get(vertex_ref.index as usize))
else {
continue;
};
try_pt(DVec3::from_array(point), SnapType::Endpoint);
} }
if !wire.key_vertices.is_empty() { // Tessellated open curves have no key-vertex set. Their only
// Use explicit vertices (Line, LwPolyline): every vertex is an endpoint. // endpoints are first/last, so testing candidate wires remains
for &p in &wire.key_vertices { // O(local wires), independent of tessellation density.
try_pt(glam::DVec3::new(p[0], p[1], p[2]), SnapType::Endpoint); for wire in wires.iter().filter(|wire| wire.key_vertices.is_empty()) {
}
} else {
// Tessellated curves (Circle, Arc, Ellipse): only an OPEN
// one (an arc) has real endpoints. A full circle / ellipse is
// closed — its tessellation's first/last is a seam point, not
// an endpoint — and is the only tessellated curve that carries
// Quadrant snap hints (arcs never do), so emit no Endpoint for
// those (#275).
let closed = wire let closed = wire
.snap_pts .snap_pts
.iter() .iter()
.any(|(_, h)| matches!(h, SnapHint::Quadrant)); .any(|(_, hint)| matches!(hint, SnapHint::Quadrant));
if !closed { if closed {
if let Some(&p) = wire.points.first() { continue;
try_pt( }
glam::DVec3::new(p[0] as f64, p[1] as f64, p[2] as f64), if !wire.points.is_empty() {
SnapType::Endpoint, try_pt(wp_f64(wire, 0), SnapType::Endpoint);
); }
if wire.points.len() > 1 {
try_pt(wp_f64(wire, wire.points.len() - 1), SnapType::Endpoint);
}
}
} else {
for wire in wires.iter() {
if !wire_in_range(wire) {
continue;
}
if !wire.key_vertices.is_empty() {
for &point in &wire.key_vertices {
try_pt(DVec3::from_array(point), SnapType::Endpoint);
} }
if wire.points.len() > 1 { } else {
if let Some(&p) = wire.points.last() { let closed = wire
try_pt( .snap_pts
glam::DVec3::new(p[0] as f64, p[1] as f64, p[2] as f64), .iter()
SnapType::Endpoint, .any(|(_, hint)| matches!(hint, SnapHint::Quadrant));
); if !closed {
if !wire.points.is_empty() {
try_pt(wp_f64(wire, 0), SnapType::Endpoint);
}
if wire.points.len() > 1 {
try_pt(wp_f64(wire, wire.points.len() - 1), SnapType::Endpoint);
} }
} }
} }
@ -1017,20 +1048,32 @@ impl Snapper {
} }
// ── Midpoint ─────────────────────────────────────────────────────── // ── Midpoint ───────────────────────────────────────────────────────
// Only explicit vertex sets (Line, LwPolyline) contribute per-segment // Tessellated curves contribute their pre-baked Midpoint above.
// midpoints. Tessellated curves (Circle, Arc, Ellipse, Spline) emit a
// single `SnapHint::Midpoint` snap_pt where one exists — iterating
// every chord here would otherwise turn a circle's tessellation into
// a haze of false midpoint hits. See #34.
if self.is_on(SnapType::Midpoint) { if self.is_on(SnapType::Midpoint) {
for wire in wires.iter() { if let Some(segments) = wires.key_segments() {
if !wire_in_range(wire) { for segment_ref in segments {
continue; let Some(wire) = wires.source_wire(segment_ref.wire) else {
continue;
};
let start = segment_ref.start as usize;
let Some((&a, &b)) = wire
.key_vertices
.get(start)
.zip(wire.key_vertices.get(start + 1))
else {
continue;
};
let a = DVec3::from_array(a);
let b = DVec3::from_array(b);
if a.distance_squared(b) > 1e-12 {
try_pt((a + b) * 0.5, SnapType::Midpoint);
}
} }
if !wire.key_vertices.is_empty() { } else {
for seg in wire.key_vertices.windows(2) { for wire in wires.iter().filter(|wire| wire_in_range(wire)) {
let a = glam::DVec3::new(seg[0][0], seg[0][1], seg[0][2]); for segment in wire.key_vertices.windows(2) {
let b = glam::DVec3::new(seg[1][0], seg[1][1], seg[1][2]); let a = DVec3::from_array(segment[0]);
let b = DVec3::from_array(segment[1]);
if a.distance_squared(b) > 1e-12 { if a.distance_squared(b) > 1e-12 {
try_pt((a + b) * 0.5, SnapType::Midpoint); try_pt((a + b) * 0.5, SnapType::Midpoint);
} }
@ -1095,30 +1138,35 @@ impl Snapper {
} }
// ── Intersection — segment-segment intersections (pairwise, gated) ── // ── Intersection — segment-segment intersections (pairwise, gated) ──
if self.is_on(SnapType::Intersection) && allow_pairwise { if self.is_on(SnapType::Intersection)
&& (local_segments.is_some() || allow_unindexed_pairwise)
{
if let Some(segments) = &local_segments { if let Some(segments) = &local_segments {
// Cursor-local segment bounds make this set small. In a // Exact cursor-local sweep: never discard a valid intersection
// pathological dense crossing retain closest segments instead // in dense geometry. Min-X ordering plus Y overlap avoids
// of disabling Intersection for the whole aperture. // comparing segment pairs whose bounds cannot meet.
const MAX_LOCAL_INTERSECTION_SEGMENTS: usize = 512;
let mut local: Vec<&IndexedSegment> = segments.iter().collect(); let mut local: Vec<&IndexedSegment> = segments.iter().collect();
local.sort_by(|a, b| {
segment_aabb_distance2(a, cursor_world)
.total_cmp(&segment_aabb_distance2(b, cursor_world))
});
local.truncate(MAX_LOCAL_INTERSECTION_SEGMENTS);
local.sort_by(|a, b| a.min_x().total_cmp(&b.min_x())); local.sort_by(|a, b| a.min_x().total_cmp(&b.min_x()));
for i in 0..local.len() { if indexed_sweep_within_budget(&local) {
let a = local[i]; 'intersection_sweep: for i in 0..local.len() {
for &b in local.iter().skip(i + 1) { let a = local[i];
if b.min_x() > a.max_x() { for &b in local.iter().skip(i + 1) {
break; if b.min_x() > a.max_x() {
} break;
if a.wire == b.wire || a.max_y() < b.min_y() || a.min_y() > b.max_y() { }
continue; if a.wire == b.wire || a.max_y() < b.min_y() || a.min_y() > b.max_y() {
} continue;
if let Some(pt) = seg_intersect_3d(a.a, a.b, b.a, b.b) { }
try_pt(pt, SnapType::Intersection); if let Some(pt) = seg_intersect_3d(a.a, a.b, b.a, b.b) {
let exact_cursor = dist2(
world_to_screen(pt, view_rot, eye, bounds),
cursor_screen,
) <= f32::EPSILON;
try_pt(pt, SnapType::Intersection);
if exact_cursor {
break 'intersection_sweep;
}
}
} }
} }
} }
@ -1237,39 +1285,57 @@ impl Snapper {
// ── Apparent Intersection — screen-space intersections (pairwise, gated) ── // ── Apparent Intersection — screen-space intersections (pairwise, gated) ──
// L: pre-project each in-range wire's points to screen once, not once per segment pair. // L: pre-project each in-range wire's points to screen once, not once per segment pair.
if self.is_on(SnapType::ApparentIntersection) && allow_pairwise { if self.is_on(SnapType::ApparentIntersection)
&& (local_segments.is_some() || allow_unindexed_pairwise)
{
if let Some(segments) = &local_segments { if let Some(segments) = &local_segments {
const MAX_LOCAL_APPARENT_SEGMENTS: usize = 512; // Project once, then run an exact screen-AABB sweep. Unlike the
let mut local: Vec<&IndexedSegment> = segments.iter().collect(); // previous 512-item cap this cannot drop the true apparent
local.sort_by(|a, b| { // intersection in a dense aperture.
segment_aabb_distance2(a, cursor_world) let mut projected: Vec<(&IndexedSegment, Point, Point, [f32; 4])> = segments
.total_cmp(&segment_aabb_distance2(b, cursor_world))
});
local.truncate(MAX_LOCAL_APPARENT_SEGMENTS);
let projected: Vec<(Point, Point)> = local
.iter() .iter()
.map(|seg| { .map(|seg| {
let a = world_to_screen(seg.a, view_rot, eye, bounds);
let b = world_to_screen(seg.b, view_rot, eye, bounds);
( (
world_to_screen(seg.a, view_rot, eye, bounds), seg,
world_to_screen(seg.b, view_rot, eye, bounds), a,
b,
[a.x.min(b.x), a.y.min(b.y), a.x.max(b.x), a.y.max(b.y)],
) )
}) })
.collect(); .collect();
for i in 0..local.len() { projected.sort_by(|a, b| a.3[0].total_cmp(&b.3[0]));
for j in (i + 1)..local.len() { if projected_sweep_within_budget(&projected) {
if local[i].wire == local[j].wire { 'apparent_sweep: for i in 0..projected.len() {
continue; let (segment_a, screen_a0, screen_a1, aabb_a) = projected[i];
} for &(segment_b, screen_b0, screen_b1, aabb_b) in
if let Some((ta, _)) = seg_intersect_2d( projected.iter().skip(i + 1)
projected[i].0, {
projected[i].1, if aabb_b[0] > aabb_a[2] {
projected[j].0, break;
projected[j].1, }
) { if segment_a.wire == segment_b.wire
try_pt( || aabb_a[3] < aabb_b[1]
local[i].a + ta as f64 * (local[i].b - local[i].a), || aabb_a[1] > aabb_b[3]
SnapType::ApparentIntersection, {
); continue;
}
if let Some((ta, _)) =
seg_intersect_2d(screen_a0, screen_a1, screen_b0, screen_b1)
{
let apparent_screen = Point::new(
screen_a0.x + ta * (screen_a1.x - screen_a0.x),
screen_a0.y + ta * (screen_a1.y - screen_a0.y),
);
try_pt(
segment_a.a + ta as f64 * (segment_a.b - segment_a.a),
SnapType::ApparentIntersection,
);
if dist2(apparent_screen, cursor_screen) <= f32::EPSILON {
break 'apparent_sweep;
}
}
} }
} }
} }
@ -1450,27 +1516,15 @@ impl Snapper {
// the cursor is to the curve. Runs here, after `try_pt`'s borrow ends, // the cursor is to the curve. Runs here, after `try_pt`'s borrow ends,
// so it can update the candidate state directly. (#152) // so it can update the candidate state directly. (#152)
if self.is_on(SnapType::Center) { if self.is_on(SnapType::Center) {
for wire in wires.iter() { let mut offer = |wire: &WireModel, curve_d2: f32| {
if !wire_in_range(wire) {
continue;
}
// Only tessellated curves carry a pre-baked Center hint; reuse
// it as the snap target. Lines / polylines have none → skip.
let Some(center) = wire let Some(center) = wire
.snap_pts .snap_pts
.iter() .iter()
.find(|(_, h)| matches!(h, SnapHint::Center)) .find(|(_, hint)| matches!(hint, SnapHint::Center))
.map(|&(c, _)| c) .map(|&(c, _)| c)
else { else {
continue; return;
}; };
// Nearest screen distance from the cursor to the curve itself.
let mut curve_d2 = f32::INFINITY;
for i in 0..wire.points.len().saturating_sub(1) {
let p = nearest_on_segment(cursor_world, wp_f64(wire, i), wp_f64(wire, i + 1));
let sp = world_to_screen(p, view_rot, eye, bounds);
curve_d2 = curve_d2.min(dist2(sp, cursor_screen));
}
let screen = world_to_screen(center, view_rot, eye, bounds); let screen = world_to_screen(center, view_rot, eye, bounds);
// Rim-hover Center: the cursor is on the CURVE, not near the // Rim-hover Center: the cursor is on the CURVE, not near the
// centre, so its distance metric (curve_d2 ≈ 0 anywhere on the // centre, so its distance metric (curve_d2 ≈ 0 anywhere on the
@ -1496,6 +1550,51 @@ impl Snapper {
extension_base2: None, extension_base2: None,
}); });
} }
};
if let Some(segments) = &local_segments {
let mut distances: HashMap<u32, f32> = HashMap::default();
for segment in segments {
let Some(wire) = wires.source_wire(segment.wire) else {
continue;
};
if !wire
.snap_pts
.iter()
.any(|(_, hint)| matches!(hint, SnapHint::Center))
{
continue;
}
let nearest = nearest_on_segment(cursor_world, segment.a, segment.b);
let distance = dist2(
world_to_screen(nearest, view_rot, eye, bounds),
cursor_screen,
);
distances
.entry(segment.wire)
.and_modify(|best| *best = best.min(distance))
.or_insert(distance);
}
for (wire_idx, distance) in distances {
if let Some(wire) = wires.source_wire(wire_idx) {
offer(wire, distance);
}
}
} else {
for wire in wires.iter().filter(|wire| wire_in_range(wire)) {
let mut curve_d2 = f32::INFINITY;
for index in 0..wire.points.len().saturating_sub(1) {
let nearest = nearest_on_segment(
cursor_world,
wp_f64(wire, index),
wp_f64(wire, index + 1),
);
curve_d2 = curve_d2.min(dist2(
world_to_screen(nearest, view_rot, eye, bounds),
cursor_screen,
));
}
offer(wire, curve_d2);
}
} }
} }
@ -1683,6 +1782,46 @@ impl IndexedSegment {
} }
} }
const MAX_INDEXED_PAIRWISE_WORK: usize = 500_000;
fn indexed_sweep_within_budget(segments: &[&IndexedSegment]) -> bool {
let mut work = 0usize;
for (index, &a) in segments.iter().enumerate() {
for &b in segments.iter().skip(index + 1) {
if b.min_x() > a.max_x() {
break;
}
work += 1;
if work > MAX_INDEXED_PAIRWISE_WORK {
return false;
}
if a.wire == b.wire || a.max_y() < b.min_y() || a.min_y() > b.max_y() {
continue;
}
}
}
true
}
fn projected_sweep_within_budget(segments: &[(&IndexedSegment, Point, Point, [f32; 4])]) -> bool {
let mut work = 0usize;
for (index, &(a, _, _, aabb_a)) in segments.iter().enumerate() {
for &(b, _, _, aabb_b) in segments.iter().skip(index + 1) {
if aabb_b[0] > aabb_a[2] {
break;
}
work += 1;
if work > MAX_INDEXED_PAIRWISE_WORK {
return false;
}
if a.wire == b.wire || aabb_a[3] < aabb_b[1] || aabb_a[1] > aabb_b[3] {
continue;
}
}
}
true
}
fn indexed_segments<W: WireSource + ?Sized>(wires: &W) -> Option<Vec<IndexedSegment>> { fn indexed_segments<W: WireSource + ?Sized>(wires: &W) -> Option<Vec<IndexedSegment>> {
let refs = wires.segments()?; let refs = wires.segments()?;
Some( Some(
@ -1706,24 +1845,6 @@ fn indexed_segments<W: WireSource + ?Sized>(wires: &W) -> Option<Vec<IndexedSegm
) )
} }
fn segment_aabb_distance2(segment: &IndexedSegment, point: DVec3) -> f64 {
let dx = if point.x < segment.min_x() {
segment.min_x() - point.x
} else if point.x > segment.max_x() {
point.x - segment.max_x()
} else {
0.0
};
let dy = if point.y < segment.min_y() {
segment.min_y() - point.y
} else if point.y > segment.max_y() {
point.y - segment.max_y()
} else {
0.0
};
dx * dx + dy * dy
}
/// Reconstruct the absolute f64 position of wire vertex `i` from its /// Reconstruct the absolute f64 position of wire vertex `i` from its
/// double-single high/low pair. At UTM-scale coordinates the `points` (high) /// double-single high/low pair. At UTM-scale coordinates the `points` (high)
/// f32 alone is ~0.5 m off; adding the low residual restores f64 precision so /// f32 alone is ~0.5 m off; adding the low residual restores f64 precision so