axiolid_core/plane_frame.rs
1//! An in-plane coordinate frame: the boundary between 3D model space and a
2//! plane's own 2D parameter space.
3//!
4//! # Why this is a type rather than three loose vectors
5//!
6//! [`Frame2`](crate::primitives::Frame2) and [`Frame3`](crate::primitives::Frame3)
7//! are inert storage: they hold axes but carry no
8//! behaviour, so every consumer re-derives the same two operations and its own
9//! validity rule. That produced four separate orthonormality checks in this
10//! workspace under three different tolerance policies, one of which compared a
11//! DIMENSIONLESS dot product against the LINEAR tolerance and so scaled a pure
12//! direction test with the model length unit.
13//!
14//! A frame that validates on construction and owns both directions of the map
15//! makes that class of bug unrepresentable.
16
17use crate::primitives::{Point2, Point3, Vec3};
18use crate::scalar::{Scalar, Tolerance};
19
20/// Why a basis could not form an in-plane frame.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22#[non_exhaustive]
23pub enum FrameError {
24 /// An origin or axis component was NaN or infinite.
25 NonFiniteInput,
26 /// An axis was not unit length within the angular tolerance.
27 NotUnitLength,
28 /// The two axes were not perpendicular within the angular tolerance.
29 NotPerpendicular,
30 /// The axes were parallel, so they span a line rather than a plane.
31 Degenerate,
32 /// The basis is mirrored: orthonormal, but left-handed.
33 NotRightHanded,
34}
35
36impl core::fmt::Display for FrameError {
37 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
38 f.write_str(match self {
39 Self::NonFiniteInput => "frame origin and axes must be finite",
40 Self::NotUnitLength => "frame axes must be unit length",
41 Self::NotPerpendicular => "frame axes must be perpendicular",
42 Self::Degenerate => "plane frame axes must span a plane, not a line",
43 Self::NotRightHanded => "frame axes must form a right-handed basis",
44 })
45 }
46}
47
48impl std::error::Error for FrameError {}
49
50/// An origin and a right-handed orthonormal pair of in-plane axes.
51///
52/// # Invariant
53///
54/// A `PlaneFrame` that exists is orthonormal within the tolerance it was
55/// built with. [`project`](Self::project) and [`lift`](Self::lift) are exact
56/// inverses on that basis, so the type cannot be used to produce the silently
57/// wrong coordinates a skewed basis yields.
58///
59/// The fields are private precisely because the invariant is the point: a
60/// public `x` would let a caller reassign one axis and keep the type.
61#[derive(Debug, Clone, Copy, PartialEq)]
62pub struct PlaneFrame {
63 origin: Point3,
64 x: Vec3,
65 y: Vec3,
66}
67
68impl PlaneFrame {
69 /// The z = 0 ground plane with the world x and y axes.
70 #[must_use]
71 pub const fn ground() -> Self {
72 Self {
73 origin: Point3::new(0.0, 0.0, 0.0),
74 x: Vec3::new(1.0, 0.0, 0.0),
75 y: Vec3::new(0.0, 1.0, 0.0),
76 }
77 }
78
79 /// Validate a basis and build a frame from it.
80 ///
81 /// Unit length and perpendicularity are both DIMENSIONLESS comparisons, so
82 /// both use the ANGULAR tolerance. Using the linear tolerance here would
83 /// make a pure direction test scale with the model length unit: the same
84 /// skewed basis would be refused in metres and accepted in millimetres.
85 ///
86 /// # Errors
87 ///
88 /// Returns [`FrameError`] naming which property failed, so a caller
89 /// can report the cause rather than a bare rejection.
90 pub fn new(origin: Point3, x: Vec3, y: Vec3, tolerance: Tolerance) -> Result<Self, FrameError> {
91 if !origin.is_finite() || !x.is_finite() || !y.is_finite() {
92 return Err(FrameError::NonFiniteInput);
93 }
94 // Floor the tolerance at a few ulps: a caller passing Tolerance::ZERO
95 // means "exact", but demanding a bit-exact unit length would reject
96 // bases that are correct to the limit of f64.
97 let unit = tolerance.angular().max(Scalar::EPSILON * 8.0);
98 if (x.length_squared() - 1.0).abs() > unit || (y.length_squared() - 1.0).abs() > unit {
99 return Err(FrameError::NotUnitLength);
100 }
101 if x.cross(y).length_squared() <= unit {
102 return Err(FrameError::Degenerate);
103 }
104 if x.dot(y).abs() > unit {
105 return Err(FrameError::NotPerpendicular);
106 }
107 Ok(Self { origin, x, y })
108 }
109
110 /// Map a model-space point to its in-plane coordinates.
111 ///
112 /// Points off the plane project ONTO it: the out-of-plane component is
113 /// discarded, not reported. Use [`signed_distance`](Self::signed_distance)
114 /// when the caller needs to know how far off the plane the point was.
115 #[must_use]
116 pub fn project(&self, point: Point3) -> Point2 {
117 let offset = point - self.origin;
118 Point2::new(offset.dot(self.x), offset.dot(self.y))
119 }
120
121 /// Map in-plane coordinates back to model space.
122 #[must_use]
123 pub fn lift(&self, point: Point2) -> Point3 {
124 self.origin + self.x * point.x + self.y * point.y
125 }
126
127 /// Signed distance from the plane, positive along the normal.
128 #[must_use]
129 pub fn signed_distance(&self, point: Point3) -> Scalar {
130 (point - self.origin).dot(self.normal())
131 }
132
133 /// The right-handed normal, `x` cross `y`.
134 ///
135 /// Derived rather than stored: a stored normal is a third value that can
136 /// disagree with the axes, which is the inconsistency this type exists to
137 /// prevent.
138 #[must_use]
139 pub fn normal(&self) -> Vec3 {
140 self.x.cross(self.y)
141 }
142
143 /// Build a frame from a normal, choosing an arbitrary in-plane x axis.
144 ///
145 /// Use when the caller cares about the PLANE but not about which in-plane
146 /// direction is x, such as sectioning. When the x direction is meaningful
147 /// (a drawing's horizontal, a wall's length), pass it explicitly via
148 /// [`new`](Self::new) instead of accepting whatever this picks.
149 ///
150 /// # Errors
151 ///
152 /// Returns [`FrameError`] when the normal is non-finite or too short
153 /// to normalize.
154 pub fn from_normal(
155 origin: Point3,
156 normal: Vec3,
157 tolerance: Tolerance,
158 ) -> Result<Self, FrameError> {
159 if !origin.is_finite() || !normal.is_finite() {
160 return Err(FrameError::NonFiniteInput);
161 }
162 let unit = tolerance.angular().max(Scalar::EPSILON * 8.0);
163 if normal.length_squared() <= unit {
164 return Err(FrameError::Degenerate);
165 }
166 let z = normal.normalize();
167 // Seed against the world axis the normal is LEAST aligned with, so the
168 // cross product never approaches zero and the resulting axis is stable.
169 let seed = if z.x.abs() <= z.y.abs() && z.x.abs() <= z.z.abs() {
170 Vec3::new(1.0, 0.0, 0.0)
171 } else if z.y.abs() <= z.z.abs() {
172 Vec3::new(0.0, 1.0, 0.0)
173 } else {
174 Vec3::new(0.0, 0.0, 1.0)
175 };
176 let x = z.cross(seed).normalize();
177 Ok(Self {
178 origin,
179 x,
180 y: z.cross(x),
181 })
182 }
183
184 /// The frame origin.
185 #[must_use]
186 pub const fn origin(&self) -> Point3 {
187 self.origin
188 }
189
190 /// The in-plane axis mapped to the planar x coordinate.
191 #[must_use]
192 pub const fn x_axis(&self) -> Vec3 {
193 self.x
194 }
195
196 /// The in-plane axis mapped to the planar y coordinate.
197 #[must_use]
198 pub const fn y_axis(&self) -> Vec3 {
199 self.y
200 }
201}