forked from 1b-agent/opencadstudio-upstream
perf(snap): keep snap/hover responsive on block-heavy drawings
Snap and hover hit-testing scanned the whole model wire set every cursor move; a heavily block-instanced drawing explodes to millions of wires, so this stalled for over a second per move. Add a cached spatial grid (wire_grid) keyed on the geometry epoch and query only the cursor neighbourhood via Scene::hit_test_wires_near. Gate the O(in-range segments squared) intersection passes on the in-range POINT count, not the wire count — a handful of curved wires, each hundreds of tessellated points, blew the quadratic up while the wire count looked modest. Give zero-size entities away from the origin (POINTs) a real cullable AABB, and fall back to a block sub-wire's own world points when its entity bounding box is degenerate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
f91e45a62a
commit
e1c19d8af3
6 files changed
+313
-15
No files matched your search
@@ -731,7 +731,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
|
||||
{
|
||||
self.tabs[i].scene.model_wires_for_viewport_arc(h, bounds.height)
|
||||
} else {
|
||||
self.tabs[i].scene.hit_test_wires()
|
||||
self.tabs[i].scene.hit_test_wires_near(raw, view_rot, bounds)
|
||||
};
|
||||
// Grip drag has no single rubber-band origin for a perp foot.
|
||||
self.snapper.from_point = None;
|
||||
@@ -853,7 +853,9 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
|
||||
{
|
||||
self.tabs[i].scene.model_wires_for_viewport_arc(h, bounds.height)
|
||||
} else {
|
||||
self.tabs[i].scene.hit_test_wires()
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.hit_test_wires_near(snap_cursor, view_rot, bounds)
|
||||
};
|
||||
let needs_entity = self.tabs[i]
|
||||
.active_cmd
|
||||
@@ -1347,7 +1349,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
|
||||
{
|
||||
self.tabs[i].scene.model_wires_for_viewport_arc(h, bounds.height)
|
||||
} else {
|
||||
self.tabs[i].scene.hit_test_wires()
|
||||
self.tabs[i].scene.hit_test_wires_near(raw, view_rot, bounds)
|
||||
};
|
||||
self.snapper.grid_spacing = crate::ui::overlay::compute_grid_step(view_rot, bounds);
|
||||
// No rubber-band origin (perp/extension feet don't apply to a free drag).
|
||||
@@ -1801,7 +1803,9 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
|
||||
{
|
||||
self.tabs[i].scene.model_wires_for_viewport_arc(h, bounds.height)
|
||||
} else {
|
||||
self.tabs[i].scene.hit_test_wires()
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.hit_test_wires_near(snap_cursor, view_rot, bounds)
|
||||
};
|
||||
let needs_tan = self.tabs[i]
|
||||
.active_cmd
|
||||
|
||||
@@ -598,7 +598,20 @@ pub(crate) fn tessellate_entity(
|
||||
for w in &mut wires {
|
||||
w.name = h.value().to_string();
|
||||
w.aci = sub_aci;
|
||||
w.aabb = sub_aabb;
|
||||
// Keep the glyph-bounds AABB tessellate set on SDF text wires
|
||||
// (their geometry is in `text_verts`, not `points`); clobbering
|
||||
// it here left block text with an UNBOUNDED box. For every
|
||||
// other wire use the sub-entity box, falling back to the wire's
|
||||
// own world points when that box is degenerate/unimplemented
|
||||
// (UNBOUNDED) — otherwise it never culls and stalls snapping on
|
||||
// block-heavy drawings.
|
||||
if w.text_verts.is_empty() {
|
||||
w.aabb = if sub_aabb == WireModel::UNBOUNDED_AABB {
|
||||
wire_points_aabb(w)
|
||||
} else {
|
||||
sub_aabb
|
||||
};
|
||||
}
|
||||
}
|
||||
wires
|
||||
})
|
||||
@@ -809,15 +822,45 @@ fn lod_stub_wire_3d(
|
||||
/// INSERT only stamps the geometry once, attribute text sits at the world
|
||||
/// position recorded on each ATTRIB. See #20.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
/// World-space XY AABB of a wire computed from its own points (double-single
|
||||
/// `points + points_low` sum = absolute world, matching `wire_in_range`'s
|
||||
/// `cursor_world`). Used as a fallback when an entity's `bounding_box()` is
|
||||
/// degenerate/unimplemented (`entity_aabb` → `UNBOUNDED`), so the wire still
|
||||
/// gets a real, cullable box instead of never being pre-rejected during snap.
|
||||
pub(crate) fn wire_points_aabb(w: &WireModel) -> [f32; 4] {
|
||||
let mut min = [f32::INFINITY; 2];
|
||||
let mut max = [f32::NEG_INFINITY; 2];
|
||||
let mut any = false;
|
||||
for (i, p) in w.points.iter().enumerate() {
|
||||
let lo = w.points_low.get(i).copied().unwrap_or([0.0; 3]);
|
||||
let (x, y) = (p[0] + lo[0], p[1] + lo[1]);
|
||||
if x.is_finite() && y.is_finite() {
|
||||
min[0] = min[0].min(x);
|
||||
min[1] = min[1].min(y);
|
||||
max[0] = max[0].max(x);
|
||||
max[1] = max[1].max(y);
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
if any {
|
||||
[min[0], min[1], max[0], max[1]]
|
||||
} else {
|
||||
WireModel::UNBOUNDED_AABB
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn entity_aabb(e: &acadrust::EntityType) -> [f32; 4] {
|
||||
let bbox = e.as_entity().bounding_box();
|
||||
let min_x = (bbox.min.x) as f32;
|
||||
let min_y = (bbox.min.y) as f32;
|
||||
let max_x = (bbox.max.x) as f32;
|
||||
let max_y = (bbox.max.y) as f32;
|
||||
// A degenerate box (min == max == 0) means bounding_box() returned Default —
|
||||
// use UNBOUNDED so the wire is never wrongly pre-rejected.
|
||||
if min_x == max_x && min_y == max_y {
|
||||
// The all-zero box is bounding_box()'s Default — returned by entities with
|
||||
// no usable box (unimplemented) — so treat it as UNBOUNDED (never
|
||||
// pre-rejected). A genuinely zero-size box *away* from the origin (e.g. a
|
||||
// POINT) is a valid, cullable position: keep it, otherwise point-heavy
|
||||
// drawings fill the always-checked set and stall hit-testing.
|
||||
if min_x == 0.0 && min_y == 0.0 && max_x == 0.0 && max_y == 0.0 {
|
||||
return WireModel::UNBOUNDED_AABB;
|
||||
}
|
||||
[min_x, min_y, max_x, max_y]
|
||||
|
||||
@@ -775,6 +775,11 @@ pub struct Scene {
|
||||
/// invalidates the cull-dependent wire list as well as a geometry change.
|
||||
/// Uses `Arc` so `build_primitive()` avoids a full Vec clone during navigation.
|
||||
wire_cache: RefCell<Option<((u64, u64), Arc<Vec<WireModel>>)>>,
|
||||
/// Spatial grid over the model hit-test wire set, keyed on `geometry_epoch`
|
||||
/// so it is rebuilt only when geometry changes (not per cursor move). Lets
|
||||
/// snap / hover query a cursor-local wire subset instead of scanning the
|
||||
/// whole (block-exploded, multi-million-wire) set every move.
|
||||
wire_grid_cache: RefCell<Option<(u64, Arc<crate::scene::pick::wire_grid::WireGrid>)>>,
|
||||
/// Index built from every SortEntitiesTable in the document.
|
||||
/// Maps block_handle → (entity_handle.value() → sort_handle.value()).
|
||||
/// Replaces the O(objects) linear scan inside `wires_for_block()` with an O(1) lookup.
|
||||
@@ -995,6 +1000,7 @@ impl Scene {
|
||||
block_epoch: GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed),
|
||||
selection_generation: 0,
|
||||
wire_cache: RefCell::new(None),
|
||||
wire_grid_cache: RefCell::new(None),
|
||||
sort_cache: RefCell::new(None),
|
||||
draw_depth_cache: RefCell::new(None),
|
||||
hatch_cache: RefCell::new(None),
|
||||
@@ -2835,6 +2841,64 @@ impl Scene {
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimum wire count before the spatial grid is worth building; below it a
|
||||
/// full linear scan is already fast and the grid's build/query overhead
|
||||
/// would just add latency.
|
||||
const WIRE_GRID_MIN: usize = 40_000;
|
||||
|
||||
/// Spatial grid over `wires`, cached on `geometry_epoch`. `wires` must be
|
||||
/// the same `geometry_epoch`-keyed set the indices will index into.
|
||||
fn wire_hit_grid(
|
||||
&self,
|
||||
wires: &Arc<Vec<WireModel>>,
|
||||
) -> Arc<crate::scene::pick::wire_grid::WireGrid> {
|
||||
{
|
||||
let cache = self.wire_grid_cache.borrow();
|
||||
if let Some((epoch, ref arc)) = *cache {
|
||||
if epoch == self.geometry_epoch {
|
||||
return Arc::clone(arc);
|
||||
}
|
||||
}
|
||||
}
|
||||
let grid = Arc::new(crate::scene::pick::wire_grid::WireGrid::build(wires));
|
||||
*self.wire_grid_cache.borrow_mut() = Some((self.geometry_epoch, Arc::clone(&grid)));
|
||||
grid
|
||||
}
|
||||
|
||||
/// Hit-test wires near the cursor. For large model sets in a flat top-down
|
||||
/// view this queries a cached spatial grid so snap / hover touch only the
|
||||
/// cursor neighbourhood instead of the whole (block-exploded) wire set;
|
||||
/// small sets, paper space and tilted views fall back to the full set
|
||||
/// (where the grid's flat-XY assumption or build/query overhead would not
|
||||
/// pay off). Drop-in for `hit_test_wires()` at per-move snap / hover sites.
|
||||
pub fn hit_test_wires_near(
|
||||
&self,
|
||||
cursor: glam::DVec3,
|
||||
view_rot: glam::Mat4,
|
||||
bounds: iced::Rectangle,
|
||||
) -> Arc<Vec<WireModel>> {
|
||||
let full = self.hit_test_wires();
|
||||
if self.current_layout != "Model" || full.len() < Self::WIRE_GRID_MIN {
|
||||
return full;
|
||||
}
|
||||
// The grid indexes world XY; only a flat top-down view maps world x/y
|
||||
// straight to screen, so restrict to it (matches the hit-test cull).
|
||||
let flat = view_rot.z_axis.x.abs() < 1e-9 && view_rot.z_axis.y.abs() < 1e-9;
|
||||
if !flat {
|
||||
return full;
|
||||
}
|
||||
// World radius covering the snap aperture + click threshold + margin.
|
||||
// `view_rot.col(0).x * width/2` = pixels per world unit (ortho).
|
||||
let s = view_rot.col(0).x.abs() * bounds.width * 0.5;
|
||||
if s <= 1e-6 {
|
||||
return full;
|
||||
}
|
||||
let radius = 64.0 / s as f64;
|
||||
let grid = self.wire_hit_grid(&full);
|
||||
let idxs = grid.query(cursor.x, cursor.y, radius);
|
||||
Arc::new(idxs.iter().map(|&i| full[i as usize].clone()).collect())
|
||||
}
|
||||
|
||||
/// Pick a meshed 3D solid by clicking on its shaded body (face), not just
|
||||
/// its thin projected edges. Returns the front-most mesh under `cursor`.
|
||||
#[allow(dead_code)]
|
||||
|
||||
@@ -2,4 +2,5 @@ pub mod hit_test;
|
||||
pub mod selection_state;
|
||||
pub mod grip;
|
||||
pub mod quadtree;
|
||||
pub mod wire_grid;
|
||||
pub mod xclip;
|
||||
@@ -0,0 +1,178 @@
|
||||
//! Uniform spatial grid over a hit-test wire set's world-XY AABBs.
|
||||
//!
|
||||
//! Snap and hover hit-testing scan the full model wire set on every cursor
|
||||
//! move. On a heavily block-instanced drawing that set explodes to millions of
|
||||
//! wires, so an O(N) per-move scan — even with a cheap AABB reject — stalls the
|
||||
//! event loop. This grid is built once per geometry epoch (cached alongside the
|
||||
//! wire set) and answers "which wires are near this cursor point" in O(cells in
|
||||
//! range), turning the per-move cost from O(all wires) into O(local).
|
||||
//!
|
||||
//! Wires whose AABB is unbounded (previews / entities with no usable
|
||||
//! bounding box) or spans a large fraction of the grid (drawing-length lines)
|
||||
//! go into an always-checked `oversized` list rather than being smeared across
|
||||
//! thousands of cells.
|
||||
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
|
||||
/// A wire spanning more than this many cells is stored once in `oversized`
|
||||
/// instead of being inserted into every overlapped cell.
|
||||
const MAX_SPAN_CELLS: u64 = 64;
|
||||
|
||||
pub struct WireGrid {
|
||||
min: [f64; 2],
|
||||
cell: f64,
|
||||
cols: u32,
|
||||
rows: u32,
|
||||
cells: Vec<Vec<u32>>,
|
||||
/// Indices always returned as candidates (unbounded / huge AABB).
|
||||
oversized: Vec<u32>,
|
||||
}
|
||||
|
||||
impl WireGrid {
|
||||
fn finite_aabb(w: &WireModel) -> Option<[f64; 4]> {
|
||||
let a = w.aabb;
|
||||
(a[0].is_finite() && a[1].is_finite() && a[2].is_finite() && a[3].is_finite())
|
||||
.then_some([a[0] as f64, a[1] as f64, a[2] as f64, a[3] as f64])
|
||||
}
|
||||
|
||||
pub fn build(wires: &[WireModel]) -> Self {
|
||||
// Overall finite extent.
|
||||
let mut min = [f64::INFINITY; 2];
|
||||
let mut max = [f64::NEG_INFINITY; 2];
|
||||
for w in wires {
|
||||
if let Some(a) = Self::finite_aabb(w) {
|
||||
min[0] = min[0].min(a[0]);
|
||||
min[1] = min[1].min(a[1]);
|
||||
max[0] = max[0].max(a[2]);
|
||||
max[1] = max[1].max(a[3]);
|
||||
}
|
||||
}
|
||||
if !min[0].is_finite() {
|
||||
// No finite wires — everything is oversized (always checked).
|
||||
return Self {
|
||||
min: [0.0, 0.0],
|
||||
cell: 1.0,
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
cells: vec![Vec::new()],
|
||||
oversized: (0..wires.len() as u32).collect(),
|
||||
};
|
||||
}
|
||||
let ext_x = (max[0] - min[0]).max(1e-6);
|
||||
let ext_y = (max[1] - min[1]).max(1e-6);
|
||||
// Aim for ~8 wires/cell; a square-ish cell keeps queries tight.
|
||||
let target_cells = ((wires.len() / 8).max(1)) as f64;
|
||||
let cell = ((ext_x * ext_y) / target_cells).sqrt().max(1e-6);
|
||||
let cols = (((ext_x / cell).ceil() as i64) + 1).clamp(1, 8192) as u32;
|
||||
let rows = (((ext_y / cell).ceil() as i64) + 1).clamp(1, 8192) as u32;
|
||||
|
||||
let mut cells = vec![Vec::<u32>::new(); (cols as usize) * (rows as usize)];
|
||||
let mut oversized = Vec::new();
|
||||
|
||||
let col_of = |x: f64| (((x - min[0]) / cell).floor()).clamp(0.0, (cols - 1) as f64) as u32;
|
||||
let row_of = |y: f64| (((y - min[1]) / cell).floor()).clamp(0.0, (rows - 1) as f64) as u32;
|
||||
|
||||
for (idx, w) in wires.iter().enumerate() {
|
||||
let Some(a) = Self::finite_aabb(w) else {
|
||||
oversized.push(idx as u32);
|
||||
continue;
|
||||
};
|
||||
let c0 = col_of(a[0]);
|
||||
let c1 = col_of(a[2]);
|
||||
let r0 = row_of(a[1]);
|
||||
let r1 = row_of(a[3]);
|
||||
let span = (c1 - c0 + 1) as u64 * (r1 - r0 + 1) as u64;
|
||||
if span > MAX_SPAN_CELLS {
|
||||
oversized.push(idx as u32);
|
||||
continue;
|
||||
}
|
||||
for r in r0..=r1 {
|
||||
let base = (r * cols) as usize;
|
||||
for c in c0..=c1 {
|
||||
cells[base + c as usize].push(idx as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
min,
|
||||
cell,
|
||||
cols,
|
||||
rows,
|
||||
cells,
|
||||
oversized,
|
||||
}
|
||||
}
|
||||
|
||||
/// Candidate wire indices whose cell overlaps the `radius`-padded box around
|
||||
/// `(x, y)`. Always includes the oversized list. May contain a wire more
|
||||
/// than once is avoided by dedup.
|
||||
pub fn query(&self, x: f64, y: f64, radius: f64) -> Vec<u32> {
|
||||
let r = radius.max(0.0);
|
||||
let mut out = self.oversized.clone();
|
||||
// Query box fully outside the grid → only oversized candidates.
|
||||
if x + r < self.min[0]
|
||||
|| y + r < self.min[1]
|
||||
|| x - r > self.min[0] + self.cols as f64 * self.cell
|
||||
|| y - r > self.min[1] + self.rows as f64 * self.cell
|
||||
{
|
||||
return out;
|
||||
}
|
||||
let clampc = |v: f64| v.clamp(0.0, (self.cols - 1) as f64) as u32;
|
||||
let clampr = |v: f64| v.clamp(0.0, (self.rows - 1) as f64) as u32;
|
||||
let c0 = clampc(((x - r) - self.min[0]) / self.cell);
|
||||
let c1 = clampc(((x + r) - self.min[0]) / self.cell);
|
||||
let r0 = clampr(((y - r) - self.min[1]) / self.cell);
|
||||
let r1 = clampr(((y + r) - self.min[1]) / self.cell);
|
||||
for row in r0..=r1 {
|
||||
let base = (row * self.cols) as usize;
|
||||
for col in c0..=c1 {
|
||||
out.extend_from_slice(&self.cells[base + col as usize]);
|
||||
}
|
||||
}
|
||||
out.sort_unstable();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn wire(x: f32, y: f32) -> WireModel {
|
||||
let mut w = WireModel::solid_f64("t".into(), vec![[x as f64, y as f64, 0.0]], [0.0; 4], false);
|
||||
w.aabb = [x, y, x, y];
|
||||
w
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_is_local_and_hits_the_point() {
|
||||
// 50×50 lattice of point-wires spaced 10 apart (2500 wires).
|
||||
let mut wires = Vec::new();
|
||||
for gx in 0..50 {
|
||||
for gy in 0..50 {
|
||||
wires.push(wire(gx as f32 * 10.0, gy as f32 * 10.0));
|
||||
}
|
||||
}
|
||||
let n = wires.len();
|
||||
let grid = WireGrid::build(&wires);
|
||||
// Tight query around one lattice point.
|
||||
let hits = grid.query(250.0, 250.0, 5.0);
|
||||
assert!(hits.len() < n / 10, "query too broad: {} of {n}", hits.len());
|
||||
let idx = (25u32 * 50) + 25; // gx=25, gy=25
|
||||
assert!(hits.contains(&idx), "the on-point wire must be a candidate");
|
||||
// A far point must not drag in the whole lattice.
|
||||
let far = grid.query(-9999.0, -9999.0, 5.0);
|
||||
assert!(far.len() < n / 10, "far query too broad: {}", far.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbounded_wire_is_always_a_candidate() {
|
||||
let mut wires = vec![wire(0.0, 0.0), wire(1_000_000.0, 1_000_000.0)];
|
||||
wires[1].aabb = WireModel::UNBOUNDED_AABB;
|
||||
let grid = WireGrid::build(&wires);
|
||||
let hits = grid.query(0.0, 0.0, 1.0);
|
||||
assert!(hits.contains(&1), "unbounded wire must always be returned");
|
||||
}
|
||||
}
|
||||
+15
-7
@@ -883,13 +883,21 @@ impl Snapper {
|
||||
// instead compare pairs of in-range segments — O(k²) — which hangs when the
|
||||
// aperture spans the whole drawing. Count the in-range wires once and gate
|
||||
// only those pairwise passes; the single-wire snaps always run.
|
||||
const MAX_PAIRWISE_CANDIDATES: usize = 1_500;
|
||||
let allow_pairwise = wires
|
||||
.iter()
|
||||
.filter(|w| wire_in_range(w))
|
||||
.take(MAX_PAIRWISE_CANDIDATES + 1)
|
||||
.count()
|
||||
<= MAX_PAIRWISE_CANDIDATES;
|
||||
// Intersection / ApparentIntersection compare every in-range segment
|
||||
// against every other — O(total in-range segments²). Gate on the
|
||||
// in-range *point* (≈ segment) count, not the wire count: a handful of
|
||||
// curved wires, each hundreds of tessellated points, blows the
|
||||
// quadratic up even though the wire count looks modest. (A dense survey
|
||||
// cursor cell held ~1k wires / ~240k points → 15 s pre-gate.)
|
||||
const MAX_PAIRWISE_POINTS: usize = 3_000;
|
||||
let mut in_range_pts = 0usize;
|
||||
for w in wires.iter().filter(|w| wire_in_range(w)) {
|
||||
in_range_pts += w.points.len();
|
||||
if in_range_pts > MAX_PAIRWISE_POINTS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let allow_pairwise = in_range_pts <= MAX_PAIRWISE_POINTS;
|
||||
|
||||
let mut try_pt = |world: glam::DVec3, snap_type: SnapType| {
|
||||
let screen = world_to_screen(world, view_rot, eye, bounds);
|
||||
|
||||
Reference in new issue
Block a user