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

+35 -8
View File
@@ -7865,11 +7865,18 @@ impl Scene {
(Arc::new(wires), base_slots, changed_slots)
}
/// `area_only` skips the snap-only categories and gathers the rest without
/// sorting; see [`InteractionIndex::query_xy_area`]. Both the overlay path
/// and the plain indexed path honour it — the overlay runs whenever the
/// drawing has been edited since the index was built, which is most of a
/// working session, so a saving that stopped there would stop the first
/// time the user drew something.
fn indexed_interaction_candidates_xy(
&self,
wires: Arc<Vec<WireModel>>,
aabb: [f64; 4],
allow_pending_empty: bool,
area_only: bool,
) -> crate::scene::pick::interaction_index::InteractionCandidates {
if let Some((base_epoch, base, changes)) = self.interaction_overlay_base() {
let perf = crate::perf::enabled();
@@ -7888,11 +7895,20 @@ impl Scene {
);
let local_ms = t_local.elapsed().as_secs_f64() * 1000.0;
let t_remap = iced::time::Instant::now();
let mut result =
base.query_remapped_xy(Arc::clone(&local), &base_slots, aabb);
result.extend_indexed(
changed_index.query_remapped_xy(local, &changed_slots, aabb),
);
let mut result = if area_only {
base.query_remapped_xy_area(Arc::clone(&local), &base_slots, aabb)
} else {
base.query_remapped_xy(Arc::clone(&local), &base_slots, aabb)
};
if area_only {
result.extend_indexed_area(
changed_index.query_remapped_xy_area(local, &changed_slots, aabb),
);
} else {
result.extend_indexed(
changed_index.query_remapped_xy(local, &changed_slots, aabb),
);
}
let remap_ms = t_remap.elapsed().as_secs_f64() * 1000.0;
let total_ms = t0.elapsed().as_secs_f64() * 1000.0;
if perf && total_ms >= 50.0 {
@@ -7910,7 +7926,11 @@ impl Scene {
return result;
}
if let Some(index) = self.cached_interaction_index(&wires) {
index.query_xy(wires, aabb)
if area_only {
index.query_xy_area(wires, aabb)
} else {
index.query_xy(wires, aabb)
}
} else if allow_pending_empty
&& self.interaction_index_pending_key.get()
== Some((self.geometry_epoch, Arc::as_ptr(&wires) as usize))
@@ -8233,12 +8253,18 @@ impl Scene {
cursor.x + radius,
cursor.y + radius,
];
self.indexed_interaction_candidates_xy(wires, query, allow_pending_empty)
self.indexed_interaction_candidates_xy(wires, query, allow_pending_empty, false)
}
/// Shared rectangular broad phase for box/lasso/fence and command windows.
/// Flat orthographic views query world XY; tilted/perspective views query
/// projected 3D bounds using the supplied screen rectangle.
/// Candidates for an area selection: box, crossing, fence and lasso.
///
/// Every caller feeds the result to the box/fence hit tests and to
/// `interaction_candidate_handles`, none of which snap, so the flat-ortho
/// path asks for the reduced category set. A caller that needs snapping
/// belongs on `interaction_candidates_near` instead.
pub fn interaction_candidates_in_aabb(
&self,
wires: Arc<Vec<WireModel>>,
@@ -8275,6 +8301,7 @@ impl Scene {
wires,
[aabb[0] - pad, aabb[1] - pad, aabb[2] + pad, aabb[3] + pad],
false,
true,
)
} else {
self.indexed_interaction_candidates_screen(
@@ -8330,7 +8357,7 @@ impl Scene {
pub fn interaction_handles_in_world_aabb(&self, aabb: [f64; 4]) -> HashSet<Handle> {
let wires = self.hit_test_wires();
let candidates = self.indexed_interaction_candidates_xy(wires, aabb, false);
let candidates = self.indexed_interaction_candidates_xy(wires, aabb, false, false);
let mut handles: HashSet<Handle> = candidates
.iter()
.filter_map(|wire| Self::handle_from_wire_name(&wire.name))