Skip to main content

brepkit_math/
bvh.rs

1//! Flat-array AABB tree for broad-phase spatial queries.
2//!
3//! Uses SAH (Surface Area Heuristic) for tree construction, consistent
4//! with the arena-based patterns used in the topology crate.
5
6use crate::aabb::Aabb3;
7use crate::vec::Point3;
8
9/// A node in the flat-array BVH.
10#[derive(Debug, Clone, Copy)]
11struct BvhNode {
12    /// Bounding box of this node.
13    aabb: Aabb3,
14    /// For leaf nodes: the index of the primitive. For internal nodes: `usize::MAX`.
15    primitive: usize,
16    /// Index of the left child. `usize::MAX` for leaf nodes.
17    left: usize,
18    /// Index of the right child. `usize::MAX` for leaf nodes.
19    right: usize,
20}
21
22/// A flat-array AABB tree for spatial queries.
23#[derive(Debug, Clone)]
24pub struct Bvh {
25    nodes: Vec<BvhNode>,
26}
27
28impl Bvh {
29    /// Build a BVH from a set of `(primitive_id, aabb)` pairs.
30    ///
31    /// Uses SAH-based construction for good query performance. If `aabbs`
32    /// is empty, returns an empty BVH.
33    #[must_use]
34    pub fn build(aabbs: &[(usize, Aabb3)]) -> Self {
35        if aabbs.is_empty() {
36            return Self { nodes: Vec::new() };
37        }
38
39        let mut indices: Vec<usize> = (0..aabbs.len()).collect();
40        let mut nodes = Vec::with_capacity(2 * aabbs.len());
41        build_recursive(aabbs, &mut indices, &mut nodes);
42        Self { nodes }
43    }
44
45    /// Query all primitives whose AABB overlaps `test`.
46    #[must_use]
47    pub fn query_overlap(&self, test: &Aabb3) -> Vec<usize> {
48        let mut results = Vec::new();
49        self.query_overlap_into(test, &mut results);
50        results
51    }
52
53    /// Query primitives overlapping `test`, reusing caller-provided buffers.
54    ///
55    /// `results` is cleared before use. This avoids per-call allocation when
56    /// called in a tight loop (e.g. compound boolean inner loops).
57    pub fn query_overlap_into(&self, test: &Aabb3, results: &mut Vec<usize>) {
58        results.clear();
59        if self.nodes.is_empty() {
60            return;
61        }
62        let mut stack = vec![0usize];
63        while let Some(idx) = stack.pop() {
64            let node = &self.nodes[idx];
65            if !node.aabb.intersects(*test) {
66                continue;
67            }
68            if node.primitive == usize::MAX {
69                stack.push(node.left);
70                stack.push(node.right);
71            } else {
72                results.push(node.primitive);
73            }
74        }
75    }
76
77    /// Query all primitives whose AABB is intersected by a ray.
78    ///
79    /// `origin` is the ray start, `dir` is the ray direction (need not be
80    /// normalised). Only positive-`t` hits are returned.
81    #[must_use]
82    pub fn query_ray(&self, origin: Point3, dir: crate::vec::Vec3) -> Vec<usize> {
83        let mut results = Vec::new();
84        self.query_ray_into(origin, dir, &mut results);
85        results
86    }
87
88    /// Query ray-intersecting primitives, reusing caller-provided buffers.
89    ///
90    /// `results` is cleared before use. This avoids per-call allocation when
91    /// called in a tight loop.
92    pub fn query_ray_into(&self, origin: Point3, dir: crate::vec::Vec3, results: &mut Vec<usize>) {
93        results.clear();
94        if self.nodes.is_empty() {
95            return;
96        }
97        // Pre-compute inverse direction for slab tests.
98        let inv_dir = crate::vec::Vec3::new(1.0 / dir.x(), 1.0 / dir.y(), 1.0 / dir.z());
99        let mut stack = vec![0usize];
100        while let Some(idx) = stack.pop() {
101            let node = &self.nodes[idx];
102            if !node.aabb.ray_intersects(origin, inv_dir) {
103                continue;
104            }
105            if node.primitive == usize::MAX {
106                stack.push(node.left);
107                stack.push(node.right);
108            } else {
109                results.push(node.primitive);
110            }
111        }
112    }
113
114    /// Find the primitive whose AABB is closest to `point`.
115    ///
116    /// **Note**: This returns the primitive with the closest *AABB*, which is
117    /// a lower bound on the actual distance. For exact closest-primitive
118    /// queries, use [`Bvh::query_closest_with_distance`] with a callback that
119    /// computes the true distance to each primitive.
120    ///
121    /// Returns `None` if the BVH is empty.
122    #[must_use]
123    pub fn query_closest(&self, point: Point3) -> Option<usize> {
124        if self.nodes.is_empty() {
125            return None;
126        }
127
128        let mut best_id = None;
129        let mut best_dist = f64::INFINITY;
130        let mut stack = vec![0usize];
131
132        while let Some(idx) = stack.pop() {
133            let node = &self.nodes[idx];
134            let node_dist = node.aabb.distance_squared_to_point(point);
135            if node_dist >= best_dist {
136                continue;
137            }
138            if node.primitive == usize::MAX {
139                // Visit closer child first for better pruning.
140                let dl = self.nodes[node.left].aabb.distance_squared_to_point(point);
141                let dr = self.nodes[node.right].aabb.distance_squared_to_point(point);
142                if dl < dr {
143                    stack.push(node.right);
144                    stack.push(node.left);
145                } else {
146                    stack.push(node.left);
147                    stack.push(node.right);
148                }
149            } else {
150                best_id = Some(node.primitive);
151                best_dist = node_dist;
152            }
153        }
154
155        best_id
156    }
157
158    /// Find the closest primitive to `point` using an exact distance callback.
159    ///
160    /// Unlike [`Bvh::query_closest`], this uses the provided `distance_sq` callback
161    /// to compute the actual squared distance from `point` to each candidate
162    /// primitive, ensuring the true closest primitive is returned. The BVH
163    /// AABB distances are used only for pruning, so distant subtrees are
164    /// skipped efficiently.
165    ///
166    /// Returns `None` if the BVH is empty.
167    #[must_use]
168    pub fn query_closest_with_distance(
169        &self,
170        point: Point3,
171        distance_sq: &dyn Fn(usize) -> f64,
172    ) -> Option<usize> {
173        if self.nodes.is_empty() {
174            return None;
175        }
176
177        let mut best_id = None;
178        let mut best_dist = f64::INFINITY;
179        let mut stack = vec![0usize];
180
181        while let Some(idx) = stack.pop() {
182            let node = &self.nodes[idx];
183            let node_dist = node.aabb.distance_squared_to_point(point);
184            if node_dist >= best_dist {
185                continue;
186            }
187            if node.primitive == usize::MAX {
188                let dl = self.nodes[node.left].aabb.distance_squared_to_point(point);
189                let dr = self.nodes[node.right].aabb.distance_squared_to_point(point);
190                if dl < dr {
191                    stack.push(node.right);
192                    stack.push(node.left);
193                } else {
194                    stack.push(node.left);
195                    stack.push(node.right);
196                }
197            } else {
198                let actual_dist = distance_sq(node.primitive);
199                if actual_dist < best_dist {
200                    best_dist = actual_dist;
201                    best_id = Some(node.primitive);
202                }
203            }
204        }
205
206        best_id
207    }
208}
209
210/// Recursively build the BVH, appending nodes to `nodes`.
211#[allow(clippy::expect_used, clippy::cast_precision_loss)]
212fn build_recursive(
213    aabbs: &[(usize, Aabb3)],
214    indices: &mut [usize],
215    nodes: &mut Vec<BvhNode>,
216) -> usize {
217    let node_idx = nodes.len();
218
219    if indices.len() == 1 {
220        let i = indices[0];
221        nodes.push(BvhNode {
222            aabb: aabbs[i].1,
223            primitive: aabbs[i].0,
224            left: usize::MAX,
225            right: usize::MAX,
226        });
227        return node_idx;
228    }
229
230    // Compute bounding box of all primitives in this subset.
231    let combined = indices
232        .iter()
233        .map(|&i| aabbs[i].1)
234        .reduce(super::aabb::Aabb3::union)
235        .expect("non-empty");
236
237    if indices.len() == 2 {
238        // Direct leaf pair.
239        nodes.push(BvhNode {
240            aabb: combined,
241            primitive: usize::MAX,
242            left: 0,
243            right: 0,
244        });
245        let left = build_recursive(aabbs, &mut indices[..1], nodes);
246        let right = build_recursive(aabbs, &mut indices[1..], nodes);
247        nodes[node_idx].left = left;
248        nodes[node_idx].right = right;
249        return node_idx;
250    }
251
252    // SAH split: try each axis and find the best split.
253    let parent_area = combined.surface_area();
254    let mut best_cost = f64::INFINITY;
255    let mut best_axis = 0;
256    let mut best_split = indices.len() / 2;
257
258    for axis in 0..3 {
259        indices.sort_by(|&a, &b| {
260            let ca = centroid_axis(aabbs[a].1, axis);
261            let cb = centroid_axis(aabbs[b].1, axis);
262            ca.partial_cmp(&cb).unwrap_or(std::cmp::Ordering::Equal)
263        });
264
265        let n = indices.len();
266
267        // Precompute suffix unions so each split candidate is O(1).
268        let mut suffix = vec![aabbs[indices[n - 1]].1; n];
269        for k in (0..n - 1).rev() {
270            suffix[k] = suffix[k + 1].union(aabbs[indices[k]].1);
271        }
272
273        let mut left_aabb = aabbs[indices[0]].1;
274        for split in 1..n {
275            left_aabb = left_aabb.union(aabbs[indices[split - 1]].1);
276            let right_aabb = suffix[split];
277
278            let cost = (split as f64).mul_add(
279                left_aabb.surface_area(),
280                (n - split) as f64 * right_aabb.surface_area(),
281            ) / parent_area;
282
283            if cost < best_cost {
284                best_cost = cost;
285                best_axis = axis;
286                best_split = split;
287            }
288        }
289    }
290
291    // Re-sort by the best axis.
292    indices.sort_by(|&a, &b| {
293        let ca = centroid_axis(aabbs[a].1, best_axis);
294        let cb = centroid_axis(aabbs[b].1, best_axis);
295        ca.partial_cmp(&cb).unwrap_or(std::cmp::Ordering::Equal)
296    });
297
298    // Allocate internal node.
299    nodes.push(BvhNode {
300        aabb: combined,
301        primitive: usize::MAX,
302        left: 0,
303        right: 0,
304    });
305
306    let (left_indices, right_indices) = indices.split_at_mut(best_split);
307    let left = build_recursive(aabbs, left_indices, nodes);
308    let right = build_recursive(aabbs, right_indices, nodes);
309    nodes[node_idx].left = left;
310    nodes[node_idx].right = right;
311
312    node_idx
313}
314
315/// Get the centroid coordinate along a specific axis.
316fn centroid_axis(aabb: Aabb3, axis: usize) -> f64 {
317    match axis {
318        0 => aabb.center().x(),
319        1 => aabb.center().y(),
320        _ => aabb.center().z(),
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    fn make_box(id: usize, x: f64, y: f64, z: f64, size: f64) -> (usize, Aabb3) {
329        (
330            id,
331            Aabb3::from_points([
332                Point3::new(x, y, z),
333                Point3::new(x + size, y + size, z + size),
334            ]),
335        )
336    }
337
338    #[test]
339    fn bvh_empty() {
340        let bvh = Bvh::build(&[]);
341        assert!(
342            bvh.query_overlap(&Aabb3::from_points([Point3::new(0.0, 0.0, 0.0)]))
343                .is_empty()
344        );
345        assert!(bvh.query_closest(Point3::new(0.0, 0.0, 0.0)).is_none());
346    }
347
348    #[test]
349    fn bvh_single() {
350        let aabbs = vec![make_box(42, 0.0, 0.0, 0.0, 1.0)];
351        let bvh = Bvh::build(&aabbs);
352
353        let hits = bvh.query_overlap(&Aabb3::from_points([
354            Point3::new(0.5, 0.5, 0.5),
355            Point3::new(0.6, 0.6, 0.6),
356        ]));
357        assert_eq!(hits, vec![42]);
358
359        let miss = bvh.query_overlap(&Aabb3::from_points([
360            Point3::new(5.0, 5.0, 5.0),
361            Point3::new(6.0, 6.0, 6.0),
362        ]));
363        assert!(miss.is_empty());
364    }
365
366    #[test]
367    fn bvh_multiple_overlap() {
368        let aabbs = vec![
369            make_box(0, 0.0, 0.0, 0.0, 1.0),
370            make_box(1, 2.0, 0.0, 0.0, 1.0),
371            make_box(2, 4.0, 0.0, 0.0, 1.0),
372            make_box(3, 0.0, 2.0, 0.0, 1.0),
373        ];
374        let bvh = Bvh::build(&aabbs);
375
376        // Query that overlaps box 0 and 1
377        let test = Aabb3::from_points([Point3::new(0.5, 0.5, 0.5), Point3::new(2.5, 0.5, 0.5)]);
378        let mut hits = bvh.query_overlap(&test);
379        hits.sort_unstable();
380        assert_eq!(hits, vec![0, 1]);
381    }
382
383    #[test]
384    fn bvh_closest() {
385        let aabbs = vec![
386            make_box(0, 0.0, 0.0, 0.0, 1.0),
387            make_box(1, 10.0, 10.0, 10.0, 1.0),
388            make_box(2, 5.0, 5.0, 5.0, 1.0),
389        ];
390        let bvh = Bvh::build(&aabbs);
391
392        assert_eq!(bvh.query_closest(Point3::new(0.5, 0.5, 0.5)), Some(0));
393        assert_eq!(bvh.query_closest(Point3::new(10.5, 10.5, 10.5)), Some(1));
394    }
395
396    #[test]
397    #[allow(clippy::cast_precision_loss)]
398    fn bvh_many_primitives() {
399        let aabbs: Vec<(usize, Aabb3)> = (0..100)
400            .map(|i| make_box(i, i as f64 * 2.0, 0.0, 0.0, 1.0))
401            .collect();
402        let bvh = Bvh::build(&aabbs);
403
404        // Query around primitive 50
405        let test = Aabb3::from_points([Point3::new(99.5, 0.0, 0.0), Point3::new(100.5, 1.0, 1.0)]);
406        let hits = bvh.query_overlap(&test);
407        assert!(
408            hits.contains(&50),
409            "expected primitive 50 in hits: {hits:?}"
410        );
411    }
412}