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}