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}