From 727992bc84daa98409d3153539b98241ecf98723 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 17 May 2026 14:40:11 +0300 Subject: [PATCH] feat(scene): quadtree primitive for 2D spatial index (Phase 2.1, step 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone data structure — no consumers yet. Flat-vector node storage, leaf capacity 32 / max depth 16, O(1) remove/update via handle locator map, out-of-root items kept in an overflow list that surfaces on every query. Foundation for replacing the O(N) entity scan in the wire tessellation path (scene/mod.rs:1232) with a query_rect call. Co-Authored-By: Claude Opus 4.7 --- src/scene/mod.rs | 1 + src/scene/quadtree.rs | 354 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 355 insertions(+) create mode 100644 src/scene/quadtree.rs diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 69aa2994c..25b190719 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -14,6 +14,7 @@ pub mod object; pub mod paper_canvas; pub mod pipeline; pub mod properties; +pub mod quadtree; mod render; mod selection; pub mod solid3d_tess; diff --git a/src/scene/quadtree.rs b/src/scene/quadtree.rs new file mode 100644 index 000000000..c0be210f0 --- /dev/null +++ b/src/scene/quadtree.rs @@ -0,0 +1,354 @@ +// Spatial index for 2D entities. Each leaf holds up to `LEAF_CAPACITY` +// items; on overflow the node splits into 4 children and re-distributes. +// +// Items are keyed by `acadrust::Handle`. AABBs are stored in WCS f64 +// (NOT world_offset-subtracted) so changing `world_offset` doesn't +// invalidate the index. +// +// API: +// - `QuadTree::new(world_bounds)` — build an empty tree spanning a +// fixed root rect. Items outside the root are clamped: they go into +// the root's overflow list and surface on every query. +// - `insert(handle, aabb)`, `remove(handle)`, `update(handle, aabb)` +// - `query_rect(aabb) -> Vec` — returns every handle whose +// stored AABB intersects `aabb`, plus all overflow handles. +// - `len()` / `is_empty()` for sanity. +// +// This is a flat-vector (Vec-of-nodes) implementation: node indices +// are `usize`, child links are `Option` to keep nodes compact and +// cache-friendly. No rebalancing — once split, nodes don't merge back +// (rare in CAD workflows where most edits are local and few entities +// move long distances). + +use acadrust::Handle; + +pub type Aabb = [f64; 4]; // [xmin, ymin, xmax, ymax] + +/// Items per leaf before it splits. Tuned: too small → deep tree, lots +/// of nodes; too large → poor culling per leaf. 32 is a typical sweet +/// spot for CAD-style content (many small entities in clusters). +const LEAF_CAPACITY: usize = 32; + +/// Hard cap on tree depth — prevents pathological recursion when many +/// items pile up at the same point. +const MAX_DEPTH: u8 = 16; + +#[derive(Debug, Clone, Copy)] +struct Item { + handle: Handle, + aabb: Aabb, +} + +#[derive(Debug)] +struct Node { + bounds: Aabb, + /// Items stored at this node. Only populated when the node is a + /// leaf, OR when a child split would put a straddling item at the + /// parent level (straddlers stay at the current node — they don't + /// fit cleanly into one quadrant). + items: Vec, + /// 4 children: NW, NE, SW, SE. `None` until the node splits. + children: Option<[u32; 4]>, + depth: u8, +} + +#[derive(Debug)] +pub struct QuadTree { + nodes: Vec, + /// `handle → (node_idx, item_idx_within_node)` so removal/update + /// is O(1) without walking the tree. + locator: std::collections::HashMap, + /// Items whose AABB falls outside the root bounds. Surfaced on + /// every query — small set in practice (typically empty for + /// well-bounded drawings). + overflow: Vec, +} + +impl QuadTree { + pub fn new(world_bounds: Aabb) -> Self { + let root = Node { + bounds: world_bounds, + items: Vec::new(), + children: None, + depth: 0, + }; + Self { + nodes: vec![root], + locator: std::collections::HashMap::new(), + overflow: Vec::new(), + } + } + + pub fn len(&self) -> usize { + self.locator.len() + self.overflow.len() + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + pub fn insert(&mut self, handle: Handle, aabb: Aabb) { + if !aabb_contained(self.nodes[0].bounds, aabb) { + self.overflow.push(Item { handle, aabb }); + return; + } + let node_idx = self.descend_to_leaf(0, aabb); + let item_idx = self.nodes[node_idx as usize].items.len() as u32; + self.nodes[node_idx as usize] + .items + .push(Item { handle, aabb }); + self.locator.insert(handle, (node_idx, item_idx)); + + // Split if the leaf overflowed and we still have depth budget. + if self.nodes[node_idx as usize].items.len() > LEAF_CAPACITY + && self.nodes[node_idx as usize].depth < MAX_DEPTH + { + self.split(node_idx); + } + } + + pub fn remove(&mut self, handle: Handle) -> bool { + if let Some((node_idx, item_idx)) = self.locator.remove(&handle) { + let node = &mut self.nodes[node_idx as usize]; + // swap_remove: re-locate whoever took the moved item's slot. + node.items.swap_remove(item_idx as usize); + if let Some(moved) = node.items.get(item_idx as usize) { + self.locator.insert(moved.handle, (node_idx, item_idx)); + } + return true; + } + // Try overflow. + if let Some(pos) = self.overflow.iter().position(|i| i.handle == handle) { + self.overflow.swap_remove(pos); + return true; + } + false + } + + pub fn update(&mut self, handle: Handle, aabb: Aabb) { + self.remove(handle); + self.insert(handle, aabb); + } + + pub fn query_rect(&self, query: Aabb) -> Vec { + let mut out = Vec::new(); + // Overflow always surfaces — these items have no usable spatial + // info (out-of-root) so we can't cull them spatially. + for i in &self.overflow { + if aabb_intersects(i.aabb, query) { + out.push(i.handle); + } + } + self.query_node(0, query, &mut out); + out + } + + // ── internal helpers ──────────────────────────────────────────── + + fn descend_to_leaf(&self, mut node_idx: u32, aabb: Aabb) -> u32 { + loop { + let node = &self.nodes[node_idx as usize]; + let Some(children) = node.children else { + return node_idx; + }; + // Pick the child that fully contains `aabb`; if `aabb` + // straddles a split boundary, stash it at the current node. + let mut fits_child: Option = None; + for &c in &children { + if aabb_contained(self.nodes[c as usize].bounds, aabb) { + fits_child = Some(c); + break; + } + } + match fits_child { + Some(c) => node_idx = c, + None => return node_idx, + } + } + } + + fn split(&mut self, node_idx: u32) { + let bounds = self.nodes[node_idx as usize].bounds; + let depth = self.nodes[node_idx as usize].depth + 1; + let [xmin, ymin, xmax, ymax] = bounds; + let mx = (xmin + xmax) * 0.5; + let my = (ymin + ymax) * 0.5; + // NW, NE, SW, SE + let child_bounds = [ + [xmin, my, mx, ymax], + [mx, my, xmax, ymax], + [xmin, ymin, mx, my], + [mx, ymin, xmax, my], + ]; + let first_child = self.nodes.len() as u32; + for &b in &child_bounds { + self.nodes.push(Node { + bounds: b, + items: Vec::new(), + children: None, + depth, + }); + } + let children = [ + first_child, + first_child + 1, + first_child + 2, + first_child + 3, + ]; + self.nodes[node_idx as usize].children = Some(children); + + // Redistribute existing items. Straddlers stay at the parent. + let existing = std::mem::take(&mut self.nodes[node_idx as usize].items); + let mut keep_at_parent = Vec::new(); + for item in existing { + let mut placed = false; + for &c in &children { + if aabb_contained(self.nodes[c as usize].bounds, item.aabb) { + let idx = self.nodes[c as usize].items.len() as u32; + self.nodes[c as usize].items.push(item); + self.locator.insert(item.handle, (c, idx)); + placed = true; + break; + } + } + if !placed { + let idx = keep_at_parent.len() as u32; + self.locator.insert(item.handle, (node_idx, idx)); + keep_at_parent.push(item); + } + } + self.nodes[node_idx as usize].items = keep_at_parent; + } + + fn query_node(&self, node_idx: u32, query: Aabb, out: &mut Vec) { + let node = &self.nodes[node_idx as usize]; + if !aabb_intersects(node.bounds, query) { + return; + } + for i in &node.items { + if aabb_intersects(i.aabb, query) { + out.push(i.handle); + } + } + if let Some(children) = node.children { + for c in children { + self.query_node(c, query, out); + } + } + } +} + +// ── AABB helpers ──────────────────────────────────────────────────── + +fn aabb_contained(outer: Aabb, inner: Aabb) -> bool { + inner[0] >= outer[0] + && inner[1] >= outer[1] + && inner[2] <= outer[2] + && inner[3] <= outer[3] +} + +fn aabb_intersects(a: Aabb, b: Aabb) -> bool { + a[0] <= b[2] && a[2] >= b[0] && a[1] <= b[3] && a[3] >= b[1] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn h(v: u64) -> Handle { + Handle::from(v) + } + + #[test] + fn empty_tree_returns_nothing() { + let t = QuadTree::new([0.0, 0.0, 100.0, 100.0]); + assert!(t.is_empty()); + assert!(t.query_rect([0.0, 0.0, 100.0, 100.0]).is_empty()); + } + + #[test] + fn insert_and_query_hit() { + let mut t = QuadTree::new([0.0, 0.0, 100.0, 100.0]); + t.insert(h(1), [10.0, 10.0, 20.0, 20.0]); + let hits = t.query_rect([0.0, 0.0, 50.0, 50.0]); + assert_eq!(hits, vec![h(1)]); + } + + #[test] + fn query_miss() { + let mut t = QuadTree::new([0.0, 0.0, 100.0, 100.0]); + t.insert(h(1), [10.0, 10.0, 20.0, 20.0]); + let hits = t.query_rect([50.0, 50.0, 90.0, 90.0]); + assert!(hits.is_empty()); + } + + #[test] + fn split_on_overflow() { + let mut t = QuadTree::new([0.0, 0.0, 100.0, 100.0]); + // Push beyond LEAF_CAPACITY (=32) into different quadrants + // so the split actually distributes them. + for i in 0..40u64 { + let x = (i as f64) * 0.5; + let y = (i as f64) * 0.5; + t.insert(h(i), [x, y, x + 0.1, y + 0.1]); + } + assert_eq!(t.len(), 40); + // The root should have split — there are children now. + assert!(t.nodes.len() > 1); + // All items recoverable. + let hits = t.query_rect([0.0, 0.0, 100.0, 100.0]); + assert_eq!(hits.len(), 40); + } + + #[test] + fn remove_works() { + let mut t = QuadTree::new([0.0, 0.0, 100.0, 100.0]); + t.insert(h(1), [10.0, 10.0, 20.0, 20.0]); + t.insert(h(2), [30.0, 30.0, 40.0, 40.0]); + assert!(t.remove(h(1))); + assert!(!t.remove(h(1))); // already gone + let hits = t.query_rect([0.0, 0.0, 100.0, 100.0]); + assert_eq!(hits, vec![h(2)]); + } + + #[test] + fn update_moves_item() { + let mut t = QuadTree::new([0.0, 0.0, 100.0, 100.0]); + t.insert(h(1), [10.0, 10.0, 20.0, 20.0]); + t.update(h(1), [70.0, 70.0, 80.0, 80.0]); + let lo = t.query_rect([0.0, 0.0, 50.0, 50.0]); + let hi = t.query_rect([60.0, 60.0, 90.0, 90.0]); + assert!(lo.is_empty()); + assert_eq!(hi, vec![h(1)]); + } + + #[test] + fn out_of_bounds_goes_to_overflow() { + let mut t = QuadTree::new([0.0, 0.0, 100.0, 100.0]); + // Way outside root. + t.insert(h(1), [1000.0, 1000.0, 1010.0, 1010.0]); + // Query a region that doesn't touch the overflow item — but + // overflow always surfaces only if its AABB intersects. + let near = t.query_rect([0.0, 0.0, 50.0, 50.0]); + assert!(near.is_empty()); + // Query that does intersect the overflow item. + let far = t.query_rect([900.0, 900.0, 1100.0, 1100.0]); + assert_eq!(far, vec![h(1)]); + } + + #[test] + fn straddler_stays_at_parent() { + let mut t = QuadTree::new([0.0, 0.0, 100.0, 100.0]); + // Force a split via 40 small items in NW quadrant. + for i in 0..40u64 { + t.insert(h(i + 100), [1.0, 1.0, 2.0, 2.0]); + } + // A straddler crossing center. + t.insert(h(1), [40.0, 40.0, 60.0, 60.0]); + // Querying any quadrant should still find the straddler. + let nw_hits = t.query_rect([0.0, 50.0, 50.0, 100.0]); + assert!(nw_hits.contains(&h(1))); + let se_hits = t.query_rect([50.0, 0.0, 100.0, 50.0]); + assert!(se_hits.contains(&h(1))); + } +}