Cut the cost of finding what a selection box contains

A window over a whole drawing spent 407 ms gathering candidates and 259 ms
testing them, of 825 ms total, at 186 468 entities.

**Gathering** ran eight spatial queries because that is what hover and
snapping need between them. An area selection reads five: the wires and their
segments, plus the fill triangles, pick triangles and glyphs that
`indexed_box_crossing_hits` uses to catch hatches and text inside the box.
The three that exist only for snapping are skipped.

That was worth 148 ms. The rest came from the queries themselves.
`SpatialGrid::query` sorts its result twice — once over the entry indices, to
remove the repeats an entry collects for spanning several cells, and once
over the values. Over a box covering the drawing that is two O(n log n)
passes across 4.4 M elements to remove a handful of duplicates.
`query_unordered` returns the same set in cell order with the duplicates left
in, and reserves the space it needs instead of growing a 17 MB vector in
steps. Gathering went from 515 ms to 69 ms on a synthetic set at that size,
and from 407 ms to 27-40 ms in the application.

Everything an area selection does with the result already dedups and does not
read the order. Click picking keeps the sorted `query`, where scan order
decides which of two overlapping entities a click lands on.

**Testing** projected both endpoints of every segment in the box, then threw
the result away for any wire already selected. A circle or a polyline
contributes one segment per tessellated span, so at 24 spans per wire that is
23 projection pairs out of 24 doing nothing. Skipping a wire once it is in
took a 4.4 M-segment scan from roughly a second to 42 ms.

The overlay path — active after any edit, so most of a working session —
needed the same treatment, including its own merge: `extend_indexed` sorts
and dedups every category, which would have put back the sorts this removes.
`extend_indexed_area` keeps the dedup on `wire_indices`, and must: the window
branch of `box_hit` walks it directly, so a wire left in twice would be
selected twice.

Tests cover the equivalence in both directions: the area query matches the
full one as a set in every category an area selection reads, and the merge
dedups `wire_indices` and nothing else.
This commit is contained in:
Panos committed 2026-09-09 10:35:17 +03:00
1 parent d02ce08349
commit d5bf919414
4 files changed
+547 -20

No files matched your search

+123 -8
View File
@@ -13,7 +13,7 @@ use iced::{Point, Rectangle};
use crate::scene::model::hatch_model::HatchModel;
use crate::scene::model::mesh_model::MeshModel;
use crate::scene::model::wire_model::WireModel;
use crate::scene::pick::interaction_index::WireSource;
use crate::scene::pick::interaction_index::{SegmentRef, WireSource};
/// Pick radius for one wire, in screen pixels.
///
@@ -1247,13 +1247,14 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>(
|| (0..4).any(|edge| segments_intersect(a, b, corners[edge], corners[(edge + 1) % 4]))
};
for segment in wires.segments().unwrap_or_default() {
let Some(wire) = wires.source_wire(segment.wire) else {
continue;
};
// One projection pair per segment, reading nothing but the wire, the view
// and the box. Hoisted into a closure so the sequential and the parallel
// path below run identical code.
let hit_name = |segment: &SegmentRef| -> Option<&'a str> {
let wire = wires.source_wire(segment.wire)?;
let start = segment.start as usize;
if start + 1 >= wire.points.len() {
continue;
return None;
}
let a = world_to_screen(
wire_point_world(wire, start, view_rot, eye),
@@ -1267,8 +1268,35 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>(
eye,
bounds,
);
if segment_hits(a, b) && seen.insert(wire.name.as_str()) {
out.push(wire.name.as_str());
segment_hits(a, b).then_some(wire.name.as_str())
};
// A crossing selection over a whole drawing spends its time here, not in
// the window branch of `box_hit`: every measured `select-commit` line read
// `crossing=true`.
//
// The scan stops projecting a wire once that wire is in. A circle or a
// polyline contributes one segment per tessellated span, and every span
// after the first that hits can only re-add a name already present — at 24
// spans per wire that is 23 projection pairs out of 24 skipped, which took
// a 4.4 M-segment scan from ~1 s to 42 ms. The wire is keyed by index, an
// integer to hash rather than a string, and the name set still runs behind
// it because one entity can produce several wires sharing a name.
//
// Fanning this out across cores was tried and removed: with the early-out
// the remaining work is small enough that per-chunk sets, which cannot see
// wires already hit in another chunk, measured *slower* than one pass
// (45.6 ms against 42.4 ms at 186 468 wires).
let mut wire_hit: HashSet<u32> = HashSet::default();
for segment in wires.segments().unwrap_or_default() {
if wire_hit.contains(&segment.wire) {
continue;
}
if let Some(name) = hit_name(segment) {
wire_hit.insert(segment.wire);
if seen.insert(name) {
out.push(name);
}
}
}
for wire in wires.iter().filter(|wire| wire.point_marker.is_some()) {
@@ -2712,3 +2740,90 @@ mod aabb_reject_tests {
}
}
#[cfg(test)]
mod parallel_selection_tests {
use super::*;
use crate::scene::pick::interaction_index::{InteractionCandidates, InteractionIndex};
use std::sync::Arc;
// Enough wires to clear the 4096 default several times over, laid on a
// diagonal so the selection box catches a known prefix of them rather than
// all or nothing — an "everything hits" case would not notice a reordering.
// A drawing is not made of single segments: a circle or an arc tessellates
// into dozens of spans, and a polyline carries as many as it has vertices.
// A one-segment-per-wire sample makes the per-wire early-out invisible and
// the whole measurement optimistic, so the benchmark uses `spans` per wire.
fn many_wires_with_spans(count: usize, spans: usize) -> Vec<WireModel> {
(0..count)
.map(|i| {
let t = i as f32 / count as f32;
let x = -0.95 + 1.9 * t;
let y = -0.95 + 1.9 * t;
let points: Vec<[f32; 3]> = (0..=spans)
.map(|s| {
let f = s as f32 / spans as f32;
[x + 0.01 * f, y + 0.01 * f, 0.0]
})
.collect();
let mut w = WireModel::solid(
(i as u64 + 1).to_string(),
points,
[1.0; 4],
false,
);
w.aabb = [x, y, x + 0.01, y + 0.01];
w
})
.collect()
}
// Not a correctness test — a measurement, at the size the user's drawing
// actually is. Run it deliberately:
//
// cargo test --release --lib selection_scaling -- --ignored --nocapture
#[test]
#[ignore = "measurement, not a check"]
fn selection_scaling_numbers() {
use std::time::Instant;
let wires = many_wires_with_spans(186_468, 24);
let index = InteractionIndex::build(&wires);
let arc = Arc::new(wires);
let aabb = [-2.0, -2.0, 2.0, 2.0];
let t = Instant::now();
let full = index.query_xy(Arc::clone(&arc), aabb);
let full_ms = t.elapsed().as_secs_f64() * 1000.0;
let t = Instant::now();
let area = index.query_xy_area(Arc::clone(&arc), aabb);
let area_ms = t.elapsed().as_secs_f64() * 1000.0;
let bounds = Rectangle {
x: 0.0,
y: 0.0,
width: 200.0,
height: 200.0,
};
let run = |candidates: &InteractionCandidates| {
let t = Instant::now();
let hits = box_hit(
Point::new(0.0, 0.0),
Point::new(200.0, 200.0),
true,
candidates,
Mat4::IDENTITY,
glam::DVec3::ZERO,
bounds,
);
(t.elapsed().as_secs_f64() * 1000.0, hits.len())
};
let (scan_ms, scan_n) = run(&area);
println!(
"candidates: full={full_ms:.1}ms ({} wires) area={area_ms:.1}ms ({} wires)",
full.len(),
area.len(),
);
println!("crossing scan: {scan_ms:.1}ms ({scan_n} hits)");
}
}