Skip to main content

oxigdal_noalloc/
bbox3d.rs

1//! 3D axis-aligned bounding box for `no_std`, `no_alloc` environments.
2//!
3//! [`BBox3D`] extends the 2D bounding box concept with a Z dimension,
4//! suitable for volumetric spatial queries and point cloud bounds.
5
6use crate::{BBox2D, Point3D, f64_max, f64_min};
7
8/// A 3-dimensional axis-aligned bounding box.
9#[derive(Debug, Clone, Copy, PartialEq)]
10pub struct BBox3D {
11    /// Minimum X.
12    pub min_x: f64,
13    /// Minimum Y.
14    pub min_y: f64,
15    /// Minimum Z.
16    pub min_z: f64,
17    /// Maximum X.
18    pub max_x: f64,
19    /// Maximum Y.
20    pub max_y: f64,
21    /// Maximum Z.
22    pub max_z: f64,
23}
24
25impl BBox3D {
26    /// Creates a new `BBox3D`.
27    #[must_use]
28    #[inline]
29    pub const fn new(
30        min_x: f64,
31        min_y: f64,
32        min_z: f64,
33        max_x: f64,
34        max_y: f64,
35        max_z: f64,
36    ) -> Self {
37        Self {
38            min_x,
39            min_y,
40            min_z,
41            max_x,
42            max_y,
43            max_z,
44        }
45    }
46
47    /// Returns `true` if the bounding box is geometrically valid (min <= max on all axes).
48    #[must_use]
49    #[inline]
50    pub fn is_valid(&self) -> bool {
51        self.min_x <= self.max_x && self.min_y <= self.max_y && self.min_z <= self.max_z
52    }
53
54    /// Returns `true` if the 3D point lies inside (or on the boundary of) this box.
55    #[must_use]
56    #[inline]
57    pub fn contains_point(&self, p: &Point3D) -> bool {
58        p.x >= self.min_x
59            && p.x <= self.max_x
60            && p.y >= self.min_y
61            && p.y <= self.max_y
62            && p.z >= self.min_z
63            && p.z <= self.max_z
64    }
65
66    /// Returns `true` if this bounding box overlaps with another in all three dimensions.
67    #[must_use]
68    #[inline]
69    pub fn intersects(&self, other: &BBox3D) -> bool {
70        self.min_x <= other.max_x
71            && self.max_x >= other.min_x
72            && self.min_y <= other.max_y
73            && self.max_y >= other.min_y
74            && self.min_z <= other.max_z
75            && self.max_z >= other.min_z
76    }
77
78    /// Computes the smallest bounding box containing both boxes.
79    #[must_use]
80    #[inline]
81    pub fn union(&self, other: &BBox3D) -> BBox3D {
82        BBox3D::new(
83            f64_min(self.min_x, other.min_x),
84            f64_min(self.min_y, other.min_y),
85            f64_min(self.min_z, other.min_z),
86            f64_max(self.max_x, other.max_x),
87            f64_max(self.max_y, other.max_y),
88            f64_max(self.max_z, other.max_z),
89        )
90    }
91
92    /// Computes the volume of the bounding box.
93    ///
94    /// Returns `0.0` if any dimension has negative extent.
95    #[must_use]
96    #[inline]
97    pub fn volume(&self) -> f64 {
98        let w = self.max_x - self.min_x;
99        let h = self.max_y - self.min_y;
100        let d = self.max_z - self.min_z;
101        if w < 0.0 || h < 0.0 || d < 0.0 {
102            0.0
103        } else {
104            w * h * d
105        }
106    }
107
108    /// Returns the center point of the bounding box.
109    #[must_use]
110    #[inline]
111    pub fn center(&self) -> Point3D {
112        Point3D::new(
113            (self.min_x + self.max_x) * 0.5,
114            (self.min_y + self.max_y) * 0.5,
115            (self.min_z + self.max_z) * 0.5,
116        )
117    }
118
119    /// Returns an expanded bounding box — each face is pushed outward by `amount`.
120    ///
121    /// If `amount` is negative, the box shrinks (but is not validated).
122    #[must_use]
123    #[inline]
124    pub fn expand(&self, amount: f64) -> BBox3D {
125        BBox3D::new(
126            self.min_x - amount,
127            self.min_y - amount,
128            self.min_z - amount,
129            self.max_x + amount,
130            self.max_y + amount,
131            self.max_z + amount,
132        )
133    }
134
135    /// Returns the surface area of the bounding box (sum of all 6 face areas).
136    ///
137    /// Returns `0.0` if any dimension has negative extent.
138    #[must_use]
139    #[inline]
140    pub fn surface_area(&self) -> f64 {
141        let w = self.max_x - self.min_x;
142        let h = self.max_y - self.min_y;
143        let d = self.max_z - self.min_z;
144        if w < 0.0 || h < 0.0 || d < 0.0 {
145            0.0
146        } else {
147            2.0 * (w * h + h * d + w * d)
148        }
149    }
150
151    /// Projects the 3D bounding box to a 2D bounding box by dropping Z.
152    #[must_use]
153    #[inline]
154    pub fn to_2d(&self) -> BBox2D {
155        BBox2D::new(self.min_x, self.min_y, self.max_x, self.max_y)
156    }
157
158    /// Computes the diagonal length of the bounding box.
159    #[must_use]
160    pub fn diagonal(&self) -> f64 {
161        let dx = self.max_x - self.min_x;
162        let dy = self.max_y - self.min_y;
163        let dz = self.max_z - self.min_z;
164        crate::libm_sqrt(dx * dx + dy * dy + dz * dz)
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn test_new_and_valid() {
174        let bbox = BBox3D::new(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
175        assert!(bbox.is_valid());
176    }
177
178    #[test]
179    fn test_invalid() {
180        let bbox = BBox3D::new(1.0, 0.0, 0.0, 0.0, 1.0, 1.0);
181        assert!(!bbox.is_valid());
182    }
183
184    #[test]
185    fn test_contains_point_inside() {
186        let bbox = BBox3D::new(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
187        assert!(bbox.contains_point(&Point3D::new(5.0, 5.0, 5.0)));
188    }
189
190    #[test]
191    fn test_contains_point_on_boundary() {
192        let bbox = BBox3D::new(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
193        assert!(bbox.contains_point(&Point3D::new(0.0, 0.0, 0.0)));
194        assert!(bbox.contains_point(&Point3D::new(10.0, 10.0, 10.0)));
195    }
196
197    #[test]
198    fn test_contains_point_outside() {
199        let bbox = BBox3D::new(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
200        assert!(!bbox.contains_point(&Point3D::new(-1.0, 5.0, 5.0)));
201        assert!(!bbox.contains_point(&Point3D::new(5.0, 11.0, 5.0)));
202        assert!(!bbox.contains_point(&Point3D::new(5.0, 5.0, -0.1)));
203    }
204
205    #[test]
206    fn test_intersects() {
207        let a = BBox3D::new(0.0, 0.0, 0.0, 5.0, 5.0, 5.0);
208        let b = BBox3D::new(3.0, 3.0, 3.0, 8.0, 8.0, 8.0);
209        assert!(a.intersects(&b));
210        assert!(b.intersects(&a));
211    }
212
213    #[test]
214    fn test_no_intersect() {
215        let a = BBox3D::new(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
216        let b = BBox3D::new(2.0, 2.0, 2.0, 3.0, 3.0, 3.0);
217        assert!(!a.intersects(&b));
218    }
219
220    #[test]
221    fn test_union() {
222        let a = BBox3D::new(0.0, 0.0, 0.0, 5.0, 5.0, 5.0);
223        let b = BBox3D::new(3.0, -1.0, 2.0, 8.0, 4.0, 10.0);
224        let u = a.union(&b);
225        assert!((u.min_x - 0.0).abs() < f64::EPSILON);
226        assert!((u.min_y - (-1.0)).abs() < f64::EPSILON);
227        assert!((u.min_z - 0.0).abs() < f64::EPSILON);
228        assert!((u.max_x - 8.0).abs() < f64::EPSILON);
229        assert!((u.max_y - 5.0).abs() < f64::EPSILON);
230        assert!((u.max_z - 10.0).abs() < f64::EPSILON);
231    }
232
233    #[test]
234    fn test_volume() {
235        let bbox = BBox3D::new(0.0, 0.0, 0.0, 2.0, 3.0, 4.0);
236        assert!((bbox.volume() - 24.0).abs() < f64::EPSILON);
237    }
238
239    #[test]
240    fn test_volume_zero() {
241        let bbox = BBox3D::new(0.0, 0.0, 0.0, 0.0, 3.0, 4.0);
242        assert!((bbox.volume() - 0.0).abs() < f64::EPSILON);
243    }
244
245    #[test]
246    fn test_volume_invalid() {
247        let bbox = BBox3D::new(1.0, 0.0, 0.0, 0.0, 1.0, 1.0);
248        assert!((bbox.volume() - 0.0).abs() < f64::EPSILON);
249    }
250
251    #[test]
252    fn test_center() {
253        let bbox = BBox3D::new(0.0, 2.0, 4.0, 10.0, 8.0, 12.0);
254        let c = bbox.center();
255        assert!((c.x - 5.0).abs() < f64::EPSILON);
256        assert!((c.y - 5.0).abs() < f64::EPSILON);
257        assert!((c.z - 8.0).abs() < f64::EPSILON);
258    }
259
260    #[test]
261    fn test_expand() {
262        let bbox = BBox3D::new(1.0, 1.0, 1.0, 3.0, 3.0, 3.0);
263        let expanded = bbox.expand(0.5);
264        assert!((expanded.min_x - 0.5).abs() < f64::EPSILON);
265        assert!((expanded.max_z - 3.5).abs() < f64::EPSILON);
266        assert!((expanded.volume() - 27.0).abs() < 1e-10);
267    }
268
269    #[test]
270    fn test_surface_area() {
271        let bbox = BBox3D::new(0.0, 0.0, 0.0, 2.0, 3.0, 4.0);
272        // 2*(2*3 + 3*4 + 2*4) = 2*(6+12+8) = 52
273        assert!((bbox.surface_area() - 52.0).abs() < f64::EPSILON);
274    }
275
276    #[test]
277    fn test_to_2d() {
278        let bbox3 = BBox3D::new(1.0, 2.0, 3.0, 4.0, 5.0, 6.0);
279        let bbox2 = bbox3.to_2d();
280        assert!((bbox2.min_x - 1.0).abs() < f64::EPSILON);
281        assert!((bbox2.min_y - 2.0).abs() < f64::EPSILON);
282        assert!((bbox2.max_x - 4.0).abs() < f64::EPSILON);
283        assert!((bbox2.max_y - 5.0).abs() < f64::EPSILON);
284    }
285
286    #[test]
287    fn test_diagonal() {
288        let bbox = BBox3D::new(0.0, 0.0, 0.0, 3.0, 4.0, 0.0);
289        // sqrt(9+16+0) = 5
290        assert!((bbox.diagonal() - 5.0).abs() < 1e-10);
291    }
292
293    #[test]
294    fn test_diagonal_cube() {
295        let bbox = BBox3D::new(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
296        // sqrt(3) ≈ 1.7320508
297        assert!((bbox.diagonal() - 1.732_050_808_068_872_4).abs() < 1e-8);
298    }
299
300    #[test]
301    fn test_intersects_touching() {
302        let a = BBox3D::new(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
303        let b = BBox3D::new(1.0, 1.0, 1.0, 2.0, 2.0, 2.0);
304        assert!(a.intersects(&b)); // touching at corner counts as intersecting
305    }
306}