forked from 1b-agent/opencadstudio-upstream
Merge PR #1099: reduce area selection candidate queries
This commit is contained in:
commit
8262ba41c1
4 files changed
+547
-20
No files matched your search
@@ -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
@@ -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
@@ -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)");
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user