Skip to main content

brepkit_math/
obb.rs

1//! Oriented bounding box (OBB) for tighter spatial filtering.
2//!
3//! OBBs fit rotated and curved geometry more tightly than axis-aligned boxes.
4//! Used as a secondary filter after BVH broad-phase queries to reject false
5//! positives before expensive narrow-phase intersection tests.
6
7use crate::vec::{Point3, Vec3};
8
9/// A 3D oriented bounding box.
10///
11/// Stores a center, three orthonormal axes, and half-extents along each axis.
12#[derive(Debug, Clone, Copy)]
13pub struct Obb3 {
14    /// Center of the box.
15    pub center: Point3,
16    /// Three orthonormal axes (columns of the rotation matrix).
17    pub axes: [Vec3; 3],
18    /// Half-extents along each axis.
19    pub half_extents: [f64; 3],
20}
21
22impl Obb3 {
23    /// Build an OBB from a point set using PCA (principal component analysis).
24    ///
25    /// Computes the covariance matrix of the points, extracts eigenvectors as
26    /// the OBB axes, then projects all points to find the extents.
27    ///
28    /// Uses canonical axes for degenerate point sets (collinear or coincident).
29    ///
30    /// # Panics
31    ///
32    /// Panics if the iterator yields fewer than 1 point.
33    #[must_use]
34    #[allow(clippy::missing_panics_doc)]
35    pub fn from_points(points: impl IntoIterator<Item = Point3>) -> Self {
36        let pts: Vec<Point3> = points.into_iter().collect();
37        Self::from_points_slice(&pts)
38    }
39
40    /// Build an OBB from a slice of points using PCA (principal component analysis).
41    ///
42    /// Same as [`from_points`](Self::from_points) but avoids an allocation when the
43    /// caller already has a slice.
44    ///
45    /// # Panics
46    ///
47    /// Panics if the slice is empty.
48    #[must_use]
49    #[allow(clippy::missing_panics_doc)]
50    pub fn from_points_slice(pts: &[Point3]) -> Self {
51        assert!(!pts.is_empty(), "OBB requires at least one point");
52
53        let n = pts.len() as f64;
54
55        let mut cx = 0.0_f64;
56        let mut cy = 0.0_f64;
57        let mut cz = 0.0_f64;
58        for p in pts {
59            cx += p.x();
60            cy += p.y();
61            cz += p.z();
62        }
63        cx /= n;
64        cy /= n;
65        cz /= n;
66
67        // Covariance matrix (symmetric 3x3).
68        let mut cov = [0.0_f64; 6]; // [xx, xy, xz, yy, yz, zz]
69        for p in pts {
70            let dx = p.x() - cx;
71            let dy = p.y() - cy;
72            let dz = p.z() - cz;
73            cov[0] += dx * dx;
74            cov[1] += dx * dy;
75            cov[2] += dx * dz;
76            cov[3] += dy * dy;
77            cov[4] += dy * dz;
78            cov[5] += dz * dz;
79        }
80
81        // Extract eigenvectors via Jacobi iteration on the symmetric 3x3.
82        let axes = eigen_axes_3x3(cov);
83
84        Self::from_axes_and_points(Point3::new(cx, cy, cz), axes, pts)
85    }
86
87    /// Build an OBB with a known primary axis (e.g. face normal for planar faces).
88    ///
89    /// Uses the given normal as one axis and PCA in the remaining plane for the
90    /// other two. This gives near-zero thickness for planar faces.
91    ///
92    /// # Panics
93    ///
94    /// Panics if the iterator yields fewer than 1 point.
95    #[must_use]
96    #[allow(clippy::missing_panics_doc)]
97    pub fn from_points_with_normal(points: impl IntoIterator<Item = Point3>, normal: Vec3) -> Self {
98        let pts: Vec<Point3> = points.into_iter().collect();
99        Self::from_slice_with_normal(&pts, normal)
100    }
101
102    /// Build an OBB with a known primary axis from a slice of points.
103    ///
104    /// Same as [`from_points_with_normal`](Self::from_points_with_normal) but avoids
105    /// an allocation when the caller already has a slice.
106    ///
107    /// # Panics
108    ///
109    /// Panics if the slice is empty.
110    #[must_use]
111    #[allow(clippy::missing_panics_doc)]
112    pub fn from_slice_with_normal(pts: &[Point3], normal: Vec3) -> Self {
113        assert!(!pts.is_empty(), "OBB requires at least one point");
114
115        let n = pts.len() as f64;
116
117        let mut cx = 0.0_f64;
118        let mut cy = 0.0_f64;
119        let mut cz = 0.0_f64;
120        for p in pts {
121            cx += p.x();
122            cy += p.y();
123            cz += p.z();
124        }
125        cx /= n;
126        cy /= n;
127        cz /= n;
128
129        // Normalize the provided normal (axis 2 = thickness direction).
130        let len =
131            (normal.x() * normal.x() + normal.y() * normal.y() + normal.z() * normal.z()).sqrt();
132        let axis2 = if len > 1e-15 {
133            Vec3::new(normal.x() / len, normal.y() / len, normal.z() / len)
134        } else {
135            // Degenerate normal, fall back to full PCA.
136            return Self::from_points_slice(pts);
137        };
138
139        // Find a perpendicular direction for in-plane PCA.
140        // Pick the coordinate axis most perpendicular to axis2.
141        let abs_x = axis2.x().abs();
142        let abs_y = axis2.y().abs();
143        let abs_z = axis2.z().abs();
144        let seed = if abs_x <= abs_y && abs_x <= abs_z {
145            Vec3::new(1.0, 0.0, 0.0)
146        } else if abs_y <= abs_z {
147            Vec3::new(0.0, 1.0, 0.0)
148        } else {
149            Vec3::new(0.0, 0.0, 1.0)
150        };
151
152        // Gram-Schmidt to get two in-plane axes.
153        let u = {
154            let v = Vec3::new(
155                seed.x() - axis2.x() * seed.dot(axis2),
156                seed.y() - axis2.y() * seed.dot(axis2),
157                seed.z() - axis2.z() * seed.dot(axis2),
158            );
159            let l = (v.x() * v.x() + v.y() * v.y() + v.z() * v.z()).sqrt();
160            Vec3::new(v.x() / l, v.y() / l, v.z() / l)
161        };
162
163        // Project points onto u to find 2D covariance for in-plane PCA.
164        let v = axis2.cross(u);
165
166        // 2D covariance in the (u, v) plane.
167        let mut cov_uu = 0.0_f64;
168        let mut cov_uv = 0.0_f64;
169        let mut cov_vv = 0.0_f64;
170        for p in pts {
171            let d = Vec3::new(p.x() - cx, p.y() - cy, p.z() - cz);
172            let du = d.dot(u);
173            let dv = d.dot(v);
174            cov_uu += du * du;
175            cov_uv += du * dv;
176            cov_vv += dv * dv;
177        }
178
179        // 2x2 eigendecomposition for in-plane axes.
180        let (angle, _e1, _e2) = eigen_2x2(cov_uu, cov_uv, cov_vv);
181        let (sin_a, cos_a) = angle.sin_cos();
182
183        // Rotate (u, v) by the eigenvector angle.
184        let axis0 = Vec3::new(
185            cos_a * u.x() + sin_a * v.x(),
186            cos_a * u.y() + sin_a * v.y(),
187            cos_a * u.z() + sin_a * v.z(),
188        );
189        let axis1 = Vec3::new(
190            -sin_a * u.x() + cos_a * v.x(),
191            -sin_a * u.y() + cos_a * v.y(),
192            -sin_a * u.z() + cos_a * v.z(),
193        );
194
195        Self::from_axes_and_points(Point3::new(cx, cy, cz), [axis0, axis1, axis2], pts)
196    }
197
198    /// Build OBB from pre-computed axes by projecting points to find extents.
199    fn from_axes_and_points(centroid: Point3, axes: [Vec3; 3], pts: &[Point3]) -> Self {
200        let mut min_ext = [f64::INFINITY; 3];
201        let mut max_ext = [f64::NEG_INFINITY; 3];
202
203        for p in pts {
204            let d = Vec3::new(
205                p.x() - centroid.x(),
206                p.y() - centroid.y(),
207                p.z() - centroid.z(),
208            );
209            for (i, ax) in axes.iter().enumerate() {
210                let proj = d.dot(*ax);
211                if proj < min_ext[i] {
212                    min_ext[i] = proj;
213                }
214                if proj > max_ext[i] {
215                    max_ext[i] = proj;
216                }
217            }
218        }
219
220        // Re-center: shift center to midpoint of extent range along each axis.
221        let mut center = centroid;
222        let mut half_extents = [0.0_f64; 3];
223        for i in 0..3 {
224            let mid = (min_ext[i] + max_ext[i]) * 0.5;
225            half_extents[i] = (max_ext[i] - min_ext[i]) * 0.5;
226            center = Point3::new(
227                center.x() + axes[i].x() * mid,
228                center.y() + axes[i].y() * mid,
229                center.z() + axes[i].z() * mid,
230            );
231        }
232
233        Self {
234            center,
235            axes,
236            half_extents,
237        }
238    }
239
240    /// Test whether two OBBs intersect using the Separating Axis Theorem.
241    ///
242    /// Tests 15 potential separating axes: 3 from each OBB + 9 cross products.
243    /// Returns `true` if the OBBs overlap (no separating axis found).
244    #[inline]
245    #[must_use]
246    #[allow(clippy::many_single_char_names)]
247    pub fn intersects(&self, other: &Self) -> bool {
248        // Vector from self center to other center.
249        let t = Vec3::new(
250            other.center.x() - self.center.x(),
251            other.center.y() - self.center.y(),
252            other.center.z() - self.center.z(),
253        );
254
255        let a = &self.axes;
256        let b = &other.axes;
257        let ea = &self.half_extents;
258        let eb = &other.half_extents;
259
260        // Precompute rotation matrix R[i][j] = a[i] . b[j]
261        // and absolute values with epsilon for parallel edge cases.
262        #[allow(clippy::items_after_statements)]
263        const EPS: f64 = 1e-12;
264        let mut r = [[0.0_f64; 3]; 3];
265        let mut abs_r = [[0.0_f64; 3]; 3];
266        for i in 0..3 {
267            for j in 0..3 {
268                r[i][j] = a[i].dot(b[j]);
269                abs_r[i][j] = r[i][j].abs() + EPS;
270            }
271        }
272
273        // Precompute dot products of t with each OBB's axes.
274        let t_a = [t.dot(a[0]), t.dot(a[1]), t.dot(a[2])];
275        let t_b = [t.dot(b[0]), t.dot(b[1]), t.dot(b[2])];
276
277        // Test axes a[0], a[1], a[2]
278        for i in 0..3 {
279            let ra = ea[i];
280            let rb = eb[0] * abs_r[i][0] + eb[1] * abs_r[i][1] + eb[2] * abs_r[i][2];
281            if t_a[i].abs() > ra + rb {
282                return false;
283            }
284        }
285
286        // Test axes b[0], b[1], b[2]
287        for j in 0..3 {
288            let ra = ea[0] * abs_r[0][j] + ea[1] * abs_r[1][j] + ea[2] * abs_r[2][j];
289            let rb = eb[j];
290            if t_b[j].abs() > ra + rb {
291                return false;
292            }
293        }
294
295        // Test 9 cross-product axes: a[i] x b[j]
296        // a[0] x b[0]
297        {
298            let ra = ea[1] * abs_r[2][0] + ea[2] * abs_r[1][0];
299            let rb = eb[1] * abs_r[0][2] + eb[2] * abs_r[0][1];
300            let d = (t_a[2] * r[1][0] - t_a[1] * r[2][0]).abs();
301            if d > ra + rb {
302                return false;
303            }
304        }
305        // a[0] x b[1]
306        {
307            let ra = ea[1] * abs_r[2][1] + ea[2] * abs_r[1][1];
308            let rb = eb[0] * abs_r[0][2] + eb[2] * abs_r[0][0];
309            let d = (t_a[2] * r[1][1] - t_a[1] * r[2][1]).abs();
310            if d > ra + rb {
311                return false;
312            }
313        }
314        // a[0] x b[2]
315        {
316            let ra = ea[1] * abs_r[2][2] + ea[2] * abs_r[1][2];
317            let rb = eb[0] * abs_r[0][1] + eb[1] * abs_r[0][0];
318            let d = (t_a[2] * r[1][2] - t_a[1] * r[2][2]).abs();
319            if d > ra + rb {
320                return false;
321            }
322        }
323        // a[1] x b[0]
324        {
325            let ra = ea[0] * abs_r[2][0] + ea[2] * abs_r[0][0];
326            let rb = eb[1] * abs_r[1][2] + eb[2] * abs_r[1][1];
327            let d = (t_a[0] * r[2][0] - t_a[2] * r[0][0]).abs();
328            if d > ra + rb {
329                return false;
330            }
331        }
332        // a[1] x b[1]
333        {
334            let ra = ea[0] * abs_r[2][1] + ea[2] * abs_r[0][1];
335            let rb = eb[0] * abs_r[1][2] + eb[2] * abs_r[1][0];
336            let d = (t_a[0] * r[2][1] - t_a[2] * r[0][1]).abs();
337            if d > ra + rb {
338                return false;
339            }
340        }
341        // a[1] x b[2]
342        {
343            let ra = ea[0] * abs_r[2][2] + ea[2] * abs_r[0][2];
344            let rb = eb[0] * abs_r[1][1] + eb[1] * abs_r[1][0];
345            let d = (t_a[0] * r[2][2] - t_a[2] * r[0][2]).abs();
346            if d > ra + rb {
347                return false;
348            }
349        }
350        // a[2] x b[0]
351        {
352            let ra = ea[0] * abs_r[1][0] + ea[1] * abs_r[0][0];
353            let rb = eb[1] * abs_r[2][2] + eb[2] * abs_r[2][1];
354            let d = (t_a[1] * r[0][0] - t_a[0] * r[1][0]).abs();
355            if d > ra + rb {
356                return false;
357            }
358        }
359        // a[2] x b[1]
360        {
361            let ra = ea[0] * abs_r[1][1] + ea[1] * abs_r[0][1];
362            let rb = eb[0] * abs_r[2][2] + eb[2] * abs_r[2][0];
363            let d = (t_a[1] * r[0][1] - t_a[0] * r[1][1]).abs();
364            if d > ra + rb {
365                return false;
366            }
367        }
368        // a[2] x b[2]
369        {
370            let ra = ea[0] * abs_r[1][2] + ea[1] * abs_r[0][2];
371            let rb = eb[0] * abs_r[2][1] + eb[1] * abs_r[2][0];
372            let d = (t_a[1] * r[0][2] - t_a[0] * r[1][2]).abs();
373            if d > ra + rb {
374                return false;
375            }
376        }
377
378        // No separating axis found -- OBBs overlap.
379        true
380    }
381}
382
383// ---------------------------------------------------------------------------
384// Eigendecomposition helpers (no external deps)
385// ---------------------------------------------------------------------------
386
387/// Eigenvalues and rotation angle for a symmetric 2x2 matrix `[[a, b], [b, c]]`.
388///
389/// Returns `(angle, eigenvalue_1, eigenvalue_2)` where `angle` rotates the
390/// standard basis to the eigenvector basis.
391fn eigen_2x2(a: f64, b: f64, c: f64) -> (f64, f64, f64) {
392    if b.abs() < 1e-30 {
393        return (0.0, a, c);
394    }
395    let theta = 0.5 * (2.0 * b).atan2(a - c);
396    let trace = a + c;
397    let det = a * c - b * b;
398    let disc = (trace * trace - 4.0 * det).max(0.0).sqrt();
399    let e1 = (trace + disc) * 0.5;
400    let e2 = (trace - disc) * 0.5;
401    (theta, e1, e2)
402}
403
404/// Extract principal axes from a symmetric 3x3 covariance matrix using
405/// Jacobi eigenvalue iteration.
406///
407/// Input: `cov = [xx, xy, xz, yy, yz, zz]` (upper triangle, row-major).
408/// Returns three orthonormal eigenvectors (sorted by decreasing eigenvalue).
409#[allow(clippy::similar_names)]
410fn eigen_axes_3x3(cov: [f64; 6]) -> [Vec3; 3] {
411    // Unpack into full symmetric matrix.
412    let mut m = [
413        [cov[0], cov[1], cov[2]],
414        [cov[1], cov[3], cov[4]],
415        [cov[2], cov[4], cov[5]],
416    ];
417    // Eigenvector matrix (starts as identity).
418    let mut v = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
419
420    // Jacobi iteration: apply Givens rotations to diagonalize m.
421    for _ in 0..50 {
422        // Find largest off-diagonal element.
423        let mut max_val = 0.0_f64;
424        let mut p = 0;
425        let mut q = 1;
426        for i in 0..3 {
427            for j in (i + 1)..3 {
428                if m[i][j].abs() > max_val {
429                    max_val = m[i][j].abs();
430                    p = i;
431                    q = j;
432                }
433            }
434        }
435        if max_val < 1e-30 {
436            break; // Converged.
437        }
438
439        // Compute Givens rotation angle.
440        let theta = if (m[p][p] - m[q][q]).abs() < 1e-30 {
441            std::f64::consts::FRAC_PI_4
442        } else {
443            0.5 * (2.0 * m[p][q]).atan2(m[p][p] - m[q][q])
444        };
445        let (sin_t, cos_t) = theta.sin_cos();
446
447        // Apply rotation to m: m' = G^T * m * G
448        let mut m2 = m;
449        m2[p][p] =
450            cos_t * cos_t * m[p][p] + 2.0 * sin_t * cos_t * m[p][q] + sin_t * sin_t * m[q][q];
451        m2[q][q] =
452            sin_t * sin_t * m[p][p] - 2.0 * sin_t * cos_t * m[p][q] + cos_t * cos_t * m[q][q];
453        m2[p][q] = 0.0;
454        m2[q][p] = 0.0;
455        for r in 0..3 {
456            if r != p && r != q {
457                let mp = cos_t * m[r][p] + sin_t * m[r][q];
458                let mq = -sin_t * m[r][p] + cos_t * m[r][q];
459                m2[r][p] = mp;
460                m2[p][r] = mp;
461                m2[r][q] = mq;
462                m2[q][r] = mq;
463            }
464        }
465        m = m2;
466
467        // Accumulate eigenvectors.
468        for r in 0..3 {
469            let vp = cos_t * v[r][p] + sin_t * v[r][q];
470            let vq = -sin_t * v[r][p] + cos_t * v[r][q];
471            v[r][p] = vp;
472            v[r][q] = vq;
473        }
474    }
475
476    // Sort eigenvectors by decreasing eigenvalue.
477    let mut order = [0, 1, 2];
478    let eigenvalues = [m[0][0], m[1][1], m[2][2]];
479    order.sort_by(|&a, &b| {
480        eigenvalues[b]
481            .partial_cmp(&eigenvalues[a])
482            .unwrap_or(std::cmp::Ordering::Equal)
483    });
484
485    let make_axis = |col: usize| {
486        let len = (v[0][col] * v[0][col] + v[1][col] * v[1][col] + v[2][col] * v[2][col]).sqrt();
487        if len > 1e-15 {
488            Vec3::new(v[0][col] / len, v[1][col] / len, v[2][col] / len)
489        } else {
490            // Degenerate -- use canonical axis.
491            match col {
492                0 => Vec3::new(1.0, 0.0, 0.0),
493                1 => Vec3::new(0.0, 1.0, 0.0),
494                _ => Vec3::new(0.0, 0.0, 1.0),
495            }
496        }
497    };
498
499    [
500        make_axis(order[0]),
501        make_axis(order[1]),
502        make_axis(order[2]),
503    ]
504}
505
506#[cfg(test)]
507mod tests {
508    #![allow(clippy::unwrap_used, clippy::expect_used)]
509
510    use super::*;
511
512    #[test]
513    fn obb_from_axis_aligned_points() {
514        let pts = [
515            Point3::new(0.0, 0.0, 0.0),
516            Point3::new(2.0, 0.0, 0.0),
517            Point3::new(2.0, 1.0, 0.0),
518            Point3::new(0.0, 1.0, 0.0),
519        ];
520        let obb = Obb3::from_points(pts);
521        // Center should be at (1, 0.5, 0).
522        assert!((obb.center.x() - 1.0).abs() < 1e-10);
523        assert!((obb.center.y() - 0.5).abs() < 1e-10);
524        assert!((obb.center.z()).abs() < 1e-10);
525    }
526
527    #[test]
528    fn obb_identical_boxes_intersect() {
529        let pts = [
530            Point3::new(0.0, 0.0, 0.0),
531            Point3::new(1.0, 0.0, 0.0),
532            Point3::new(1.0, 1.0, 0.0),
533            Point3::new(0.0, 1.0, 0.0),
534            Point3::new(0.0, 0.0, 1.0),
535            Point3::new(1.0, 0.0, 1.0),
536            Point3::new(1.0, 1.0, 1.0),
537            Point3::new(0.0, 1.0, 1.0),
538        ];
539        let obb = Obb3::from_points(pts);
540        assert!(obb.intersects(&obb));
541    }
542
543    #[test]
544    fn obb_separated_boxes_dont_intersect() {
545        let a = Obb3::from_points([
546            Point3::new(0.0, 0.0, 0.0),
547            Point3::new(1.0, 0.0, 0.0),
548            Point3::new(1.0, 1.0, 0.0),
549            Point3::new(0.0, 1.0, 0.0),
550        ]);
551        let b = Obb3::from_points([
552            Point3::new(5.0, 0.0, 0.0),
553            Point3::new(6.0, 0.0, 0.0),
554            Point3::new(6.0, 1.0, 0.0),
555            Point3::new(5.0, 1.0, 0.0),
556        ]);
557        assert!(!a.intersects(&b));
558    }
559
560    #[test]
561    fn obb_overlapping_rotated_boxes_intersect() {
562        // A unit square at origin and a rotated square overlapping it.
563        let a = Obb3::from_points([
564            Point3::new(-1.0, -1.0, 0.0),
565            Point3::new(1.0, -1.0, 0.0),
566            Point3::new(1.0, 1.0, 0.0),
567            Point3::new(-1.0, 1.0, 0.0),
568        ]);
569        // 45-degree rotated square, overlapping.
570        let s = std::f64::consts::FRAC_1_SQRT_2;
571        let b = Obb3::from_points([
572            Point3::new(0.0, -s, 0.0),
573            Point3::new(s, 0.0, 0.0),
574            Point3::new(0.0, s, 0.0),
575            Point3::new(-s, 0.0, 0.0),
576        ]);
577        assert!(a.intersects(&b));
578    }
579
580    #[test]
581    fn obb_with_normal_planar_face() {
582        let pts = [
583            Point3::new(0.0, 0.0, 5.0),
584            Point3::new(2.0, 0.0, 5.0),
585            Point3::new(2.0, 3.0, 5.0),
586            Point3::new(0.0, 3.0, 5.0),
587        ];
588        let normal = Vec3::new(0.0, 0.0, 1.0);
589        let obb = Obb3::from_points_with_normal(pts, normal);
590
591        // Thickness along normal should be near zero.
592        assert!(obb.half_extents[2] < 1e-10);
593    }
594
595    #[test]
596    fn obb_edge_touching() {
597        // Two boxes sharing an edge at x=1.
598        let a = Obb3::from_points([
599            Point3::new(0.0, 0.0, 0.0),
600            Point3::new(1.0, 0.0, 0.0),
601            Point3::new(1.0, 1.0, 0.0),
602            Point3::new(0.0, 1.0, 0.0),
603        ]);
604        let b = Obb3::from_points([
605            Point3::new(1.0, 0.0, 0.0),
606            Point3::new(2.0, 0.0, 0.0),
607            Point3::new(2.0, 1.0, 0.0),
608            Point3::new(1.0, 1.0, 0.0),
609        ]);
610        // Edge-touching should still be considered intersecting.
611        assert!(a.intersects(&b));
612    }
613
614    #[test]
615    fn obb_from_points_slice_matches_from_points() {
616        let pts = vec![
617            Point3::new(0.0, 0.0, 0.0),
618            Point3::new(2.0, 0.0, 0.0),
619            Point3::new(2.0, 1.0, 0.0),
620            Point3::new(0.0, 1.0, 0.0),
621        ];
622        let obb_iter = Obb3::from_points(pts.iter().copied());
623        let obb_slice = Obb3::from_points_slice(&pts);
624        assert!((obb_iter.center.x() - obb_slice.center.x()).abs() < 1e-15);
625        assert!((obb_iter.center.y() - obb_slice.center.y()).abs() < 1e-15);
626        assert!((obb_iter.center.z() - obb_slice.center.z()).abs() < 1e-15);
627        for i in 0..3 {
628            assert!((obb_iter.half_extents[i] - obb_slice.half_extents[i]).abs() < 1e-15);
629        }
630    }
631
632    #[test]
633    fn obb_from_slice_with_normal_matches_iterator() {
634        let pts = vec![
635            Point3::new(0.0, 0.0, 5.0),
636            Point3::new(2.0, 0.0, 5.0),
637            Point3::new(2.0, 3.0, 5.0),
638            Point3::new(0.0, 3.0, 5.0),
639        ];
640        let normal = Vec3::new(0.0, 0.0, 1.0);
641        let obb_iter = Obb3::from_points_with_normal(pts.iter().copied(), normal);
642        let obb_slice = Obb3::from_slice_with_normal(&pts, normal);
643        assert!((obb_iter.center.x() - obb_slice.center.x()).abs() < 1e-15);
644        assert!((obb_iter.center.y() - obb_slice.center.y()).abs() < 1e-15);
645        assert!((obb_iter.center.z() - obb_slice.center.z()).abs() < 1e-15);
646        for i in 0..3 {
647            assert!((obb_iter.half_extents[i] - obb_slice.half_extents[i]).abs() < 1e-15);
648        }
649    }
650
651    #[test]
652    fn eigen_2x2_correct_angle() {
653        // For a = 3, b = 1, c = 1: theta = 0.5 * atan2(2, 2) = pi/8
654        let (theta, _e1, _e2) = super::eigen_2x2(3.0, 1.0, 1.0);
655        let expected = 0.5 * (2.0_f64).atan2(2.0);
656        assert!(
657            (theta - expected).abs() < 1e-15,
658            "theta={theta}, expected={expected}"
659        );
660    }
661}