Skip to main content

rscad_csg_bsp/
plane.rs

1use crate::{Number, Polygon, PolygonClassification};
2use core::ops::{AddAssign, MulAssign, SubAssign};
3use nalgebra::{Point, Point3, SVector};
4use num_traits::real::Real;
5
6pub type Plane3<T> = Plane<T, 3>;
7
8/// A plane in 3D space, defined by a normal vector and a distance from the origin
9#[repr(C)]
10#[derive(Clone, Copy, Debug, PartialOrd, PartialEq)]
11pub struct Plane<T: Number, const N: usize> {
12    distance: T,
13    normal: SVector<T, N>,
14}
15
16#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Hash)]
17pub enum PlaneSide {
18    Front,
19    Back,
20    On,
21}
22
23impl core::ops::Neg for PlaneSide {
24    type Output = Self;
25
26    fn neg(self) -> Self::Output {
27        match self {
28            PlaneSide::Front => PlaneSide::Back,
29            PlaneSide::Back => PlaneSide::Front,
30            PlaneSide::On => PlaneSide::On,
31        }
32    }
33}
34
35impl<T: Number> Plane<T, 3> {
36    pub fn xy_plane() -> Self {
37        Self {
38            normal: *SVector::ith_axis(2),
39            distance: T::zero(),
40        }
41    }
42
43    pub fn xz_plane() -> Self {
44        Self {
45            normal: *SVector::ith_axis(1),
46            distance: T::zero(),
47        }
48    }
49
50    pub fn yz_plane() -> Self {
51        Self {
52            normal: *SVector::ith_axis(0),
53            distance: T::zero(),
54        }
55    }
56}
57
58impl<T, const N: usize> Plane<T, N>
59where
60    T: Number,
61{
62    pub fn new(normal: SVector<T, N>, distance: T) -> Self {
63        let len = normal.magnitude();
64        if len > T::default_epsilon() {
65            Self {
66                normal: normal / len,
67                distance: distance / len,
68            }
69        } else {
70            Self { normal, distance }
71        }
72    }
73
74    #[inline]
75    pub fn normal(&self) -> SVector<T, N> {
76        self.normal
77    }
78
79    pub fn with_normal(mut self, normal: SVector<T, N>) -> Self {
80        self.normal = normal;
81        self
82    }
83
84    /// Returns the signed distance from the origin to the plane along the normal.
85    #[inline]
86    pub fn distance(&self) -> T {
87        self.distance
88    }
89
90    /// Approximate equality check using epsilon tolerance.
91    pub fn roughly_eq(&self, other: &Self) -> bool {
92        let eps = T::default_epsilon();
93        (self.normal - other.normal).norm() < eps && Real::abs(self.distance - other.distance) < eps
94    }
95
96    /// Returns the point on the plane closest to the origin.
97    /// Requires the normal to be a unit vector.
98    #[inline]
99    pub fn origin_projection(&self) -> Point<T, N> {
100        (self.normal * self.distance).into()
101    }
102
103    #[inline]
104    pub fn classify_point(&self, point: &Point<T, N>) -> PlaneSide {
105        // Use a geometry-appropriate epsilon. default_epsilon() (~1.19e-7 for f32) is
106        // too tight after multiple BSP split/clip cycles where floating-point drift
107        // accumulates, causing misclassification and degenerate slivers.
108        let eps = T::from(1e-5).expect("BUG: 1e-5 is representable as T");
109        let distance = self.normal.dot(&point.coords) - self.distance;
110        if distance > eps {
111            PlaneSide::Front
112        } else if distance < -eps {
113            PlaneSide::Back
114        } else {
115            PlaneSide::On
116        }
117    }
118
119    #[inline(always)]
120    pub fn has_point(&self, point: &Point<T, N>) -> bool {
121        self.classify_point(point) == PlaneSide::On
122    }
123
124    /// Cast the plane's numeric type from `T` to `T2`.
125    pub fn cast<T2: Number>(self) -> Plane<T2, N>
126    where
127        T: num_traits::cast::AsPrimitive<T2>,
128    {
129        Plane {
130            normal: self.normal.map(|x| x.as_()),
131            distance: self.distance.as_(),
132        }
133    }
134
135    #[inline]
136    #[must_use = "flip takes ownership of the original plane and returns a new one with the flipped normal and distance"]
137    pub fn flip(mut self) -> Self {
138        self.normal *= -T::one();
139        self.distance *= -T::one();
140        self
141    }
142
143    /// Classifies a polygon
144    ///
145    /// Computes signed distances for all vertices in a single pass, tracking
146    /// min/max to derive the classification without per-vertex branching.
147    pub fn classify_polygon(&self, polygon: &Polygon<T, N>) -> PolygonClassification {
148        let eps = T::from(1e-5).expect("BUG: 1e-5 is representable as T");
149        let neg_eps = -eps;
150        let mut min_dist =
151            T::from(f64::INFINITY).expect("BUG: f64::INFINITY is representable as T");
152        let mut max_dist =
153            T::from(f64::NEG_INFINITY).expect("BUG: f64::NEG_INFINITY is representable as T");
154
155        for vertex in polygon.vertices().iter() {
156            let d = self.normal.dot(&vertex.coords) - self.distance;
157            if d < min_dist {
158                min_dist = d;
159            }
160            if d > max_dist {
161                max_dist = d;
162            }
163        }
164
165        match (max_dist > eps, min_dist < neg_eps) {
166            (true, true) => PolygonClassification::Spanning,
167            (true, false) => PolygonClassification::Front,
168            (false, true) => PolygonClassification::Back,
169            (false, false) => PolygonClassification::Coplanar,
170        }
171    }
172}
173
174impl<T: Number, const N: usize> Plane<T, N> {
175    #[allow(clippy::type_complexity)]
176    pub fn split_edge(
177        &self,
178        a: &Point<T, N>,
179        b: &Point<T, N>,
180    ) -> Result<(Point<T, N>, Point<T, N>, Point<T, N>), PlaneSide> {
181        // Classify both points
182        let a_class = self.classify_point(a);
183        let b_class = self.classify_point(b);
184        if a_class == b_class {
185            Err(a_class)?;
186        }
187        let t = (self.distance() - self.normal().dot(&a.coords)) / self.normal().dot(&(b - a));
188        let p = a + (b - a) * t;
189        Ok((*a, p, *b))
190    }
191
192    /// Split a spanning polygon into a front fragment and a back fragment.
193    ///
194    /// Algorithm:
195    /// 1. Classify each vertex with `classify_point`.
196    /// 2. For each consecutive edge (a, b):
197    ///    - If `a` is `Front`, add `a` to the front list.
198    ///    - If `a` is `Back`, add `a` to the back list.
199    ///    - If `a` is `On`, add `a` to both lists.
200    ///    - If edge (a, b) crosses the plane (a and b have opposite Front/Back classification):
201    ///      compute the intersection point `p = a + t*(b-a)` where `t = (d - n·a) / (n·(b-a))`,
202    ///      then add `p` to both lists.
203    /// 3. Build two new `Polygon`s from the front and back vertex lists, keeping the original plane.
204    ///
205    /// Metadata is preserved: both fragments inherit the source polygon's metadata.
206    pub fn split_polygon(&self, polygon: Polygon<T, N>) -> (Polygon<T, N>, Polygon<T, N>) {
207        let verts = polygon.vertices();
208        let n = verts.len();
209        let cap = n + 1;
210        let mut front = Vec::with_capacity(cap);
211        let mut back = Vec::with_capacity(cap);
212
213        // Pre-compute per-vertex classifications to avoid redundant classify_point calls.
214        // Each vertex is classified once instead of twice (as this + as next).
215        let classes: Vec<PlaneSide> = verts.iter().map(|v| self.classify_point(v)).collect();
216
217        for i in 0..n {
218            let j = if i + 1 < n { i + 1 } else { 0 };
219            let this = &verts[i];
220            let this_class = classes[i];
221            let next_class = classes[j];
222
223            match this_class {
224                PlaneSide::Front => front.push(*this),
225                PlaneSide::Back => back.push(*this),
226                PlaneSide::On => {
227                    front.push(*this);
228                    back.push(*this);
229                }
230            }
231            if this_class != PlaneSide::On
232                && next_class != PlaneSide::On
233                && this_class != next_class
234            {
235                let next = &verts[j];
236                let t = (self.distance() - self.normal().dot(&this.coords))
237                    / self.normal().dot(&(next - this));
238                let p = this + (next - this) * t;
239                front.push(p);
240                back.push(p);
241            }
242        }
243
244        let a = ndarray::Array1::from_vec(front);
245        let b = ndarray::Array1::from_vec(back);
246        (polygon.clone_with_points(a), polygon.clone_with_points(b))
247    }
248}
249
250impl<T> Plane<T, 3>
251where
252    T: Number + AddAssign + SubAssign + MulAssign + nalgebra::RealField,
253{
254    /// Construct a plane from three non-collinear points.
255    /// The normal is oriented by the right-hand rule: (p2 - p1) × (p3 - p1).
256    pub fn from_points(p1: Point3<T>, p2: Point3<T>, p3: Point3<T>) -> Self {
257        let v1 = p2 - p1;
258        let v2 = p3 - p1;
259        let normal = v1.cross(&v2).normalize();
260        let distance = normal.dot(&p1.coords);
261        Self { normal, distance }
262    }
263
264    /// Compute the 3×3 rotation matrix **R** such that `R * self.normal() == other.normal()`.
265    ///
266    /// In other words, given a point `p` that lies in the plane described by `self`, the product
267    /// `R * p` will lie in the plane described by `other`.  This is the unique shortest-arc rotation
268    /// (minimum rotation angle) that carries one normal onto the other, computed via the
269    /// **Rodrigues rotation formula** in its trigonometry-free form.
270    ///
271    /// # Mathematical derivation
272    ///
273    /// Let **a** = `self.normal()` and **b** = `other.normal()` (both assumed to be unit vectors).
274    ///
275    /// The angle θ between them satisfies:
276    /// ```text
277    ///   cos θ  = a · b           (dot product)
278    ///   sin θ  = ‖a × b‖         (magnitude of cross product)
279    /// ```
280    ///
281    /// The rotation axis is `v = a × b` (unnormalized; its magnitude is sin θ).
282    ///
283    /// The skew-symmetric cross-product matrix `[v]×` encodes the "cross with v" operation:
284    /// ```text
285    ///         ⎡  0   −v₃   v₂ ⎤
286    /// [v]× =  ⎢  v₃   0   −v₁ ⎥
287    ///         ⎣ −v₂   v₁   0  ⎦
288    /// ```
289    ///
290    /// Rodrigues' formula then gives:
291    /// ```text
292    /// R = I  +  [v]×  +  [v]ײ  ·  1/(1 + cos θ)
293    /// ```
294    ///
295    /// This avoids calling `acos`/`sin`/`cos` and is numerically stable for all θ except
296    /// θ = 180° (anti-parallel normals), which is handled separately below.
297    ///
298    /// # Special case: anti-parallel normals (θ ≈ 180°)
299    ///
300    /// When `a · b ≈ −1` the denominator `(1 + cos θ)` approaches zero, so the formula above
301    /// is undefined.  Geometrically, any 180° rotation whose axis is perpendicular to **a** is a
302    /// valid solution; we break the degeneracy by choosing the world axis that is *least* aligned
303    /// with **a** (found with `iamin()` — the index of the smallest absolute component) as a
304    /// helper to build a perpendicular rotation axis:
305    /// ```text
306    /// rot_axis = (a × world_axis).normalize()
307    /// R = −I + 2 · rot_axis · rot_axisᵀ
308    /// ```
309    /// (This is Rodrigues with θ = π: cos π = −1, sin π = 0.)
310    pub fn rotation_to(&self, other: Plane<T, 3>) -> nalgebra::Matrix3<T>
311    where
312        T: num_traits::Signed,
313    {
314        // Unit normals of the source and destination planes.
315        let a = self.normal().normalize();
316        let b = other.normal().normalize();
317
318        // cos θ = a · b  and  v = a × b  (unnormalized rotation axis, ‖v‖ = sin θ).
319        let cos_theta = a.dot(&b);
320        let v = a.cross(&b);
321
322        let one = T::one();
323        let two = one + one;
324
325        // -----------------------------------------------------------------------
326        // Anti-parallel special case: a · b ≈ −1  (θ ≈ 180°).
327        //
328        // The general Rodrigues formula below scales `[v]ײ` by 1/(1 + cos θ). As the
329        // normals approach anti-parallel, (1 + cos θ) → 0, so that factor explodes and
330        // amplifies the rounding noise in `v = a × b` (which is itself ≈ 0 here). Writing
331        // δ for the angular deviation from exactly 180°, 1 + cos θ ≈ δ²/2, so the matrix
332        // error grows like ε/δ². The old cutoff only kicked in for δ ≲ 3e-8 rad, leaving
333        // a wide band of nearly-anti-parallel inputs to fall through to the unstable
334        // formula and produce garbage matrices (non-orthogonal, det ≠ 1).
335        //
336        // We therefore branch to the exact 180° construction long before the denominator
337        // becomes dangerous. The `1e-6` threshold corresponds to δ ≈ 1.4e-3 rad, where the
338        // Rodrigues term still carries only ~1e-10 error — comfortably below the crossover
339        // while covering every input the formula cannot handle cleanly.
340        //
341        // Any 180° rotation whose axis is perpendicular to `a` sends a → −a ≈ b, so we
342        // build one explicitly. `iamin()` returns the index of the component of `a` with
343        // the smallest absolute value, i.e. the world axis (X=0, Y=1, Z=2) that is *least*
344        // parallel to `a`; crossing `a` with it yields a well-conditioned perpendicular.
345        // -----------------------------------------------------------------------
346        let anti_parallel_threshold = T::from(1e-6).expect("BUG: 1e-6 is representable as T");
347        if cos_theta < -one + anti_parallel_threshold {
348            // Pick the world axis least aligned with `a` to avoid a near-zero cross product.
349            let least_aligned_axis = a.iamin();
350            let world_axis = SVector::<T, 3>::ith_axis(least_aligned_axis);
351
352            // rot_axis is perpendicular to `a` and unit-length.
353            let rot_axis = a.cross(&world_axis.into_inner()).normalize();
354
355            // 180° Rodrigues: R = −I + 2 * rot_axis * rot_axisᵀ
356            // (cos π = −1 cancels the identity, sin π = 0 removes the [v]× term)
357            return -nalgebra::Matrix3::identity() + rot_axis * rot_axis.transpose() * two;
358        }
359
360        // -----------------------------------------------------------------------
361        // General case via the trigonometry-free Rodrigues formula:
362        //
363        //   R = I  +  [v]×  +  [v]ײ  ·  1 / (1 + cos θ)
364        //
365        // where [v]× is the skew-symmetric matrix of v = a × b.
366        // -----------------------------------------------------------------------
367
368        // Build the skew-symmetric cross-product matrix [v]×.
369        let vx = nalgebra::Matrix3::new(
370            T::zero(),
371            -v[2],
372            v[1],
373            v[2],
374            T::zero(),
375            -v[0],
376            -v[1],
377            v[0],
378            T::zero(),
379        );
380
381        // R = I + [v]× + [v]ײ / (1 + cos θ)
382        nalgebra::Matrix3::identity() + vx + vx * vx * (one / (one + cos_theta))
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    #[test]
391    fn classify_point_front() {
392        let plane = Plane::xy_plane();
393        let point = Point::from([0f32, 0f32, 1f32]);
394        assert_eq!(plane.classify_point(&point), PlaneSide::Front);
395    }
396
397    #[test]
398    fn classify_point_back() {
399        let plane = Plane::xy_plane();
400        let point = Point::from([0f32, 0f32, -1f32]);
401        assert_eq!(plane.classify_point(&point), PlaneSide::Back);
402    }
403
404    #[test]
405    fn classify_point_on_plane() {
406        let plane = Plane::xy_plane();
407        let point = Point::from([5f32, -3f32, 0f32]);
408        assert_eq!(plane.classify_point(&point), PlaneSide::On);
409    }
410
411    #[test]
412    fn classify_point_offset_plane_front() {
413        let normal = SVector::from([0f32, 0f32, 1f32]);
414        let plane = Plane::new(normal, 5f32);
415        let point = Point::from([10f32, 20f32, 6f32]);
416        assert_eq!(plane.classify_point(&point), PlaneSide::Front);
417    }
418
419    #[test]
420    fn classify_point_offset_plane_back() {
421        let normal = SVector::from([0f32, 0f32, 1f32]);
422        let plane = Plane::new(normal, 5f32);
423        let point = Point::from([10f32, 20f32, 4f32]);
424        assert_eq!(plane.classify_point(&point), PlaneSide::Back);
425    }
426
427    #[test]
428    fn classify_point_offset_plane_on() {
429        let normal = SVector::from([0f32, 0f32, 1f32]);
430        let plane = Plane::new(normal, 5f32);
431        let point = Point::from([-10f32, 30f32, 5f32]);
432        assert_eq!(plane.classify_point(&point), PlaneSide::On);
433    }
434
435    #[test]
436    fn edge_both_front() {
437        let plane = Plane::xy_plane();
438        let a = Point::from([0f32, 0f32, 5f32]);
439        let b = Point::from([10f32, 10f32, 3f32]);
440        assert_eq!(plane.split_edge(&a, &b), Err(PlaneSide::Front));
441    }
442
443    #[test]
444    fn edge_both_back() {
445        let plane = Plane::xy_plane();
446        let a = Point::from([0f32, 0f32, -5f32]);
447        let b = Point::from([10f32, 10f32, -3f32]);
448        assert_eq!(plane.split_edge(&a, &b), Err(PlaneSide::Back));
449    }
450
451    #[test]
452    fn edge_both_on_plane() {
453        let plane = Plane::xy_plane();
454        let a = Point::from([10f32, 10f32, 0f32]);
455        let b = Point::from([-10f32, -10f32, 0f32]);
456        assert_eq!(plane.split_edge(&a, &b), Err(PlaneSide::On));
457    }
458
459    #[test]
460    fn edge_crossing_front_to_back() {
461        let plane = Plane::xy_plane();
462        let a = Point::from([0f32, 0f32, 2f32]);
463        let b = Point::from([0f32, 0f32, -2f32]);
464        let result = plane.split_edge(&a, &b);
465        assert!(result.is_ok());
466        let (pa, intersection, pb) = result.unwrap();
467        assert_eq!(pa, a);
468        assert_eq!(pb, b);
469        assert_eq!(intersection, Point::from([0f32, 0f32, 0f32]));
470    }
471
472    #[test]
473    fn edge_crossing_back_to_front() {
474        let plane = Plane::xy_plane();
475        let a = Point::from([0f32, 0f32, -2f32]);
476        let b = Point::from([0f32, 0f32, 2f32]);
477        let result = plane.split_edge(&a, &b);
478        assert!(result.is_ok());
479        let (pa, intersection, pb) = result.unwrap();
480        assert_eq!(pa, a);
481        assert_eq!(pb, b);
482        assert_eq!(intersection, Point::from([0f32, 0f32, 0f32]));
483    }
484
485    #[test]
486    fn edge_crossing_offset_plane() {
487        let normal = SVector::from([0f32, 0f32, 1f32]);
488        let plane = Plane::new(normal, 3f32);
489        let a = Point::from([1f32, 2f32, 1f32]);
490        let b = Point::from([1f32, 2f32, 5f32]);
491        let result = plane.split_edge(&a, &b);
492        assert!(result.is_ok());
493        let (_, intersection, _) = result.unwrap();
494        assert_eq!(intersection, Point::from([1f32, 2f32, 3f32]));
495    }
496
497    #[test]
498    fn edge_crossing_offset_plane_negative_distance() {
499        let normal = SVector::from([0f32, 0f32, 1f32]);
500        let plane = Plane::new(normal, -2f32);
501        let a = Point::from([5f32, 7f32, -5f32]);
502        let b = Point::from([5f32, 7f32, 1f32]);
503        let result = plane.split_edge(&a, &b);
504        assert!(result.is_ok());
505        let (_, intersection, _) = result.unwrap();
506        assert_eq!(intersection, Point::from([5f32, 7f32, -2f32]));
507    }
508
509    #[test]
510    fn edge_crossing_positive_x_plane() {
511        let normal = SVector::from([1f32, 0f32, 0f32]);
512        let plane = Plane::new(normal, 4f32);
513        let a = Point::from([2f32, 3f32, 5f32]);
514        let b = Point::from([8f32, 3f32, 5f32]);
515        let result = plane.split_edge(&a, &b);
516        assert!(result.is_ok());
517        let (_, intersection, _) = result.unwrap();
518        assert_eq!(intersection, Point::from([4f32, 3f32, 5f32]));
519    }
520
521    #[test]
522    fn edge_crossing_positive_y_plane() {
523        let normal = SVector::from([0f32, 1f32, 0f32]);
524        let plane = Plane::new(normal, -6f32);
525        let a = Point::from([1f32, -10f32, 2f32]);
526        let b = Point::from([1f32, 0f32, 2f32]);
527        let result = plane.split_edge(&a, &b);
528        assert!(result.is_ok());
529        let (_, intersection, _) = result.unwrap();
530        assert_eq!(intersection, Point::from([1f32, -6f32, 2f32]));
531    }
532
533    #[test]
534    fn edge_crossing_diagonal_plane() {
535        let normal = SVector::from([1f32, 0f32, 0f32]);
536        let plane = Plane::new(normal, 0f32);
537        let a = Point::from([-2f32, 3f32, 7f32]);
538        let b = Point::from([4f32, 3f32, 7f32]);
539        let result = plane.split_edge(&a, &b);
540        assert!(result.is_ok());
541        let (_, intersection, _) = result.unwrap();
542        assert_eq!(intersection, Point::from([0f32, 3f32, 7f32]));
543    }
544
545    #[test]
546    fn edge_crossing_arbitrary_points() {
547        let normal = SVector::from([0f32, 0f32, 1f32]);
548        let plane = Plane::new(normal, 10f32);
549        let a = Point::from([100f32, 200f32, 5f32]);
550        let b = Point::from([100f32, 200f32, 20f32]);
551        let result = plane.split_edge(&a, &b);
552        assert!(result.is_ok());
553        let (_, intersection, _) = result.unwrap();
554        assert_eq!(intersection, Point::from([100f32, 200f32, 10f32]));
555    }
556
557    #[test]
558    fn edge_crossing_slanted_edge() {
559        let normal = SVector::from([0f32, 0f32, 1f32]);
560        let plane = Plane::new(normal, 1f32);
561        let a = Point::from([0f32, 0f32, 0f32]);
562        let b = Point::from([10f32, 20f32, 2f32]);
563        let result = plane.split_edge(&a, &b);
564        assert!(result.is_ok());
565        let (_, intersection, _) = result.unwrap();
566        assert_eq!(intersection.x, 5f32);
567        assert_eq!(intersection.y, 10f32);
568        assert_eq!(intersection.z, 1f32);
569    }
570
571    #[test]
572    fn edge_crossing_asymmetric() {
573        let normal = SVector::from([0f32, 0f32, 1f32]);
574        let plane = Plane::new(normal, 5f32);
575        let a = Point::from([2f32, -3f32, 2f32]);
576        let b = Point::from([2f32, -3f32, 8f32]);
577        let result = plane.split_edge(&a, &b);
578        assert!(result.is_ok());
579        let (_, intersection, _) = result.unwrap();
580        assert_eq!(intersection, Point::from([2f32, -3f32, 5f32]));
581    }
582
583    #[test]
584    fn edge_crossing_diagonal() {
585        let plane = Plane::xy_plane();
586        let a = Point::from([0f32, 0f32, 1f32]);
587        let b = Point::from([0f32, 0f32, -1f32]);
588        let result = plane.split_edge(&a, &b);
589        assert!(result.is_ok());
590        let (_, intersection, _) = result.unwrap();
591        assert_eq!(intersection.z, 0f32);
592    }
593
594    #[test]
595    fn edge_one_on_plane_with_front() {
596        let plane = Plane::xy_plane();
597        let a = Point::from([0f32, 0f32, 0f32]);
598        let b = Point::from([0f32, 0f32, 5f32]);
599        let result = plane.split_edge(&a, &b);
600        assert!(result.is_ok());
601        let (_, intersection, _) = result.unwrap();
602        assert_eq!(intersection, Point::from([0f32, 0f32, 0f32]));
603    }
604
605    #[test]
606    fn edge_one_on_plane_with_front_reversed() {
607        let plane = Plane::xy_plane();
608        let a = Point::from([0f32, 0f32, 5f32]);
609        let b = Point::from([0f32, 0f32, 0f32]);
610        let result = plane.split_edge(&a, &b);
611        assert!(result.is_ok());
612        let (_, intersection, _) = result.unwrap();
613        assert_eq!(intersection, Point::from([0f32, 0f32, 0f32]));
614    }
615
616    #[test]
617    fn edge_one_on_plane_with_back() {
618        let plane = Plane::xy_plane();
619        let a = Point::from([0f32, 0f32, 0f32]);
620        let b = Point::from([0f32, 0f32, -5f32]);
621        let result = plane.split_edge(&a, &b);
622        assert!(result.is_ok());
623        let (_, intersection, _) = result.unwrap();
624        assert_eq!(intersection, Point::from([0f32, 0f32, 0f32]));
625    }
626
627    #[test]
628    fn edge_one_on_plane_with_back_reversed() {
629        let plane = Plane::xy_plane();
630        let a = Point::from([0f32, 0f32, -5f32]);
631        let b = Point::from([0f32, 0f32, 0f32]);
632        let result = plane.split_edge(&a, &b);
633        assert!(result.is_ok());
634        let (_, intersection, _) = result.unwrap();
635        assert_eq!(intersection, Point::from([0f32, 0f32, 0f32]));
636    }
637
638    #[test]
639    fn split_polygon_triangle_spanning_xy_plane() {
640        let plane = Plane::xy_plane();
641        let polygon = Polygon::from_points(vec![
642            Point3::new(0f32, 0f32, 1f32),
643            Point3::new(1f32, 0f32, 1f32),
644            Point3::new(0.5f32, 1f32, -1f32),
645        ])
646        .unwrap();
647        assert_eq!(
648            plane.classify_polygon(&polygon),
649            PolygonClassification::Spanning
650        );
651        let (front, back) = plane.split_polygon(polygon);
652        assert!(front.vertices().len() >= 3);
653        assert!(back.vertices().len() >= 3);
654        for v in front.vertices().iter() {
655            assert!(plane.classify_point(v) != PlaneSide::Back);
656        }
657        for v in back.vertices().iter() {
658            assert!(plane.classify_point(v) != PlaneSide::Front);
659        }
660    }
661
662    #[test]
663    fn split_polygon_square_spanning_xy_plane() {
664        let plane = Plane::xy_plane();
665        let polygon: Polygon<f32, 3> = Polygon::from_points(vec![
666            Point3::new(-1f32, -1f32, 1f32),
667            Point3::new(1f32, -1f32, 1f32),
668            Point3::new(1f32, 1f32, -1f32),
669            Point3::new(-1f32, 1f32, -1f32),
670        ])
671        .unwrap();
672        assert_eq!(
673            plane.classify_polygon(&polygon),
674            PolygonClassification::Spanning
675        );
676        let (front, back) = plane.split_polygon(polygon);
677        assert!(front.vertices().len() >= 3);
678        assert!(back.vertices().len() >= 3);
679        for v in front.vertices().iter() {
680            assert!(plane.classify_point(v) != PlaneSide::Back);
681        }
682        for v in back.vertices().iter() {
683            assert!(plane.classify_point(v) != PlaneSide::Front);
684        }
685    }
686
687    #[test]
688    fn split_polygon_vertex_counts_sum() {
689        let plane = Plane::xy_plane();
690        let polygon: Polygon<f32, 3> = Polygon::from_points(vec![
691            Point3::new(0f32, 0f32, 2f32),
692            Point3::new(2f32, 0f32, 2f32),
693            Point3::new(2f32, 2f32, -2f32),
694            Point3::new(0f32, 2f32, -2f32),
695        ])
696        .unwrap();
697        let (front, back) = plane.split_polygon(polygon);
698        assert_eq!(front.vertices().len() + back.vertices().len(), 8);
699    }
700
701    #[test]
702    fn split_polygon_offset_plane() {
703        let plane = Plane::new(SVector::from([0f32, 0f32, 1f32]), 1f32);
704        let polygon: Polygon<f32, 3> = Polygon::from_points(vec![
705            Point3::new(0f32, 0f32, 0f32),
706            Point3::new(2f32, 0f32, 0f32),
707            Point3::new(2f32, 2f32, 2f32),
708            Point3::new(0f32, 2f32, 2f32),
709        ])
710        .unwrap();
711        let (front, back) = plane.split_polygon(polygon);
712        for v in front.vertices().iter() {
713            assert!(plane.classify_point(v) != PlaneSide::Back);
714        }
715        for v in back.vertices().iter() {
716            assert!(plane.classify_point(v) != PlaneSide::Front);
717        }
718    }
719
720    /// Assert that `rotation_to` produced a proper rotation matrix `R` (`RᵀR = I`,
721    /// `det R = 1`) that carries the source normal `a` onto the target normal `b`
722    /// within `map_tol`.
723    fn assert_valid_rotation(a: SVector<f64, 3>, b: SVector<f64, 3>, map_tol: f64) {
724        let src = Plane::new(a, 0.0);
725        let dst = Plane::new(b, 0.0);
726        let r = src.rotation_to(dst);
727
728        let ortho_err = (r.transpose() * r - nalgebra::Matrix3::<f64>::identity()).norm();
729        assert!(
730            ortho_err < 1e-9,
731            "‖RᵀR−I‖ = {ortho_err} (a = {a:?}, b = {b:?})"
732        );
733
734        let det = r.determinant();
735        assert!(
736            (det - 1.0).abs() < 1e-9,
737            "det(R) = {det} (a = {a:?}, b = {b:?})"
738        );
739
740        let map_err = (r * src.normal() - dst.normal()).norm();
741        assert!(
742            map_err < map_tol,
743            "‖R·a − b‖ = {map_err} exceeds {map_tol} (a = {a:?}, b = {b:?})"
744        );
745    }
746
747    #[test]
748    fn rotation_to_generic_pair() {
749        // A plain 90° rotation must map the source normal onto the target exactly.
750        let a = SVector::from([1.0, 0.0, 0.0]);
751        let b = SVector::from([0.0, 1.0, 0.0]);
752        assert_valid_rotation(a, b, 1e-12);
753    }
754
755    #[test]
756    fn rotation_to_near_parallel_is_identity() {
757        // cos θ ≈ +1: the general Rodrigues path must still yield a valid rotation
758        // (≈ identity) and not be disturbed by the anti-parallel widening.
759        let a = SVector::from([0.3, -0.5, 0.8]).normalize();
760        let perp = a.cross(&SVector::from([1.0, 0.0, 0.0])).normalize();
761        let b = (a * 1e-7_f64.cos() + perp * 1e-7_f64.sin()).normalize();
762        assert_valid_rotation(a, b, 1e-6);
763    }
764
765    #[test]
766    fn rotation_to_near_and_exact_anti_parallel() {
767        // Base normal (off-axis so no component is trivially zero) plus a unit vector
768        // perpendicular to it, used to tilt `-a` by a tiny angle δ away from exactly
769        // anti-parallel. Before the fix, every δ below reached the unstable
770        // 1/(1 + cos θ) branch and produced a non-orthogonal matrix (det ≠ 1).
771        let a = SVector::from([0.3, -0.5, 0.8]).normalize();
772        let perp = a.cross(&SVector::from([1.0, 0.0, 0.0])).normalize();
773
774        // δ = 0 (exactly anti-parallel) via cos 0 = 1, sin 0 = 0, plus the pathological
775        // deltas flagged in issue #100.
776        for &delta in &[0.0_f64, 5e-8, 1e-7, 1e-6] {
777            let b = (-a * delta.cos() + perp * delta.sin()).normalize();
778            // R maps a to exactly −a, which differs from b by ~δ, so the mapping
779            // tolerance absorbs δ; orthogonality and det stay pinned at 1e-9.
780            assert_valid_rotation(a, b, 1e-5);
781        }
782    }
783}