Merge PR #1099: reduce area selection candidate queries

This commit is contained in:
Hakan Seven committed 2026-09-09 13:09:18 +03:00
commit 8262ba41c1
4 files changed
+547 -20

No files matched your search

+6 -4
View File
@@ -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(),
);
+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))
+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)");
}
}
+383
View File
@@ -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<T: Copy>(&self, entries: &[Entry3<T>], query: [f64; 4]) -> Vec<T> {
let mut indices = Vec::new();
self.oversized.query(entries, query, &mut indices);
let mut out: Vec<T> = 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<T: Copy + Ord>(&self, entries: &[Entry3<T>], query: [f64; 4]) -> Vec<T> {
let mut entry_indices = Vec::new();
self.oversized.query(entries, query, &mut entry_indices);
@@ -520,6 +590,11 @@ impl<T: Copy + Ord + Sync> SpatialSet<T> {
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<T> {
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<Vec<WireModel>>,
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<Vec<WireModel>>,
@@ -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<Vec<WireModel>>,
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<Vec<WireModel>>, 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<T>(target: &mut Option<Vec<T>>, incoming: Option<Vec<T>>) {
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<WireModel> {
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<T: Copy + Ord>(v: &Option<Vec<T>>) -> Option<Vec<T>> {
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<u32>, segments: Vec<SegmentRef>| 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::<Vec<_>>();
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}");
}
}
}