Skip to main content

arris_math/
frame.rs

1//! Right-handed 3D frames, and 2D frames of either handedness.
2
3use core::fmt;
4
5use nalgebra::UnitQuaternion;
6
7use crate::{Isometry, Point2, Point3, UnitVec2, UnitVec3, Vec2, Vec3};
8
9/// Why an origin and some directions are not a frame.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum FrameError {
12    /// A coordinate is NaN or infinite.
13    NonFinite,
14    /// The axis has zero length.
15    ZeroAxis,
16    /// The `x` hint has no component perpendicular to the axis: it is zero
17    /// or exactly parallel to `z`.
18    DegenerateHint,
19    /// Axes given as a frame are not unit and mutually perpendicular to
20    /// rounding, or `z ≠ x × y` for a 3D frame.
21    NotOrthonormal,
22}
23
24impl fmt::Display for FrameError {
25    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26        f.write_str(match self {
27            FrameError::NonFinite => "frame has a non-finite coordinate",
28            FrameError::ZeroAxis => "frame axis has zero length",
29            FrameError::DegenerateHint => "frame x hint is zero or parallel to the axis",
30            FrameError::NotOrthonormal => "frame axes are not orthonormal",
31        })
32    }
33}
34
35impl std::error::Error for FrameError {}
36
37/// A right-handed orthonormal frame: an origin and axes `x`, `y`, `z` with
38/// `x × y = z`, each unit to within rounding, whatever the constructor was
39/// given. Every analytic surface and curve is placed by one, so a
40/// transform is a frame change and nothing else (`docs/DATA-MODEL.md`
41/// §Conventions).
42///
43/// Only the validating constructors build one; there is no way to hold a
44/// `Frame` whose axes are not orthonormal.
45///
46/// ```
47/// use arris_math::{Frame, Point3, Vec3};
48///
49/// let f = Frame::new(Point3::new(1.0, 2.0, 3.0), Vec3::z(), Vec3::new(1.0, 1.0, 0.0)).unwrap();
50/// let local = Point3::new(1.0, 0.0, 0.0);
51/// let world = f.to_world(local);
52/// assert!((f.to_local(world) - local).norm() < 1e-15);
53/// assert!((f.x().dot(&f.y())).abs() < 1e-15);
54/// ```
55#[derive(Debug, Clone, Copy, PartialEq)]
56#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
57#[cfg_attr(feature = "serde", serde(try_from = "FrameRepr", into = "FrameRepr"))]
58pub struct Frame {
59    origin: Point3,
60    x: UnitVec3,
61    y: UnitVec3,
62    z: UnitVec3,
63}
64
65/// The wire form of a [`Frame`]: its four fields as given, validated by
66/// [`Frame::from_orthonormal`] on the way in so a stored frame is never
67/// less of a frame than a built one.
68#[cfg(feature = "serde")]
69#[derive(serde::Serialize, serde::Deserialize)]
70struct FrameRepr {
71    origin: Point3,
72    x: Vec3,
73    y: Vec3,
74    z: Vec3,
75}
76
77#[cfg(feature = "serde")]
78impl From<Frame> for FrameRepr {
79    fn from(f: Frame) -> Self {
80        FrameRepr {
81            origin: f.origin,
82            x: f.x.into_inner(),
83            y: f.y.into_inner(),
84            z: f.z.into_inner(),
85        }
86    }
87}
88
89#[cfg(feature = "serde")]
90impl TryFrom<FrameRepr> for Frame {
91    type Error = FrameError;
92
93    fn try_from(r: FrameRepr) -> Result<Self, FrameError> {
94        Frame::from_orthonormal(r.origin, r.x, r.y, r.z)
95    }
96}
97
98impl Frame {
99    /// The world frame: origin at zero, axes the coordinate axes.
100    pub fn world() -> Self {
101        Frame {
102            origin: Point3::origin(),
103            x: Vec3::x_axis(),
104            y: Vec3::y_axis(),
105            z: Vec3::z_axis(),
106        }
107    }
108
109    /// A frame with axis `z` (normalised) and `x` the direction of
110    /// `x_hint`'s component perpendicular to `z`; `y = z × x`.
111    ///
112    /// Errors: a non-finite input, a zero `z`, or a hint with no
113    /// perpendicular component. A hint that is *nearly* parallel to `z`
114    /// still yields an orthonormal frame, but its `x` is whatever rounding
115    /// left of the perpendicular component; a caller who wants to reject
116    /// that compares the hint against `z` with its own `Tolerance` first.
117    pub fn new(origin: Point3, z: Vec3, x_hint: Vec3) -> Result<Self, FrameError> {
118        if !(is_finite3(&origin.coords) && is_finite3(&z) && is_finite3(&x_hint)) {
119            return Err(FrameError::NonFinite);
120        }
121        let z = UnitVec3::try_new(z, 0.0).ok_or(FrameError::ZeroAxis)?;
122        let perpendicular = x_hint - z.dot(&x_hint) * z.into_inner();
123        let x = UnitVec3::try_new(perpendicular, 0.0).ok_or(FrameError::DegenerateHint)?;
124        Ok(Self::orthonormalised(origin, x, z))
125    }
126
127    /// A frame with axis `z` and `x` chosen by the rule Open CASCADE's
128    /// `gp_Ax3(P, N)` uses (read in the reference tree's `gp` package,
129    /// reimplemented): zero the axis coordinate of smallest magnitude, swap
130    /// the other two with the sign that keeps the larger one, so a
131    /// cylinder built from an axis alone seams where the oracle's does.
132    /// For `z` along a coordinate axis: `+z ↦ x = +x`, `+x ↦ x = +z`,
133    /// `+y ↦ x = +z`.
134    ///
135    /// Errors: a non-finite input or a zero `z`.
136    pub fn from_z(origin: Point3, z: Vec3) -> Result<Self, FrameError> {
137        if !(is_finite3(&origin.coords) && is_finite3(&z)) {
138            return Err(FrameError::NonFinite);
139        }
140        let z = UnitVec3::try_new(z, 0.0).ok_or(FrameError::ZeroAxis)?;
141        let (a, b, c) = (z.x, z.y, z.z);
142        let (aa, ba, ca) = (a.abs(), b.abs(), c.abs());
143        let hint = if ba <= aa && ba <= ca {
144            if aa > ca {
145                Vec3::new(-c, 0.0, a)
146            } else {
147                Vec3::new(c, 0.0, -a)
148            }
149        } else if aa <= ba && aa <= ca {
150            if ba > ca {
151                Vec3::new(0.0, -c, b)
152            } else {
153                Vec3::new(0.0, c, -b)
154            }
155        } else if aa > ba {
156            Vec3::new(-b, a, 0.0)
157        } else {
158            Vec3::new(b, -a, 0.0)
159        };
160        // `hint` is perpendicular to `z` by construction and has the norm of
161        // the two larger coordinates, so it is never zero for a unit `z`.
162        let x = UnitVec3::try_new(hint, 0.0).ok_or(FrameError::ZeroAxis)?;
163        Ok(Self::orthonormalised(origin, x, z))
164    }
165
166    /// A frame from axes that already are one: each unit, mutually
167    /// perpendicular and `z = x × y`, all to rounding
168    /// ([`crate::RELATIVE_ROUNDING`]), stored bit for bit — what the
169    /// native format reads a frame back through, so a round trip changes
170    /// nothing. Errors: a non-finite input, or
171    /// [`FrameError::NotOrthonormal`].
172    ///
173    /// ```
174    /// use arris_math::{Frame, FrameError, Point3, Vec3};
175    ///
176    /// let f = Frame::from_orthonormal(Point3::origin(), Vec3::y(), Vec3::z(), Vec3::x()).unwrap();
177    /// assert_eq!(f.x().into_inner(), Vec3::y());
178    /// let bad = Frame::from_orthonormal(Point3::origin(), Vec3::x(), Vec3::x(), Vec3::z());
179    /// assert_eq!(bad, Err(FrameError::NotOrthonormal));
180    /// ```
181    pub fn from_orthonormal(origin: Point3, x: Vec3, y: Vec3, z: Vec3) -> Result<Self, FrameError> {
182        if !(is_finite3(&origin.coords) && is_finite3(&x) && is_finite3(&y) && is_finite3(&z)) {
183            return Err(FrameError::NonFinite);
184        }
185        let unit = |v: &Vec3| crate::is_negligible(v.norm() - 1.0, 1.0);
186        let perpendicular = |a: &Vec3, b: &Vec3| crate::is_negligible(a.dot(b), 1.0);
187        if !(unit(&x) && unit(&y) && unit(&z))
188            || !(perpendicular(&x, &y) && perpendicular(&y, &z) && perpendicular(&z, &x))
189            || !crate::is_negligible((x.cross(&y) - z).norm(), 1.0)
190        {
191            return Err(FrameError::NotOrthonormal);
192        }
193        Ok(Frame {
194            origin,
195            x: UnitVec3::new_unchecked(x),
196            y: UnitVec3::new_unchecked(y),
197            z: UnitVec3::new_unchecked(z),
198        })
199    }
200
201    /// The frame whose axes are the images of the coordinate axes under
202    /// `rotation`. Infallible: a unit quaternion's basis is orthonormal.
203    pub fn from_rotation(origin: Point3, rotation: &UnitQuaternion<f64>) -> Self {
204        let x = UnitVec3::new_normalize(rotation.transform_vector(&Vec3::x()));
205        let z = UnitVec3::new_normalize(rotation.transform_vector(&Vec3::z()));
206        Self::orthonormalised(origin, x, z)
207    }
208
209    /// Rebuilds `y` and `x` from `z` and an `x` that is unit and nearly
210    /// perpendicular to it, so the result is orthonormal to rounding
211    /// regardless of how good the input was.
212    fn orthonormalised(origin: Point3, x: UnitVec3, z: UnitVec3) -> Self {
213        let y = UnitVec3::new_normalize(z.cross(&x));
214        let x = UnitVec3::new_normalize(y.cross(&z));
215        Frame { origin, x, y, z }
216    }
217
218    /// The same axes at another origin, bit for bit: a translation that
219    /// leaves the orientation untouched, where `transformed` by a pure
220    /// translation would re-round the axes through the identity rotation.
221    pub const fn with_origin(&self, origin: Point3) -> Frame {
222        Frame {
223            origin,
224            x: self.x,
225            y: self.y,
226            z: self.z,
227        }
228    }
229
230    /// The origin.
231    pub const fn origin(&self) -> Point3 {
232        self.origin
233    }
234
235    /// The `x` axis.
236    pub const fn x(&self) -> UnitVec3 {
237        self.x
238    }
239
240    /// The `y` axis, `z × x`.
241    pub const fn y(&self) -> UnitVec3 {
242        self.y
243    }
244
245    /// The `z` axis, `x × y`.
246    pub const fn z(&self) -> UnitVec3 {
247        self.z
248    }
249
250    /// The coordinates of a world point in this frame.
251    pub fn to_local(&self, p: Point3) -> Point3 {
252        Point3::from(self.vec_to_local(p - self.origin))
253    }
254
255    /// The world point at local coordinates `p`.
256    pub fn to_world(&self, p: Point3) -> Point3 {
257        self.origin + self.vec_to_world(p.coords)
258    }
259
260    /// The components of a world vector along the axes.
261    pub fn vec_to_local(&self, v: Vec3) -> Vec3 {
262        Vec3::new(v.dot(&self.x), v.dot(&self.y), v.dot(&self.z))
263    }
264
265    /// The world vector with local components `v`.
266    pub fn vec_to_world(&self, v: Vec3) -> Vec3 {
267        v.x * self.x.into_inner() + v.y * self.y.into_inner() + v.z * self.z.into_inner()
268    }
269
270    /// The rotation taking the coordinate axes onto this frame's axes.
271    pub fn rotation(&self) -> UnitQuaternion<f64> {
272        UnitQuaternion::from_basis_unchecked(&[
273            self.x.into_inner(),
274            self.y.into_inner(),
275            self.z.into_inner(),
276        ])
277    }
278
279    /// The rigid motion taking local coordinates to world coordinates:
280    /// `as_isometry().apply(p) == to_world(p)` to rounding.
281    pub fn as_isometry(&self) -> Isometry {
282        Isometry::new(self.rotation(), self.origin.coords)
283    }
284
285    /// This frame moved by `motion`. Moving geometry is moving its frame,
286    /// and this is that.
287    pub fn transformed(&self, motion: &Isometry) -> Frame {
288        Self::orthonormalised(
289            motion.apply(self.origin),
290            motion.apply_unit(self.x),
291            motion.apply_unit(self.z),
292        )
293    }
294}
295
296fn is_finite3(v: &Vec3) -> bool {
297    v.iter().all(|c| c.is_finite())
298}
299
300/// Which way a [`Frame2`]'s `y` turns from its `x`.
301#[derive(Debug, Clone, Copy, PartialEq, Eq)]
302pub enum Handedness {
303    /// `y` is `x` rotated by +90°: `(u, v)` axes in their natural order.
304    Right,
305    /// `y` is `x` rotated by −90°: a reflection of the right-handed frame.
306    Left,
307}
308
309/// An orthonormal frame in a surface's (u, v) plane, of either handedness.
310/// A pcurve placed by a left-handed `Frame2` is traversed clockwise in
311/// (u, v) — the case of a circle shared by a cap and a wall whose normal
312/// opposes the circle's `Z` (`docs/DATA-MODEL.md` §Pcurves).
313///
314/// ```
315/// use arris_math::{Frame2, Handedness, Point2, Vec2};
316///
317/// let f = Frame2::new(Point2::new(1.0, 1.0), Vec2::new(0.0, 2.0), Handedness::Left).unwrap();
318/// assert!(!f.is_right_handed());
319/// assert_eq!(f.y().into_inner(), Vec2::new(1.0, 0.0));
320/// assert_eq!(f.to_world(Point2::new(1.0, 1.0)), Point2::new(2.0, 2.0));
321/// ```
322#[derive(Debug, Clone, Copy, PartialEq)]
323#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
324#[cfg_attr(feature = "serde", serde(try_from = "Frame2Repr", into = "Frame2Repr"))]
325pub struct Frame2 {
326    origin: Point2,
327    x: UnitVec2,
328    y: UnitVec2,
329}
330
331/// The wire form of a [`Frame2`], validated by [`Frame2::from_orthonormal`]
332/// on the way in.
333#[cfg(feature = "serde")]
334#[derive(serde::Serialize, serde::Deserialize)]
335struct Frame2Repr {
336    origin: Point2,
337    x: Vec2,
338    y: Vec2,
339}
340
341#[cfg(feature = "serde")]
342impl From<Frame2> for Frame2Repr {
343    fn from(f: Frame2) -> Self {
344        Frame2Repr {
345            origin: f.origin,
346            x: f.x.into_inner(),
347            y: f.y.into_inner(),
348        }
349    }
350}
351
352#[cfg(feature = "serde")]
353impl TryFrom<Frame2Repr> for Frame2 {
354    type Error = FrameError;
355
356    fn try_from(r: Frame2Repr) -> Result<Self, FrameError> {
357        Frame2::from_orthonormal(r.origin, r.x, r.y)
358    }
359}
360
361impl Frame2 {
362    /// Origin at zero, `x` along `u`, `y` along `v`: right-handed.
363    pub fn identity() -> Self {
364        Frame2 {
365            origin: Point2::origin(),
366            x: Vec2::x_axis(),
367            y: Vec2::y_axis(),
368        }
369    }
370
371    /// A frame with `x` along `x` (normalised) and `y` perpendicular to it
372    /// on the side `handedness` says. Errors: a non-finite input or a zero
373    /// `x`.
374    pub fn new(origin: Point2, x: Vec2, handedness: Handedness) -> Result<Self, FrameError> {
375        if !(origin.coords.iter().all(|c| c.is_finite()) && x.iter().all(|c| c.is_finite())) {
376            return Err(FrameError::NonFinite);
377        }
378        let x = UnitVec2::try_new(x, 0.0).ok_or(FrameError::ZeroAxis)?;
379        let y = match handedness {
380            Handedness::Right => Vec2::new(-x.y, x.x),
381            Handedness::Left => Vec2::new(x.y, -x.x),
382        };
383        Ok(Frame2 {
384            origin,
385            x,
386            y: UnitVec2::new_unchecked(y),
387        })
388    }
389
390    /// A frame from axes that already are one — each unit and
391    /// perpendicular to rounding ([`crate::RELATIVE_ROUNDING`]), of either
392    /// handedness — stored bit for bit; what the native format reads a
393    /// `Frame2` back through. Errors: a non-finite input, or
394    /// [`FrameError::NotOrthonormal`].
395    pub fn from_orthonormal(origin: Point2, x: Vec2, y: Vec2) -> Result<Self, FrameError> {
396        let finite = |v: &Vec2| v.iter().all(|c| c.is_finite());
397        if !(finite(&origin.coords) && finite(&x) && finite(&y)) {
398            return Err(FrameError::NonFinite);
399        }
400        let unit = |v: &Vec2| crate::is_negligible(v.norm() - 1.0, 1.0);
401        if !(unit(&x) && unit(&y)) || !crate::is_negligible(x.dot(&y), 1.0) {
402            return Err(FrameError::NotOrthonormal);
403        }
404        Ok(Frame2 {
405            origin,
406            x: UnitVec2::new_unchecked(x),
407            y: UnitVec2::new_unchecked(y),
408        })
409    }
410
411    /// The origin.
412    pub const fn origin(&self) -> Point2 {
413        self.origin
414    }
415
416    /// The same axes at the origin moved by `by`.
417    ///
418    /// ```
419    /// use arris_math::{Frame2, Point2, Vec2};
420    ///
421    /// let f = Frame2::identity().translated(Vec2::new(1.0, 2.0));
422    /// assert_eq!(f.origin(), Point2::new(1.0, 2.0));
423    /// assert_eq!(f.x(), Frame2::identity().x());
424    /// ```
425    pub fn translated(&self, by: Vec2) -> Frame2 {
426        Frame2 {
427            origin: self.origin + by,
428            x: self.x,
429            y: self.y,
430        }
431    }
432
433    /// The `x` axis.
434    pub const fn x(&self) -> UnitVec2 {
435        self.x
436    }
437
438    /// The `y` axis: `x` rotated by ±90° according to the handedness.
439    pub const fn y(&self) -> UnitVec2 {
440        self.y
441    }
442
443    /// Which way `y` turns from `x`.
444    pub fn handedness(&self) -> Handedness {
445        if self.is_right_handed() {
446            Handedness::Right
447        } else {
448            Handedness::Left
449        }
450    }
451
452    /// `true` when `x × y > 0`, the (u, v) plane's own orientation.
453    pub fn is_right_handed(&self) -> bool {
454        self.x.perp(&self.y) > 0.0
455    }
456
457    /// The coordinates of a (u, v) point in this frame.
458    pub fn to_local(&self, p: Point2) -> Point2 {
459        Point2::from(self.vec_to_local(p - self.origin))
460    }
461
462    /// The (u, v) point at local coordinates `p`.
463    pub fn to_world(&self, p: Point2) -> Point2 {
464        self.origin + self.vec_to_world(p.coords)
465    }
466
467    /// The components of a (u, v) vector along the axes.
468    pub fn vec_to_local(&self, v: Vec2) -> Vec2 {
469        Vec2::new(v.dot(&self.x), v.dot(&self.y))
470    }
471
472    /// The (u, v) vector with local components `v`.
473    pub fn vec_to_world(&self, v: Vec2) -> Vec2 {
474        v.x * self.x.into_inner() + v.y * self.y.into_inner()
475    }
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481
482    #[test]
483    fn world_frame_is_the_identity() {
484        let w = Frame::world();
485        let p = Point3::new(1.0, -2.0, 3.0);
486        assert_eq!(w.to_local(p), p);
487        assert_eq!(w.to_world(p), p);
488        assert_eq!(w.rotation(), UnitQuaternion::identity());
489    }
490
491    #[test]
492    fn from_z_follows_the_axis_rule() {
493        let f = Frame::from_z(Point3::origin(), Vec3::z()).unwrap();
494        assert_eq!(f.x().into_inner(), Vec3::x());
495        assert_eq!(f.y().into_inner(), Vec3::y());
496        let f = Frame::from_z(Point3::origin(), Vec3::x()).unwrap();
497        assert_eq!(f.x().into_inner(), Vec3::z());
498        assert_eq!(f.y().into_inner(), -Vec3::y());
499        let f = Frame::from_z(Point3::origin(), Vec3::y()).unwrap();
500        assert_eq!(f.x().into_inner(), Vec3::z());
501        assert_eq!(f.y().into_inner(), Vec3::x());
502    }
503
504    #[test]
505    fn errors_name_the_problem() {
506        let o = Point3::origin();
507        assert_eq!(
508            Frame::new(o, Vec3::zeros(), Vec3::x()),
509            Err(FrameError::ZeroAxis)
510        );
511        assert_eq!(
512            Frame::new(o, Vec3::z(), Vec3::z() * 2.0),
513            Err(FrameError::DegenerateHint)
514        );
515        assert_eq!(
516            Frame::new(o, Vec3::z(), Vec3::zeros()),
517            Err(FrameError::DegenerateHint)
518        );
519        assert_eq!(
520            Frame::new(o, Vec3::new(f64::NAN, 0.0, 1.0), Vec3::x()),
521            Err(FrameError::NonFinite)
522        );
523        assert_eq!(Frame::from_z(o, Vec3::zeros()), Err(FrameError::ZeroAxis));
524        assert_eq!(
525            Frame2::new(Point2::origin(), Vec2::zeros(), Handedness::Right),
526            Err(FrameError::ZeroAxis)
527        );
528    }
529
530    #[test]
531    fn frame2_handedness_round_trips() {
532        for h in [Handedness::Right, Handedness::Left] {
533            let f = Frame2::new(Point2::new(0.5, -0.5), Vec2::new(3.0, 4.0), h).unwrap();
534            assert_eq!(f.handedness(), h);
535            let p = Point2::new(0.3, 0.9);
536            assert!((f.to_local(f.to_world(p)) - p).norm() < 1e-15);
537            assert!(f.x().dot(&f.y()).abs() < 1e-15);
538        }
539        assert!(Frame2::identity().is_right_handed());
540    }
541}