fix(quadtree): split() on internal node was orphaning the entire subtree

Symptom: open a non-UTM drawing, most lines/polylines/hatches/etc.
vanish — only ~5% of indexed entities reachable from a huge query
(walking the nodes flat showed all 2975 items present, but recursing
through `node.children` from root reached only 161).

Root cause: when straddler items piled up at an INTERNAL node and the
count exceeded LEAF_CAPACITY, `insert()` re-invoked `split()` on that
node. `split()` allocates four fresh children and assigns them to
`node.children = Some([...])` — overwriting the existing children
pointer and orphaning the entire previous subtree (the descendant
nodes still hold their items; nothing reaches them).

Splitting an internal node is futile anyway: straddlers don't fit
smaller children either. Fix: gate the split on
`children.is_none()` so only leaves split. Internal nodes accumulate
straddlers as a linear-scan list — slower per-query for big strad-
dler counts, but correct.

Adds a regression test (`many_straddlers_dont_orphan_subtree`) that
floods an internal node with straddlers and verifies a huge query
still returns every inserted handle.

Bonus: `examples/dump_index_stats.rs` — standalone tool that loads a
DWG/DXF and dumps per-type counts (indexed / unbounded / top-level /
block-internal) plus a sample of bboxes. Useful for future bug
triage; built and ran cleanly against the file that exposed this.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.7 committed 2026-05-17 19:28:57 +03:00
1 parent b4745efb24
commit 416b0d4dc3
2 files changed
+201 -1

No files matched your search

+30 -1
View File
@@ -99,9 +99,14 @@ impl QuadTree {
.push(Item { handle, aabb });
self.locator.insert(handle, (node_idx, item_idx));
// Split if the leaf overflowed and we still have depth budget.
// Split only LEAF nodes. Internal nodes accumulate straddlers
// (items whose AABB crosses a child boundary); calling split on
// an internal node would overwrite its `children` pointer and
// orphan the entire existing subtree. Straddlers can't fit
// smaller children anyway, so splitting is futile here.
if self.nodes[node_idx as usize].items.len() > LEAF_CAPACITY
&& self.nodes[node_idx as usize].depth < MAX_DEPTH
&& self.nodes[node_idx as usize].children.is_none()
{
self.split(node_idx);
}
@@ -336,6 +341,30 @@ mod tests {
assert_eq!(far, vec![h(1)]);
}
#[test]
fn many_straddlers_dont_orphan_subtree() {
// Regression: previously, when straddlers piled up at an
// internal node and exceeded LEAF_CAPACITY, split() was called
// again — overwriting the node's `children` and orphaning every
// descendant. Symptom: thousands of items inserted, only ~5%
// reachable from root via a huge_query.
let mut t = QuadTree::new([0.0, 0.0, 100.0, 100.0]);
// First, fill one leaf so it splits.
for i in 0..40u64 {
t.insert(h(i), [1.0, 1.0, 2.0, 2.0]);
}
// Now flood the root with straddlers (AABBs that cross center).
for i in 100..200u64 {
t.insert(h(i), [10.0, 10.0, 90.0, 90.0]);
}
// After the regression-trigger inserts, every handle must still
// come back from a huge query.
let huge = [-1e9, -1e9, 1e9, 1e9];
let hits = t.query_rect(huge);
let expected = 40 + 100;
assert_eq!(hits.len(), expected, "lost items: tree.len={} hits.len={}", t.len(), hits.len());
}
#[test]
fn straddler_stays_at_parent() {
let mut t = QuadTree::new([0.0, 0.0, 100.0, 100.0]);