axiolid_core/
space_frame.rs

1//! A full 3D orthonormal frame: the boundary between model space and a
2//! local coordinate system with three axes.
3//!
4//! # Why this exists alongside PlaneFrame
5//!
6//! PlaneFrame answers "where is this point on that plane". A SpaceFrame
7//! answers "what are this point coordinates in that local system", keeping
8//! the third axis. Surface evaluation, sampled fields, and sectioning all
9//! need the second question.
10//!
11//! Three call sites previously each rolled their own validity rule under a
12//! DIFFERENT tolerance policy, and only two of the three checked handedness.
13//! A left-handed basis passes every unit-length and perpendicularity test
14//! while mirroring the geometry, so omitting that check silently reflects
15//! whatever the frame maps.
16
17use crate::plane_frame::FrameError;
18use crate::primitives::{Point3, Vec3};
19use crate::scalar::{Scalar, Tolerance};
20
21/// An origin and a right-handed orthonormal triad of axes.
22///
23/// # Invariant
24///
25/// A `SpaceFrame` that exists is valid: finite, unit-length on every axis,
26/// mutually perpendicular, and right-handed, all judged when it was built.
27/// Fields are private so a struct literal cannot bypass that.
28#[derive(Debug, Clone, Copy, PartialEq)]
29pub struct SpaceFrame {
30    origin: Point3,
31    x: Vec3,
32    y: Vec3,
33    z: Vec3,
34}
35
36impl SpaceFrame {
37    /// The world axes at the origin.
38    #[must_use]
39    pub const fn world() -> Self {
40        Self {
41            origin: Point3::new(0.0, 0.0, 0.0),
42            x: Vec3::new(1.0, 0.0, 0.0),
43            y: Vec3::new(0.0, 1.0, 0.0),
44            z: Vec3::new(0.0, 0.0, 1.0),
45        }
46    }
47
48    /// Validate a basis and build a frame from it.
49    ///
50    /// Unit length, perpendicularity, and handedness are all DIMENSIONLESS
51    /// properties, so all three are judged against the ANGULAR tolerance.
52    /// Using the linear tolerance would tie a pure direction test to the
53    /// model length unit.
54    ///
55    /// Handedness is checked explicitly because it is invisible to the other
56    /// two tests: a mirrored basis is perfectly orthonormal and still
57    /// reflects every point it maps.
58    ///
59    /// # Errors
60    ///
61    /// Returns the property that failed, so a caller can name the cause.
62    pub fn new(
63        origin: Point3,
64        x: Vec3,
65        y: Vec3,
66        z: Vec3,
67        tolerance: Tolerance,
68    ) -> Result<Self, FrameError> {
69        if !origin.is_finite() || !x.is_finite() || !y.is_finite() || !z.is_finite() {
70            return Err(FrameError::NonFiniteInput);
71        }
72        // Floored at a few ulps so Tolerance::ZERO still admits bases that
73        // are correct to the limit of f64.
74        let unit = tolerance.angular().max(Scalar::EPSILON * 8.0);
75        let lengths = [x.length_squared(), y.length_squared(), z.length_squared()];
76        if lengths.iter().any(|l| (l - 1.0).abs() > unit) {
77            return Err(FrameError::NotUnitLength);
78        }
79        if x.dot(y).abs() > unit || y.dot(z).abs() > unit || z.dot(x).abs() > unit {
80            return Err(FrameError::NotPerpendicular);
81        }
82        // On an orthonormal triad this triple product is exactly +1 for a
83        // right-handed basis and -1 for a mirrored one, so comparing against
84        // +1 separates them by a margin of 2 rather than by a tolerance.
85        if (x.cross(y).dot(z) - 1.0).abs() > unit {
86            return Err(FrameError::NotRightHanded);
87        }
88        Ok(Self { origin, x, y, z })
89    }
90
91    /// Map a model-space point into local coordinates.
92    ///
93    /// Exact inverse of [`to_world`](Self::to_world): the frame is orthonormal,
94    /// so the inverse of the rotation is its transpose, which is a projection
95    /// onto the axes.
96    #[must_use]
97    pub fn to_local(&self, point: Point3) -> Vec3 {
98        let offset = point - self.origin;
99        Vec3::new(offset.dot(self.x), offset.dot(self.y), offset.dot(self.z))
100    }
101
102    /// Map local coordinates back into model space.
103    #[must_use]
104    pub fn to_world(&self, local: Vec3) -> Point3 {
105        self.origin + self.x * local.x + self.y * local.y + self.z * local.z
106    }
107
108    /// The frame origin.
109    #[must_use]
110    pub const fn origin(&self) -> Point3 {
111        self.origin
112    }
113
114    /// The local x axis.
115    #[must_use]
116    pub const fn x(&self) -> Vec3 {
117        self.x
118    }
119
120    /// The local y axis.
121    #[must_use]
122    pub const fn y(&self) -> Vec3 {
123        self.y
124    }
125
126    /// The local z axis.
127    #[must_use]
128    pub const fn z(&self) -> Vec3 {
129        self.z
130    }
131}