diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 98b2f4aca..c13518413 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -731,7 +731,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { { 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 { { 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 { { 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 { { 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 diff --git a/src/scene/convert/tess.rs b/src/scene/convert/tess.rs index bb0912ae3..37233c0df 100644 --- a/src/scene/convert/tess.rs +++ b/src/scene/convert/tess.rs @@ -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] diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 1739b2ce3..367c9295d 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -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>)>>, + /// 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)>>, /// 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>, + ) -> Arc { + { + 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> { + 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)] diff --git a/src/scene/pick/mod.rs b/src/scene/pick/mod.rs index 419a60c57..d30c14b70 100644 --- a/src/scene/pick/mod.rs +++ b/src/scene/pick/mod.rs @@ -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; diff --git a/src/scene/pick/wire_grid.rs b/src/scene/pick/wire_grid.rs new file mode 100644 index 000000000..cebdbc1c0 --- /dev/null +++ b/src/scene/pick/wire_grid.rs @@ -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>, + /// Indices always returned as candidates (unbounded / huge AABB). + oversized: Vec, +} + +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::::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 { + 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"); + } +} diff --git a/src/snap.rs b/src/snap.rs index 0fe6d1484..69bd3580f 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -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);