Skip to main content

brepkit_math/
frame.rs

1//! Orthonormal reference frame in 3D space.
2//!
3//! [`Frame3`] bundles an origin with three mutually perpendicular unit axes.
4//! It replaces the 14+ hand-rolled "pick a candidate, cross twice" patterns
5//! scattered across surfaces, curves, and intersection code.
6
7use crate::MathError;
8use crate::vec::{Point3, Vec3};
9
10/// An orthonormal reference frame: origin + three mutually perpendicular unit
11/// axes (`x`, `y`, `z`).
12///
13/// The `z` axis is the *primary* direction (surface normal, curve axis, etc.).
14/// `x` and `y` span the plane perpendicular to `z`.
15#[derive(Debug, Clone, Copy, PartialEq)]
16pub struct Frame3 {
17    /// Frame origin.
18    pub origin: Point3,
19    /// First axis in the perpendicular plane.
20    pub x: Vec3,
21    /// Second axis in the perpendicular plane.
22    pub y: Vec3,
23    /// Primary axis / normal.
24    pub z: Vec3,
25}
26
27impl Frame3 {
28    /// Build an orthonormal frame from an origin and a primary axis (normal).
29    ///
30    /// `x` and `y` are chosen arbitrarily in the plane perpendicular to `z`.
31    /// The input `normal` is normalized internally.
32    ///
33    /// # Errors
34    ///
35    /// Returns [`MathError::ZeroVector`] if `normal` is zero-length.
36    pub fn from_normal(origin: Point3, normal: Vec3) -> Result<Self, MathError> {
37        let z = normal.normalize()?;
38        let (x, y) = perpendicular_pair(z)?;
39        Ok(Self { origin, x, y, z })
40    }
41
42    /// Build an orthonormal frame from an origin, a primary axis, and a
43    /// preferred reference direction for `x`.
44    ///
45    /// `ref_dir` is projected onto the plane perpendicular to `z` to produce
46    /// `x`. If `ref_dir` is (nearly) parallel to `z`, the frame falls back to
47    /// an arbitrary perpendicular choice.
48    ///
49    /// # Errors
50    ///
51    /// Returns [`MathError::ZeroVector`] if `normal` is zero-length.
52    pub fn from_normal_and_ref(
53        origin: Point3,
54        normal: Vec3,
55        ref_dir: Vec3,
56    ) -> Result<Self, MathError> {
57        let z = normal.normalize()?;
58        let ref_proj = ref_dir - z * ref_dir.dot(z);
59        let x = if let Ok(v) = ref_proj.normalize() {
60            v
61        } else {
62            // ref_dir is parallel to z — fall back to arbitrary choice.
63            let (arb_x, _) = perpendicular_pair(z)?;
64            arb_x
65        };
66        let y = z.cross(x);
67        Ok(Self { origin, x, y, z })
68    }
69}
70
71/// Given a unit vector `z`, return two unit vectors `(x, y)` forming an
72/// orthonormal basis where `x = z × candidate` and `y = z × x`.
73fn perpendicular_pair(z: Vec3) -> Result<(Vec3, Vec3), MathError> {
74    let candidate = if z.x().abs() < 0.9 {
75        Vec3::new(1.0, 0.0, 0.0)
76    } else {
77        Vec3::new(0.0, 1.0, 0.0)
78    };
79    let x = z.cross(candidate).normalize()?;
80    let y = z.cross(x);
81    Ok((x, y))
82}
83
84#[cfg(test)]
85#[allow(clippy::unwrap_used, clippy::expect_used)]
86mod tests {
87    use super::*;
88    use proptest::prelude::*;
89
90    #[test]
91    fn frame_from_z_axis() {
92        let f = Frame3::from_normal(Point3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 5.0))
93            .expect("non-zero");
94        assert!((f.z.z() - 1.0).abs() < 1e-14);
95        assert!(f.x.dot(f.y).abs() < 1e-14);
96        assert!(f.x.dot(f.z).abs() < 1e-14);
97        assert!(f.y.dot(f.z).abs() < 1e-14);
98    }
99
100    #[test]
101    fn frame_from_x_axis() {
102        // Tests the branch where x_component > 0.9
103        let f = Frame3::from_normal(Point3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0))
104            .expect("non-zero");
105        assert!((f.z.x() - 1.0).abs() < 1e-14);
106        assert!(f.x.dot(f.z).abs() < 1e-14);
107    }
108
109    #[test]
110    fn frame_with_ref_dir() {
111        let f = Frame3::from_normal_and_ref(
112            Point3::new(0.0, 0.0, 0.0),
113            Vec3::new(0.0, 0.0, 1.0),
114            Vec3::new(1.0, 0.0, 0.0),
115        )
116        .expect("non-zero");
117        // x should align with the reference direction
118        assert!((f.x.x() - 1.0).abs() < 1e-14);
119        assert!(f.x.y().abs() < 1e-14);
120    }
121
122    #[test]
123    fn frame_with_parallel_ref_falls_back() {
124        // ref_dir parallel to normal — should still produce valid frame
125        let f = Frame3::from_normal_and_ref(
126            Point3::new(0.0, 0.0, 0.0),
127            Vec3::new(0.0, 0.0, 1.0),
128            Vec3::new(0.0, 0.0, 1.0),
129        )
130        .expect("non-zero");
131        assert!(f.x.dot(f.z).abs() < 1e-14);
132        assert!((f.x.length() - 1.0).abs() < 1e-14);
133    }
134
135    #[test]
136    fn zero_normal_errors() {
137        let r = Frame3::from_normal(Point3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0));
138        assert!(r.is_err());
139    }
140
141    proptest! {
142        #[test]
143        fn prop_frame_orthonormal(
144            nx in -10.0f64..10.0, ny in -10.0f64..10.0, nz in -10.0f64..10.0,
145        ) {
146            let n = Vec3::new(nx, ny, nz);
147            if let Ok(f) = Frame3::from_normal(Point3::new(0.0, 0.0, 0.0), n) {
148                // All axes are unit length
149                prop_assert!((f.x.length() - 1.0).abs() < 1e-12);
150                prop_assert!((f.y.length() - 1.0).abs() < 1e-12);
151                prop_assert!((f.z.length() - 1.0).abs() < 1e-12);
152                // Mutually perpendicular
153                prop_assert!(f.x.dot(f.y).abs() < 1e-12);
154                prop_assert!(f.x.dot(f.z).abs() < 1e-12);
155                prop_assert!(f.y.dot(f.z).abs() < 1e-12);
156                // Right-handed: x × y ≈ z
157                let cross = f.x.cross(f.y);
158                prop_assert!((cross.x() - f.z.x()).abs() < 1e-12);
159                prop_assert!((cross.y() - f.z.y()).abs() < 1e-12);
160                prop_assert!((cross.z() - f.z.z()).abs() < 1e-12);
161            }
162        }
163    }
164}