From d5bf919414395218c844dd64ea2f792d2d88a1f9 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:33:38 +0300 Subject: [PATCH] Cut the cost of finding what a selection box contains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/update/viewport.rs | 10 +- src/scene/mod.rs | 43 +++- src/scene/pick/hit_test.rs | 131 +++++++++- src/scene/pick/interaction_index.rs | 383 ++++++++++++++++++++++++++++ 4 files changed, 547 insertions(+), 20 deletions(-) diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 534618de0..fc69d43ef 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -4186,8 +4186,9 @@ impl OpenCADStudio { self.refresh_properties(); if crate::perf::enabled() { crate::perf_record!( - "[perf] select-commit kind=drag-box candidates={cand_ms:.1}ms \ -hit={hit_ms:.1}ms apply={apply_ms:.1}ms properties={:.1}ms picked={}", + "[perf] select-commit kind=drag-box crossing={crossing} \ +candidates={cand_ms:.1}ms hit={hit_ms:.1}ms apply={apply_ms:.1}ms \ +properties={:.1}ms picked={}", t_props.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0), handles.len(), ); @@ -4564,8 +4565,9 @@ hit={hit_ms:.1}ms apply={apply_ms:.1}ms properties={:.1}ms picked={}", self.refresh_properties(); if crate::perf::enabled() { crate::perf_record!( - "[perf] select-commit kind=window candidates={cand_ms:.1}ms \ -hit={hit_ms:.1}ms filter={filter_ms:.1}ms apply={apply_ms:.1}ms properties={:.1}ms picked={}", + "[perf] select-commit kind=window crossing={crossing} \ +candidates={cand_ms:.1}ms hit={hit_ms:.1}ms filter={filter_ms:.1}ms \ +apply={apply_ms:.1}ms properties={:.1}ms picked={}", t_props.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0), handles.len(), ); diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 04b0f7bb7..2aa89c864 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -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>, 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>, @@ -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 { 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 = candidates .iter() .filter_map(|wire| Self::handle_from_wire_name(&wire.name)) diff --git a/src/scene/pick/hit_test.rs b/src/scene/pick/hit_test.rs index f9520c760..ce68c2994 100644 --- a/src/scene/pick/hit_test.rs +++ b/src/scene/pick/hit_test.rs @@ -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 = 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 { + (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)"); + } +} diff --git a/src/scene/pick/interaction_index.rs b/src/scene/pick/interaction_index.rs index 4e1f6987a..e21068f25 100644 --- a/src/scene/pick/interaction_index.rs +++ b/src/scene/pick/interaction_index.rs @@ -458,6 +458,76 @@ impl SpatialGrid { } } + /// The same set as [`SpatialGrid::query`], in cell order and with + /// duplicates left in. + /// + /// `query` sorts twice over the result — once to dedup the entry indices an + /// entry gets for spanning several cells, once to dedup the values — which + /// over a box covering the drawing is two O(n log n) passes across 4.4 M + /// elements to remove a handful of repeats. Everything an area selection + /// does with the result is order-independent and dedups anyway: the + /// crossing scan keys wires by index and names by string, and the box test + /// on a segment does not care when it runs. + /// + /// Click picking keeps [`SpatialGrid::query`]: it resolves ties between + /// overlapping geometry by scan order, so its order is part of which entity + /// a click selects. + fn query_unordered(&self, entries: &[Entry3], query: [f64; 4]) -> Vec { + let mut indices = Vec::new(); + self.oversized.query(entries, query, &mut indices); + let mut out: Vec = indices + .into_iter() + .filter_map(|idx| { + let entry = entries.get(idx as usize)?; + aabb_overlaps(entry_aabb2(entry), query).then_some(entry.value) + }) + .collect(); + + let grid_max_x = self.min[0] + self.cols as f64 * self.cell; + let grid_max_y = self.min[1] + self.rows as f64 * self.cell; + if query[2] < self.min[0] + || query[3] < self.min[1] + || query[0] > grid_max_x + || query[1] > grid_max_y + { + return out; + } + let col = |x: f64| { + (((x - self.min[0]) / self.cell).floor()).clamp(0.0, (self.cols - 1) as f64) as u32 + }; + let row = |y: f64| { + (((y - self.min[1]) / self.cell).floor()).clamp(0.0, (self.rows - 1) as f64) as u32 + }; + let (row_lo, row_hi) = (row(query[1]), row(query[3])); + let (col_lo, col_hi) = (col(query[0]), col(query[2])); + + // One growth instead of a chain of reallocations: a box over the whole + // drawing lands nearly every entry here. + let spanned: usize = (row_lo..=row_hi) + .map(|r| { + let base = r as usize * self.cols as usize; + let start = self.cell_offsets[base + col_lo as usize] as usize; + let end = self.cell_offsets[base + col_hi as usize + 1] as usize; + end.saturating_sub(start) + }) + .sum(); + out.reserve(spanned); + + for r in row_lo..=row_hi { + let base = r as usize * self.cols as usize; + for c in col_lo..=col_hi { + let cell_idx = base + c as usize; + let start = self.cell_offsets[cell_idx] as usize; + let end = self.cell_offsets[cell_idx + 1] as usize; + out.extend(self.cell_entries[start..end].iter().filter_map(|&idx| { + let entry = entries.get(idx as usize)?; + aabb_overlaps(entry_aabb2(entry), query).then_some(entry.value) + })); + } + } + out + } + fn query(&self, entries: &[Entry3], query: [f64; 4]) -> Vec { let mut entry_indices = Vec::new(); self.oversized.query(entries, query, &mut entry_indices); @@ -520,6 +590,11 @@ impl SpatialSet { self.xy.query(&self.entries, aabb) } + /// See [`SpatialGrid::query_unordered`] — same set, no sort, duplicates in. + fn query_xy_unordered(&self, aabb: [f64; 4]) -> Vec { + self.xy.query_unordered(&self.entries, aabb) + } + fn prepare_screen(&self) { self.xyz .get_or_init(|| SpatialBvh3::build(&self.entries)); @@ -1136,6 +1211,59 @@ impl InteractionIndex { .collect() } + /// [`InteractionIndex::query_remapped_xy`] for an area selection. + /// + /// The overlay path runs whenever the drawing has been edited since the + /// interaction index was built — that is, for most of a working session — + /// so leaving it on the full query would have meant the area selection got + /// its saving only until the user drew something. Same five categories, + /// same unordered gather. + pub(crate) fn query_remapped_xy_area( + &self, + wires: Arc>, + slots: &rustc_hash::FxHashMap<(u64, u32), u32>, + aabb: [f64; 4], + ) -> InteractionCandidates { + InteractionCandidates { + wires, + wire_indices: Some(self.remap_wire_indices( + self.wires.query_xy_unordered(aabb), + slots, + false, + )), + segments: Some(self.remap_refs( + self.segments.query_xy_unordered(aabb), + slots, + |value| value.wire, + |value, wire| value.wire = wire, + )), + snap_points: None, + key_vertices: None, + key_segments: None, + fill_triangles: Some(self.remap_refs( + self.fill_triangles.query_xy_unordered(aabb), + slots, + |value| value.wire, + |value, wire| value.wire = wire, + )), + pick_triangles: Some(self.remap_refs( + self.pick_triangles.query_xy_unordered(aabb), + slots, + |value| value.wire, + |value, wire| value.wire = wire, + )), + glyphs: Some(self.remap_refs( + self.glyphs.query_xy_unordered(aabb), + slots, + |value| value.wire, + |value, wire| value.wire = wire, + )), + query_aabb: Some(aabb), + screen_rect: None, + screen_view: None, + } + } + pub(crate) fn query_remapped_xy( &self, wires: Arc>, @@ -1269,6 +1397,76 @@ impl InteractionIndex { } } + /// Candidates for an area selection — box, crossing or fence. + /// + /// `query_xy` runs eight spatial queries because hover and snapping need + /// them all. An area selection needs five: the wires and their segments, + /// and — because `indexed_box_crossing_hits` reads them to catch hatches + /// and text inside the box — the fill triangles, pick triangles and + /// glyphs. Only the three that exist for snapping (`snap_points`, + /// `key_vertices`, `key_segments`, read solely by `pick::snap::snap`) go + /// unread, so only those are skipped. + /// + /// Over a box covering the whole drawing each query gathers nearly + /// everything, which is why this is worth splitting: gathering candidates + /// was 407 ms of an 825 ms selection of 186 468 entities. + /// + /// The skipped categories stay `None`. That is safe *only* for consumers + /// that never read them — `snap` would silently stop snapping rather than + /// fail, since the accessors treat `None` as "no index". Route anything + /// that snaps through `query_xy`. + pub fn query_xy_area( + &self, + wires: Arc>, + aabb: [f64; 4], + ) -> InteractionCandidates { + // The five are independent tree walks over the same box, and over a box + // covering the drawing each one gathers nearly everything — 259 ms + // together. Running them concurrently costs the largest instead of the + // sum. Each closure still calls the same query, so the results are + // identical to the sequential ones, order included. + let ((wire_indices, segments), ((fill_triangles, pick_triangles), glyphs)) = + rayon::join( + || { + rayon::join( + || { + let mut indices = self.wires.query_xy_unordered(aabb); + indices.extend_from_slice(&self.unbounded_wires); + indices.sort_unstable(); + indices.dedup(); + indices + }, + || self.segments.query_xy_unordered(aabb), + ) + }, + || { + rayon::join( + || { + rayon::join( + || self.fill_triangles.query_xy_unordered(aabb), + || self.pick_triangles.query_xy_unordered(aabb), + ) + }, + || self.glyphs.query_xy_unordered(aabb), + ) + }, + ); + InteractionCandidates { + wires, + wire_indices: Some(wire_indices), + segments: Some(segments), + snap_points: None, + key_vertices: None, + key_segments: None, + fill_triangles: Some(fill_triangles), + pick_triangles: Some(pick_triangles), + glyphs: Some(glyphs), + query_aabb: Some(aabb), + screen_rect: None, + screen_view: None, + } + } + pub fn query_xy(&self, wires: Arc>, aabb: [f64; 4]) -> InteractionCandidates { let mut wire_indices = self.wires.query_xy(aabb); wire_indices.extend_from_slice(&self.unbounded_wires); @@ -1505,6 +1703,39 @@ impl InteractionCandidates { .map(|(rect, (view, eye, bounds))| (rect, view, eye, bounds)) } + /// [`InteractionCandidates::extend_indexed`] for an area selection. + /// + /// `extend_indexed` sorts and dedups every category it merges, which over a + /// box covering the drawing would put back exactly the sorts the unordered + /// query exists to avoid. + /// + /// `wire_indices` keeps its dedup, and must: the window branch of `box_hit` + /// walks it directly, so a wire listed twice would be selected twice. The + /// other categories are read by scans that dedup by wire index and by name + /// already, so a repeat there costs one extra test and changes nothing. + pub(crate) fn extend_indexed_area(&mut self, other: Self) { + debug_assert!(Arc::ptr_eq(&self.wires, &other.wires)); + + fn append(target: &mut Option>, incoming: Option>) { + let (Some(target), Some(incoming)) = (target.as_mut(), incoming) else { + return; + }; + target.extend(incoming); + } + + if let (Some(target), Some(incoming)) = + (self.wire_indices.as_mut(), other.wire_indices) + { + target.extend(incoming); + target.sort_unstable(); + target.dedup(); + } + append(&mut self.segments, other.segments); + append(&mut self.fill_triangles, other.fill_triangles); + append(&mut self.pick_triangles, other.pick_triangles); + append(&mut self.glyphs, other.glyphs); + } + pub(crate) fn extend_indexed(&mut self, other: Self) { debug_assert!(Arc::ptr_eq(&self.wires, &other.wires)); @@ -1830,3 +2061,155 @@ fn aabb3_projects_into( fn aabb_overlaps(a: [f64; 4], b: [f64; 4]) -> bool { a[2] >= b[0] && a[0] <= b[2] && a[3] >= b[1] && a[1] <= b[3] } + +#[cfg(test)] +mod area_query_tests { + use super::*; + use iced::{Point, Rectangle}; + + fn wire(name: &str, pts: Vec<[f32; 3]>, aabb: [f32; 4]) -> WireModel { + let mut w = WireModel::solid(name.to_string(), pts, [1.0; 4], false); + w.aabb = aabb; + w + } + + fn sample() -> Vec { + vec![ + wire("5", vec![[-0.5, -0.5, 0.0], [0.5, 0.5, 0.0]], [-0.5, -0.5, 0.5, 0.5]), + wire("9", vec![[0.1, -0.9, 0.0], [0.9, -0.1, 0.0]], [0.1, -0.9, 0.9, -0.1]), + wire("13", vec![[-0.9, 0.2, 0.0], [-0.2, 0.9, 0.0]], [-0.9, 0.2, -0.2, 0.9]), + ] + } + + // The area query drops the three snap-only categories and must leave every + // category an area selection reads untouched. `unwrap_or_default()` in + // `indexed_box_crossing_hits` would turn a wrongly-dropped category into a + // silent miss — a hatch or a text that stops being selectable — rather than + // a failure, so the equivalence is asserted rather than assumed. + #[test] + fn area_query_matches_full_query_where_it_is_read() { + let wires = sample(); + let index = InteractionIndex::build(&wires); + let arc = Arc::new(wires); + let aabb = [-2.0, -2.0, 2.0, 2.0]; + + let full = index.query_xy(Arc::clone(&arc), aabb); + let area = index.query_xy_area(Arc::clone(&arc), aabb); + + // The area query skips the sort and the dedup `query` pays for, so it + // returns the same *set* in cell order, with an entry repeated once per + // cell it spans. Every consumer on this path dedups already — the + // crossing scan by wire index and by name — so the set is the contract, + // and comparing sequences here would only pin down an order nothing + // reads. `wire_indices` is the exception: `query_xy_area` sorts and + // dedups it before returning, so it must match exactly. + fn normalise(v: &Option>) -> Option> { + v.as_ref().map(|items| { + let mut items = items.clone(); + items.sort_unstable(); + items.dedup(); + items + }) + } + assert_eq!(area.wire_indices, full.wire_indices); + assert_eq!(normalise(&area.segments), normalise(&full.segments)); + assert_eq!( + normalise(&area.fill_triangles), + normalise(&full.fill_triangles), + ); + assert_eq!( + normalise(&area.pick_triangles), + normalise(&full.pick_triangles), + ); + assert_eq!(normalise(&area.glyphs), normalise(&full.glyphs)); + assert_eq!(area.query_aabb, full.query_aabb); + + // The sample carries no hatches or text, so the equality above is + // thin for those three. What matters is that they stay indexed at all: + // dropping one to `None` is what would silently unselect a hatch. + assert!(area.fill_triangles.is_some()); + assert!(area.pick_triangles.is_some()); + assert!(area.glyphs.is_some()); + + assert!(area.snap_points.is_none()); + assert!(area.key_vertices.is_none()); + assert!(area.key_segments.is_none()); + assert!(full.snap_points.is_some(), "the full query still snaps"); + } + + // The overlay merge drops the sort on every category but one. That one + // matters: the window branch of `box_hit` walks `wire_indices` directly, so + // a wire left in twice would be selected twice — a duplicate handle rather + // than an error, which is the kind of thing that surfaces as a wrong count + // somewhere far away. + #[test] + fn the_area_merge_dedups_wire_indices_and_nothing_else() { + let arc = Arc::new(sample()); + let base = |indices: Vec, segments: Vec| InteractionCandidates { + wires: Arc::clone(&arc), + wire_indices: Some(indices), + segments: Some(segments), + snap_points: None, + key_vertices: None, + key_segments: None, + fill_triangles: Some(Vec::new()), + pick_triangles: Some(Vec::new()), + glyphs: Some(Vec::new()), + query_aabb: Some([-2.0, -2.0, 2.0, 2.0]), + screen_rect: None, + screen_view: None, + }; + let seg = |wire: u32| SegmentRef { wire, start: 0 }; + + let mut merged = base(vec![2, 0], vec![seg(2), seg(0)]); + merged.extend_indexed_area(base(vec![1, 0], vec![seg(1), seg(0)])); + + assert_eq!(merged.wire_indices, Some(vec![0, 1, 2])); + assert_eq!( + merged.segments, + Some(vec![seg(2), seg(0), seg(1), seg(0)]), + "the other categories are appended as they come", + ); + } + + // World (x,y) → screen ((x+1)*100, (1-y)*100) over a 200×200 viewport, the + // same identity ortho view the hit-test tests use. + #[test] + fn box_selection_picks_the_same_handles_from_either_query() { + let wires = sample(); + let index = InteractionIndex::build(&wires); + let arc = Arc::new(wires); + let aabb = [-2.0, -2.0, 2.0, 2.0]; + let bounds = Rectangle { + x: 0.0, + y: 0.0, + width: 200.0, + height: 200.0, + }; + let eye = glam::DVec3::ZERO; + + for crossing in [false, true] { + let full = index.query_xy(Arc::clone(&arc), aabb); + let area = index.query_xy_area(Arc::clone(&arc), aabb); + let hit = |c: &InteractionCandidates| { + let mut names = crate::scene::pick::hit_test::box_hit( + Point::new(0.0, 0.0), + Point::new(200.0, 200.0), + crossing, + c, + glam::Mat4::IDENTITY, + eye, + bounds, + ) + .into_iter() + .map(str::to_string) + .collect::>(); + names.sort(); + names + }; + let from_full = hit(&full); + assert!(!from_full.is_empty(), "the box covers the whole sample"); + assert_eq!(hit(&area), from_full, "crossing={crossing}"); + } + } +}