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 beyond
17    /// rounding: it is zero, or parallel to `z` to rounding.
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 beyond rounding
114    /// ([`crate::RELATIVE_ROUNDING`] of the hint's length): what is left
115    /// of a hint along `z` is noise whose direction means nothing, and
116    /// may lie along `z` itself. A hint that is *nearly* parallel to `z`,
117    /// past rounding, still yields an orthonormal frame, but its `x` is
118    /// only as good as the few bits of the perpendicular component; a
119    /// caller who wants to reject that compares the hint against `z` with
120    /// its own `Tolerance` first.
121    pub fn new(origin: Point3, z: Vec3, x_hint: Vec3) -> Result<Self, FrameError> {
122        if !(is_finite3(&origin.coords) && is_finite3(&z) && is_finite3(&x_hint)) {
123            return Err(FrameError::NonFinite);
124        }
125        let z = rescaled(z)
126            .and_then(|z| UnitVec3::try_new(z, 0.0))
127            .ok_or(FrameError::ZeroAxis)?;
128        let x_hint = rescaled(x_hint).ok_or(FrameError::DegenerateHint)?;
129        let perpendicular = x_hint - z.dot(&x_hint) * z.into_inner();
130        if crate::is_negligible(perpendicular.norm(), x_hint.norm()) {
131            return Err(FrameError::DegenerateHint);
132        }
133        let x = UnitVec3::try_new(perpendicular, 0.0).ok_or(FrameError::DegenerateHint)?;
134        Ok(Self::orthonormalised(origin, x, z))
135    }
136
137    /// A frame with axis `z` and `x` chosen by the rule Open CASCADE's
138    /// `gp_Ax3(P, N)` uses (read in the reference tree's `gp` package,
139    /// reimplemented): zero the axis coordinate of smallest magnitude, swap
140    /// the other two with the sign that keeps the larger one, so a
141    /// cylinder built from an axis alone seams where the oracle's does.
142    /// For `z` along a coordinate axis: `+z ↦ x = +x`, `+x ↦ x = +z`,
143    /// `+y ↦ x = +z`.
144    ///
145    /// Errors: a non-finite input or a zero `z`.
146    pub fn from_z(origin: Point3, z: Vec3) -> Result<Self, FrameError> {
147        if !(is_finite3(&origin.coords) && is_finite3(&z)) {
148            return Err(FrameError::NonFinite);
149        }
150        let z = rescaled(z)
151            .and_then(|z| UnitVec3::try_new(z, 0.0))
152            .ok_or(FrameError::ZeroAxis)?;
153        let (a, b, c) = (z.x, z.y, z.z);
154        let (aa, ba, ca) = (a.abs(), b.abs(), c.abs());
155        let hint = if ba <= aa && ba <= ca {
156            if aa > ca {
157                Vec3::new(-c, 0.0, a)
158            } else {
159                Vec3::new(c, 0.0, -a)
160            }
161        } else if aa <= ba && aa <= ca {
162            if ba > ca {
163                Vec3::new(0.0, -c, b)
164            } else {
165                Vec3::new(0.0, c, -b)
166            }
167        } else if aa > ba {
168            Vec3::new(-b, a, 0.0)
169        } else {
170            Vec3::new(b, -a, 0.0)
171        };
172        // `hint` is perpendicular to `z` by construction and has the norm of
173        // the two larger coordinates, so it is never zero for a unit `z`.
174        let x = UnitVec3::try_new(hint, 0.0).ok_or(FrameError::ZeroAxis)?;
175        Ok(Self::orthonormalised(origin, x, z))
176    }
177
178    /// A frame from axes that already are one: each unit, mutually
179    /// perpendicular and `z = x × y`, all to rounding
180    /// ([`crate::RELATIVE_ROUNDING`]), stored bit for bit — what the
181    /// native format reads a frame back through, so a round trip changes
182    /// nothing. Errors: a non-finite input, or
183    /// [`FrameError::NotOrthonormal`].
184    ///
185    /// ```
186    /// use arris_math::{Frame, FrameError, Point3, Vec3};
187    ///
188    /// let f = Frame::from_orthonormal(Point3::origin(), Vec3::y(), Vec3::z(), Vec3::x()).unwrap();
189    /// assert_eq!(f.x().into_inner(), Vec3::y());
190    /// let bad = Frame::from_orthonormal(Point3::origin(), Vec3::x(), Vec3::x(), Vec3::z());
191    /// assert_eq!(bad, Err(FrameError::NotOrthonormal));
192    /// ```
193    pub fn from_orthonormal(origin: Point3, x: Vec3, y: Vec3, z: Vec3) -> Result<Self, FrameError> {
194        if !(is_finite3(&origin.coords) && is_finite3(&x) && is_finite3(&y) && is_finite3(&z)) {
195            return Err(FrameError::NonFinite);
196        }
197        let unit = |v: &Vec3| crate::is_negligible(v.norm() - 1.0, 1.0);
198        let perpendicular = |a: &Vec3, b: &Vec3| crate::is_negligible(a.dot(b), 1.0);
199        if !(unit(&x) && unit(&y) && unit(&z))
200            || !(perpendicular(&x, &y) && perpendicular(&y, &z) && perpendicular(&z, &x))
201            || !crate::is_negligible((x.cross(&y) - z).norm(), 1.0)
202        {
203            return Err(FrameError::NotOrthonormal);
204        }
205        Ok(Frame {
206            origin,
207            x: UnitVec3::new_unchecked(x),
208            y: UnitVec3::new_unchecked(y),
209            z: UnitVec3::new_unchecked(z),
210        })
211    }
212
213    /// The frame whose axes are the images of the coordinate axes under
214    /// `rotation`. Infallible: a unit quaternion's basis is orthonormal.
215    pub fn from_rotation(origin: Point3, rotation: &UnitQuaternion<f64>) -> Self {
216        let x = UnitVec3::new_normalize(rotation.transform_vector(&Vec3::x()));
217        let z = UnitVec3::new_normalize(rotation.transform_vector(&Vec3::z()));
218        Self::orthonormalised(origin, x, z)
219    }
220
221    /// Rebuilds `y` and `x` from `z` and an `x` that is unit and nearly
222    /// perpendicular to it, so the result is orthonormal to rounding
223    /// regardless of how good the input was.
224    fn orthonormalised(origin: Point3, x: UnitVec3, z: UnitVec3) -> Self {
225        let y = UnitVec3::new_normalize(z.cross(&x));
226        let x = UnitVec3::new_normalize(y.cross(&z));
227        Frame { origin, x, y, z }
228    }
229
230    /// The same axes at another origin, bit for bit: a translation that
231    /// leaves the orientation untouched, where `transformed` by a pure
232    /// translation would re-round the axes through the identity rotation.
233    pub const fn with_origin(&self, origin: Point3) -> Frame {
234        Frame {
235            origin,
236            x: self.x,
237            y: self.y,
238            z: self.z,
239        }
240    }
241
242    /// The origin.
243    pub const fn origin(&self) -> Point3 {
244        self.origin
245    }
246
247    /// The `x` axis.
248    pub const fn x(&self) -> UnitVec3 {
249        self.x
250    }
251
252    /// The `y` axis, `z × x`.
253    pub const fn y(&self) -> UnitVec3 {
254        self.y
255    }
256
257    /// The `z` axis, `x × y`.
258    pub const fn z(&self) -> UnitVec3 {
259        self.z
260    }
261
262    /// The coordinates of a world point in this frame.
263    pub fn to_local(&self, p: Point3) -> Point3 {
264        Point3::from(self.vec_to_local(p - self.origin))
265    }
266
267    /// The world point at local coordinates `p`.
268    pub fn to_world(&self, p: Point3) -> Point3 {
269        self.origin + self.vec_to_world(p.coords)
270    }
271
272    /// The components of a world vector along the axes.
273    pub fn vec_to_local(&self, v: Vec3) -> Vec3 {
274        Vec3::new(v.dot(&self.x), v.dot(&self.y), v.dot(&self.z))
275    }
276
277    /// The world vector with local components `v`.
278    pub fn vec_to_world(&self, v: Vec3) -> Vec3 {
279        v.x * self.x.into_inner() + v.y * self.y.into_inner() + v.z * self.z.into_inner()
280    }
281
282    /// The rotation taking the coordinate axes onto this frame's axes.
283    pub fn rotation(&self) -> UnitQuaternion<f64> {
284        UnitQuaternion::from_basis_unchecked(&[
285            self.x.into_inner(),
286            self.y.into_inner(),
287            self.z.into_inner(),
288        ])
289    }
290
291    /// The rigid motion taking local coordinates to world coordinates:
292    /// `as_isometry().apply(p) == to_world(p)` to rounding.
293    pub fn as_isometry(&self) -> Isometry {
294        Isometry::new(self.rotation(), self.origin.coords)
295    }
296
297    /// This frame moved by `motion`. Moving geometry is moving its frame,
298    /// and this is that.
299    pub fn transformed(&self, motion: &Isometry) -> Frame {
300        Self::orthonormalised(
301            motion.apply(self.origin),
302            motion.apply_unit(self.x),
303            motion.apply_unit(self.z),
304        )
305    }
306}
307
308fn is_finite3(v: &Vec3) -> bool {
309    v.iter().all(|c| c.is_finite())
310}
311
312/// `v` scaled by a power of two that brings its largest coordinate into
313/// `[1, 2)`: the same direction, so the squares its normalisation sums
314/// neither underflow nor overflow. A vector whose coordinates are near
315/// `1e-154` has squares in the subnormal range, and normalised directly
316/// it comes out a few parts in ten thousand off unit length. A power of
317/// two scales exactly, so a vector already in range normalises to the
318/// same bits as without it. `None` for the zero vector.
319fn rescaled(v: Vec3) -> Option<Vec3> {
320    let largest = v.amax();
321    if largest <= 0.0 {
322        return None;
323    }
324    // In [-1023, 1074]: applied in two halves, since `2^1074` is not an
325    // `f64` and neither half overflows or underflows on the way.
326    let k = -(largest.log2().floor() as i32);
327    Some(v * 2f64.powi(k / 2) * 2f64.powi(k - k / 2))
328}
329
330/// Which way a [`Frame2`]'s `y` turns from its `x`.
331#[derive(Debug, Clone, Copy, PartialEq, Eq)]
332pub enum Handedness {
333    /// `y` is `x` rotated by +90°: `(u, v)` axes in their natural order.
334    Right,
335    /// `y` is `x` rotated by −90°: a reflection of the right-handed frame.
336    Left,
337}
338
339/// An orthonormal frame in a surface's (u, v) plane, of either handedness.
340/// A pcurve placed by a left-handed `Frame2` is traversed clockwise in
341/// (u, v) — the case of a circle shared by a cap and a wall whose normal
342/// opposes the circle's `Z` (`docs/DATA-MODEL.md` §Pcurves).
343///
344/// ```
345/// use arris_math::{Frame2, Handedness, Point2, Vec2};
346///
347/// let f = Frame2::new(Point2::new(1.0, 1.0), Vec2::new(0.0, 2.0), Handedness::Left).unwrap();
348/// assert!(!f.is_right_handed());
349/// assert_eq!(f.y().into_inner(), Vec2::new(1.0, 0.0));
350/// assert_eq!(f.to_world(Point2::new(1.0, 1.0)), Point2::new(2.0, 2.0));
351/// ```
352#[derive(Debug, Clone, Copy, PartialEq)]
353#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
354#[cfg_attr(feature = "serde", serde(try_from = "Frame2Repr", into = "Frame2Repr"))]
355pub struct Frame2 {
356    origin: Point2,
357    x: UnitVec2,
358    y: UnitVec2,
359}
360
361/// The wire form of a [`Frame2`], validated by [`Frame2::from_orthonormal`]
362/// on the way in.
363#[cfg(feature = "serde")]
364#[derive(serde::Serialize, serde::Deserialize)]
365struct Frame2Repr {
366    origin: Point2,
367    x: Vec2,
368    y: Vec2,
369}
370
371#[cfg(feature = "serde")]
372impl From<Frame2> for Frame2Repr {
373    fn from(f: Frame2) -> Self {
374        Frame2Repr {
375            origin: f.origin,
376            x: f.x.into_inner(),
377            y: f.y.into_inner(),
378        }
379    }
380}
381
382#[cfg(feature = "serde")]
383impl TryFrom<Frame2Repr> for Frame2 {
384    type Error = FrameError;
385
386    fn try_from(r: Frame2Repr) -> Result<Self, FrameError> {
387        Frame2::from_orthonormal(r.origin, r.x, r.y)
388    }
389}
390
391impl Frame2 {
392    /// Origin at zero, `x` along `u`, `y` along `v`: right-handed.
393    pub fn identity() -> Self {
394        Frame2 {
395            origin: Point2::origin(),
396            x: Vec2::x_axis(),
397            y: Vec2::y_axis(),
398        }
399    }
400
401    /// A frame with `x` along `x` (normalised) and `y` perpendicular to it
402    /// on the side `handedness` says. Errors: a non-finite input or a zero
403    /// `x`.
404    pub fn new(origin: Point2, x: Vec2, handedness: Handedness) -> Result<Self, FrameError> {
405        if !(origin.coords.iter().all(|c| c.is_finite()) && x.iter().all(|c| c.is_finite())) {
406            return Err(FrameError::NonFinite);
407        }
408        let x = UnitVec2::try_new(x, 0.0).ok_or(FrameError::ZeroAxis)?;
409        let y = match handedness {
410            Handedness::Right => Vec2::new(-x.y, x.x),
411            Handedness::Left => Vec2::new(x.y, -x.x),
412        };
413        Ok(Frame2 {
414            origin,
415            x,
416            y: UnitVec2::new_unchecked(y),
417        })
418    }
419
420    /// A frame from axes that already are one — each unit and
421    /// perpendicular to rounding ([`crate::RELATIVE_ROUNDING`]), of either
422    /// handedness — stored bit for bit; what the native format reads a
423    /// `Frame2` back through. Errors: a non-finite input, or
424    /// [`FrameError::NotOrthonormal`].
425    pub fn from_orthonormal(origin: Point2, x: Vec2, y: Vec2) -> Result<Self, FrameError> {
426        let finite = |v: &Vec2| v.iter().all(|c| c.is_finite());
427        if !(finite(&origin.coords) && finite(&x) && finite(&y)) {
428            return Err(FrameError::NonFinite);
429        }
430        let unit = |v: &Vec2| crate::is_negligible(v.norm() - 1.0, 1.0);
431        if !(unit(&x) && unit(&y)) || !crate::is_negligible(x.dot(&y), 1.0) {
432            return Err(FrameError::NotOrthonormal);
433        }
434        Ok(Frame2 {
435            origin,
436            x: UnitVec2::new_unchecked(x),
437            y: UnitVec2::new_unchecked(y),
438        })
439    }
440
441    /// The origin.
442    pub const fn origin(&self) -> Point2 {
443        self.origin
444    }
445
446    /// The same axes at the origin moved by `by`.
447    ///
448    /// ```
449    /// use arris_math::{Frame2, Point2, Vec2};
450    ///
451    /// let f = Frame2::identity().translated(Vec2::new(1.0, 2.0));
452    /// assert_eq!(f.origin(), Point2::new(1.0, 2.0));
453    /// assert_eq!(f.x(), Frame2::identity().x());
454    /// ```
455    pub fn translated(&self, by: Vec2) -> Frame2 {
456        Frame2 {
457            origin: self.origin + by,
458            x: self.x,
459            y: self.y,
460        }
461    }
462
463    /// The `x` axis.
464    pub const fn x(&self) -> UnitVec2 {
465        self.x
466    }
467
468    /// The `y` axis: `x` rotated by ±90° according to the handedness.
469    pub const fn y(&self) -> UnitVec2 {
470        self.y
471    }
472
473    /// Which way `y` turns from `x`.
474    pub fn handedness(&self) -> Handedness {
475        if self.is_right_handed() {
476            Handedness::Right
477        } else {
478            Handedness::Left
479        }
480    }
481
482    /// `true` when `x × y > 0`, the (u, v) plane's own orientation.
483    pub fn is_right_handed(&self) -> bool {
484        self.x.perp(&self.y) > 0.0
485    }
486
487    /// The coordinates of a (u, v) point in this frame.
488    pub fn to_local(&self, p: Point2) -> Point2 {
489        Point2::from(self.vec_to_local(p - self.origin))
490    }
491
492    /// The (u, v) point at local coordinates `p`.
493    pub fn to_world(&self, p: Point2) -> Point2 {
494        self.origin + self.vec_to_world(p.coords)
495    }
496
497    /// The components of a (u, v) vector along the axes.
498    pub fn vec_to_local(&self, v: Vec2) -> Vec2 {
499        Vec2::new(v.dot(&self.x), v.dot(&self.y))
500    }
501
502    /// The (u, v) vector with local components `v`.
503    pub fn vec_to_world(&self, v: Vec2) -> Vec2 {
504        v.x * self.x.into_inner() + v.y * self.y.into_inner()
505    }
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    /// Found by the `intersect_surfaces` fuzz target (`fuzz/`,
513    /// ADR-0024 §5): a hint along `z` up to rounding leaves a residue
514    /// that can itself lie along `z`, whose cross product with it is zero
515    /// and normalises to NaN axes.
516    #[test]
517    fn a_hint_along_the_axis_to_rounding_is_degenerate_not_nan() {
518        let mut nan = Vec::new();
519        for k in 1..=2000 {
520            let scale = 0.001 * f64::from(k);
521            for z in [
522                Vec3::new(-1.0, -1.0, -1.0),
523                Vec3::new(1.0, 2.0, 3.0),
524                Vec3::new(0.3, -0.7, 0.1),
525            ] {
526                let hint = z * scale;
527                match Frame::new(Point3::origin(), z, hint) {
528                    Err(FrameError::DegenerateHint) => {}
529                    Ok(f) => nan.push((z, scale, f)),
530                    Err(e) => panic!("{z:?} × {scale}: {e}"),
531                }
532            }
533        }
534        assert!(
535            nan.is_empty(),
536            "{} frames built: {:?}",
537            nan.len(),
538            nan.first()
539        );
540    }
541
542    /// Found by the `intersect_surfaces` fuzz target beside the one
543    /// above: an axis whose coordinates are near `1e-154` has squares in
544    /// the subnormal range, and normalised as given it came out 8e-4 off
545    /// unit length — a cylinder about it was a different cylinder.
546    #[test]
547    fn a_tiny_axis_or_hint_still_makes_an_orthonormal_frame() {
548        for scale in [1e-160, 1e-155, 1e-154, 1e-150, 1.0, 1e150, 1e154] {
549            let z = Vec3::new(-0.7, 3e-300, -0.7) * scale;
550            let hint = Vec3::new(0.3, -1.0, 0.2) * scale;
551            for f in [
552                Frame::new(Point3::origin(), z, hint).unwrap(),
553                Frame::from_z(Point3::origin(), z).unwrap(),
554            ] {
555                for axis in [f.x(), f.y(), f.z()] {
556                    assert!(
557                        (axis.norm() - 1.0).abs() <= crate::RELATIVE_ROUNDING,
558                        "{scale:e}: {axis:?}"
559                    );
560                }
561                assert!(f.x().dot(&f.z()).abs() <= crate::RELATIVE_ROUNDING);
562            }
563        }
564    }
565
566    #[test]
567    fn world_frame_is_the_identity() {
568        let w = Frame::world();
569        let p = Point3::new(1.0, -2.0, 3.0);
570        assert_eq!(w.to_local(p), p);
571        assert_eq!(w.to_world(p), p);
572        assert_eq!(w.rotation(), UnitQuaternion::identity());
573    }
574
575    #[test]
576    fn from_z_follows_the_axis_rule() {
577        let f = Frame::from_z(Point3::origin(), Vec3::z()).unwrap();
578        assert_eq!(f.x().into_inner(), Vec3::x());
579        assert_eq!(f.y().into_inner(), Vec3::y());
580        let f = Frame::from_z(Point3::origin(), Vec3::x()).unwrap();
581        assert_eq!(f.x().into_inner(), Vec3::z());
582        assert_eq!(f.y().into_inner(), -Vec3::y());
583        let f = Frame::from_z(Point3::origin(), Vec3::y()).unwrap();
584        assert_eq!(f.x().into_inner(), Vec3::z());
585        assert_eq!(f.y().into_inner(), Vec3::x());
586    }
587
588    #[test]
589    fn errors_name_the_problem() {
590        let o = Point3::origin();
591        assert_eq!(
592            Frame::new(o, Vec3::zeros(), Vec3::x()),
593            Err(FrameError::ZeroAxis)
594        );
595        assert_eq!(
596            Frame::new(o, Vec3::z(), Vec3::z() * 2.0),
597            Err(FrameError::DegenerateHint)
598        );
599        assert_eq!(
600            Frame::new(o, Vec3::z(), Vec3::zeros()),
601            Err(FrameError::DegenerateHint)
602        );
603        assert_eq!(
604            Frame::new(o, Vec3::new(f64::NAN, 0.0, 1.0), Vec3::x()),
605            Err(FrameError::NonFinite)
606        );
607        assert_eq!(Frame::from_z(o, Vec3::zeros()), Err(FrameError::ZeroAxis));
608        assert_eq!(
609            Frame2::new(Point2::origin(), Vec2::zeros(), Handedness::Right),
610            Err(FrameError::ZeroAxis)
611        );
612    }
613
614    #[test]
615    fn frame2_handedness_round_trips() {
616        for h in [Handedness::Right, Handedness::Left] {
617            let f = Frame2::new(Point2::new(0.5, -0.5), Vec2::new(3.0, 4.0), h).unwrap();
618            assert_eq!(f.handedness(), h);
619            let p = Point2::new(0.3, 0.9);
620            assert!((f.to_local(f.to_world(p)) - p).norm() < 1e-15);
621            assert!(f.x().dot(&f.y()).abs() < 1e-15);
622        }
623        assert!(Frame2::identity().is_right_handed());
624    }
625}