feat(scene): quadtree primitive for 2D spatial index (Phase 2.1, step 1)

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 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-05-17 14:40:11 +03:00
commit 727992bc84
2 changed files with 355 additions and 0 deletions

View file

@ -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;

354
src/scene/quadtree.rs Normal file
View file

@ -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<Handle>` — 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<u32>` 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<Item>,
/// 4 children: NW, NE, SW, SE. `None` until the node splits.
children: Option<[u32; 4]>,
depth: u8,
}
#[derive(Debug)]
pub struct QuadTree {
nodes: Vec<Node>,
/// `handle → (node_idx, item_idx_within_node)` so removal/update
/// is O(1) without walking the tree.
locator: std::collections::HashMap<Handle, (u32, u32)>,
/// Items whose AABB falls outside the root bounds. Surfaced on
/// every query — small set in practice (typically empty for
/// well-bounded drawings).
overflow: Vec<Item>,
}
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<Handle> {
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<u32> = 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<Handle>) {
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)));
}
}