Skip to main content

ogeom_math/
frame.rs

1//! Axes and coordinate frames.
2//!
3//! An [`Axis`] is a point and a direction. A [`Frame`] is a full local
4//! coordinate system: an origin plus three mutually perpendicular directions
5//! plus a handedness.
6//!
7//! Frames are how every piece of analytic geometry in the kernel is positioned.
8//! A cylinder is a radius and a frame, and so is a circle. The
9//! parameterization of each is defined *relative to* its frame, which is what
10//! makes "the seam of this cylinder" a well-defined place rather than an
11//! accident of how the surface was built.
12//!
13//! There is one [`Frame`] type, carrying a [`Handedness`], rather than separate
14//! right-handed and possibly-left-handed types, so nothing is converted at a
15//! boundary between them.
16
17use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
18
19use crate::{Direction, Direction2, Matrix3, Point, Point2, Vector, Vector2};
20
21/// Whether a frame's third direction follows the right-hand rule.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub enum Handedness {
24    /// `x × y = z`. The usual case.
25    #[default]
26    Right,
27    /// `x × y = -z`. Arises from mirroring.
28    Left,
29}
30
31impl Handedness {
32    /// `+1` for right-handed, `-1` for left-handed.
33    #[must_use]
34    pub const fn sign(self) -> f64 {
35        match self {
36            Self::Right => 1.0,
37            Self::Left => -1.0,
38        }
39    }
40
41    /// The opposite handedness.
42    #[must_use]
43    pub const fn flipped(self) -> Self {
44        match self {
45            Self::Right => Self::Left,
46            Self::Left => Self::Right,
47        }
48    }
49}
50
51/// A point and a direction: an oriented line through space.
52#[derive(Debug, Clone, Copy, PartialEq)]
53pub struct Axis {
54    /// A point on the axis.
55    pub location: Point,
56    /// The axis direction.
57    pub direction: Direction,
58}
59
60/// A point and a direction in the plane.
61#[derive(Debug, Clone, Copy, PartialEq)]
62pub struct Axis2 {
63    /// A point on the axis.
64    pub location: Point2,
65    /// The axis direction.
66    pub direction: Direction2,
67}
68
69/// A local coordinate system in space.
70///
71/// The `z` direction is primary: it is the axis of revolution for a cylinder,
72/// the normal of a plane, the axis of a circle. `x` fixes where parameterization
73/// starts. `y` is derived and always consistent with the handedness.
74#[derive(Debug, Clone, Copy, PartialEq)]
75pub struct Frame {
76    origin: Point,
77    z: Direction,
78    x: Direction,
79    y: Direction,
80    handedness: Handedness,
81}
82
83/// A local coordinate system in the plane.
84#[derive(Debug, Clone, Copy, PartialEq)]
85pub struct Frame2 {
86    origin: Point2,
87    x: Direction2,
88    y: Direction2,
89    handedness: Handedness,
90}
91
92impl Axis {
93    /// The X axis through the origin.
94    pub const X: Self = Self {
95        location: Point::ORIGIN,
96        direction: Direction::X,
97    };
98    /// The Y axis through the origin.
99    pub const Y: Self = Self {
100        location: Point::ORIGIN,
101        direction: Direction::Y,
102    };
103    /// The Z axis through the origin.
104    pub const Z: Self = Self {
105        location: Point::ORIGIN,
106        direction: Direction::Z,
107    };
108
109    /// An axis from a point and a direction.
110    #[must_use]
111    pub const fn new(location: Point, direction: Direction) -> Self {
112        Self {
113            location,
114            direction,
115        }
116    }
117
118    /// The axis through two distinct points, directed from `from` to `to`.
119    ///
120    /// # Errors
121    ///
122    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the points
123    /// coincide.
124    pub fn through(from: Point, to: Point, tol: Tolerances) -> OgeomResult<Self> {
125        Ok(Self::new(from, Direction::new(to - from, tol)?))
126    }
127
128    /// This axis with its direction reversed.
129    #[must_use]
130    pub const fn reversed(self) -> Self {
131        Self::new(self.location, self.direction.reversed())
132    }
133
134    /// The point at parameter `t`, measured from [`Axis::location`] in units of
135    /// length along the direction.
136    #[must_use]
137    pub fn point_at(self, t: f64) -> Point {
138        self.location + self.direction * t
139    }
140
141    /// The parameter of the projection of `p` onto this axis.
142    #[must_use]
143    pub fn parameter_of(self, p: Point) -> f64 {
144        self.direction.dot_vector(p - self.location)
145    }
146
147    /// The closest point on this axis to `p`.
148    #[must_use]
149    pub fn project(self, p: Point) -> Point {
150        self.point_at(self.parameter_of(p))
151    }
152
153    /// The perpendicular distance from `p` to this axis.
154    #[must_use]
155    pub fn distance_to(self, p: Point) -> f64 {
156        // The cross product with a unit direction gives the perpendicular
157        // component directly, without the cancellation that subtracting the
158        // projection would introduce for a point far along the axis.
159        self.direction.cross_with(p - self.location).magnitude()
160    }
161
162    /// Whether `p` lies on this axis within `tol.confusion()`.
163    #[must_use]
164    pub fn contains(self, p: Point, tol: Tolerances) -> bool {
165        self.distance_to(p) <= tol.confusion()
166    }
167
168    /// Whether two axes are the same line with the same sense.
169    #[must_use]
170    pub fn is_coaxial(self, other: Self, tol: Tolerances) -> bool {
171        self.direction.is_equal(other.direction, tol)
172            && self.contains(other.location, tol)
173            && other.contains(self.location, tol)
174    }
175
176    /// Whether two axes lie on the same line, ignoring sense.
177    #[must_use]
178    pub fn is_collinear(self, other: Self, tol: Tolerances) -> bool {
179        self.direction.is_parallel(other.direction, tol)
180            && self.contains(other.location, tol)
181            && other.contains(self.location, tol)
182    }
183}
184
185impl Axis2 {
186    /// The X axis through the origin.
187    pub const X: Self = Self {
188        location: Point2::ORIGIN,
189        direction: Direction2::X,
190    };
191    /// The Y axis through the origin.
192    pub const Y: Self = Self {
193        location: Point2::ORIGIN,
194        direction: Direction2::Y,
195    };
196
197    /// An axis from a point and a direction.
198    #[must_use]
199    pub const fn new(location: Point2, direction: Direction2) -> Self {
200        Self {
201            location,
202            direction,
203        }
204    }
205
206    /// The axis through two distinct points.
207    ///
208    /// # Errors
209    ///
210    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the points
211    /// coincide.
212    pub fn through(from: Point2, to: Point2, tol: Tolerances) -> OgeomResult<Self> {
213        Ok(Self::new(from, Direction2::new(to - from, tol)?))
214    }
215
216    /// This axis with its direction reversed.
217    #[must_use]
218    pub const fn reversed(self) -> Self {
219        Self::new(self.location, self.direction.reversed())
220    }
221
222    /// The point at parameter `t`.
223    #[must_use]
224    pub fn point_at(self, t: f64) -> Point2 {
225        self.location + self.direction * t
226    }
227
228    /// The parameter of the projection of `p` onto this axis.
229    #[must_use]
230    pub fn parameter_of(self, p: Point2) -> f64 {
231        self.direction.vector().dot(p - self.location)
232    }
233
234    /// The closest point on this axis to `p`.
235    #[must_use]
236    pub fn project(self, p: Point2) -> Point2 {
237        self.point_at(self.parameter_of(p))
238    }
239
240    /// The signed distance from `p` to this axis, positive on the left.
241    #[must_use]
242    pub fn signed_distance_to(self, p: Point2) -> f64 {
243        self.direction.vector().cross(p - self.location)
244    }
245
246    /// The distance from `p` to this axis.
247    #[must_use]
248    pub fn distance_to(self, p: Point2) -> f64 {
249        self.signed_distance_to(p).abs()
250    }
251}
252
253impl Default for Frame {
254    fn default() -> Self {
255        Self::WORLD
256    }
257}
258
259impl Frame {
260    /// The identity frame: origin at the origin, axes along X, Y and Z.
261    pub const WORLD: Self = Self {
262        origin: Point::ORIGIN,
263        z: Direction::Z,
264        x: Direction::X,
265        y: Direction::Y,
266        handedness: Handedness::Right,
267    };
268
269    /// A right-handed frame from an origin, a primary direction and a reference
270    /// for the first axis.
271    ///
272    /// `x_reference` need not be perpendicular to `z`: its component along `z`
273    /// is removed. It must not be parallel to `z`, since then there is nothing
274    /// left to orient by.
275    ///
276    /// # Errors
277    ///
278    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `z` and
279    /// `x_reference` are parallel.
280    pub fn new(
281        origin: Point,
282        z: Direction,
283        x_reference: Direction,
284        tol: Tolerances,
285    ) -> OgeomResult<Self> {
286        // Gram-Schmidt: remove the component of the reference along z, then
287        // renormalize. Fails cleanly when nothing is left to normalize.
288        let v = x_reference.vector() - z.vector() * z.dot(x_reference);
289        let Ok(x) = Direction::new(v, tol) else {
290            ogeom_bail!(
291                Construction,
292                "frame reference direction is parallel to the primary direction"
293            );
294        };
295        let y = Direction::new(z.cross_vector(x), tol)?;
296        Ok(Self {
297            origin,
298            z,
299            x,
300            y,
301            handedness: Handedness::Right,
302        })
303    }
304
305    /// A right-handed frame with an arbitrary but deterministic first axis.
306    ///
307    /// For geometry with rotational symmetry (a sphere, a full circle), the
308    /// choice of `x` is immaterial, and requiring the caller to invent one is
309    /// noise.
310    #[must_use]
311    pub fn about(origin: Point, z: Direction) -> Self {
312        let x = z.any_perpendicular();
313        // z and x are perpendicular unit vectors, so their cross product is
314        // already unit length.
315        let y =
316            Direction::new(z.cross_vector(x), Tolerances::millimetres()).unwrap_or(Direction::Y);
317        Self {
318            origin,
319            z,
320            x,
321            y,
322            handedness: Handedness::Right,
323        }
324    }
325
326    /// A frame from three directions given explicitly.
327    ///
328    /// Handedness is inferred from the triple product rather than asserted.
329    ///
330    /// # Errors
331    ///
332    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the three
333    /// are not mutually perpendicular within `tol.angular()`, or if they are
334    /// coplanar.
335    pub fn from_axes(
336        origin: Point,
337        x: Direction,
338        y: Direction,
339        z: Direction,
340        tol: Tolerances,
341    ) -> OgeomResult<Self> {
342        for (a, b, names) in [(x, y, "x/y"), (y, z, "y/z"), (z, x, "z/x")] {
343            if !a.is_normal(b, tol) {
344                ogeom_bail!(Construction, "frame axes {names} are not perpendicular");
345            }
346        }
347        let triple = x.vector().triple(y.vector(), z.vector());
348        if triple.abs() <= tol.angular() {
349            ogeom_bail!(Construction, "frame axes are coplanar");
350        }
351        let handedness = if triple > 0.0 {
352            Handedness::Right
353        } else {
354            Handedness::Left
355        };
356        Ok(Self {
357            origin,
358            z,
359            x,
360            y,
361            handedness,
362        })
363    }
364
365    /// The frame's origin.
366    #[must_use]
367    pub const fn origin(&self) -> Point {
368        self.origin
369    }
370
371    /// The primary direction: the normal of a plane, the axis of a cylinder.
372    #[must_use]
373    pub const fn z(&self) -> Direction {
374        self.z
375    }
376
377    /// The first axis, fixing where parameterization starts.
378    #[must_use]
379    pub const fn x(&self) -> Direction {
380        self.x
381    }
382
383    /// The second axis.
384    #[must_use]
385    pub const fn y(&self) -> Direction {
386        self.y
387    }
388
389    /// This frame's handedness.
390    #[must_use]
391    pub const fn handedness(&self) -> Handedness {
392        self.handedness
393    }
394
395    /// The axis along the primary direction.
396    #[must_use]
397    pub const fn axis(&self) -> Axis {
398        Axis::new(self.origin, self.z)
399    }
400
401    /// This frame moved to a new origin.
402    #[must_use]
403    pub const fn with_origin(&self, origin: Point) -> Self {
404        Self { origin, ..*self }
405    }
406
407    /// This frame with its handedness flipped, by reversing `y`.
408    #[must_use]
409    pub const fn mirrored(&self) -> Self {
410        Self {
411            y: self.y.reversed(),
412            handedness: self.handedness.flipped(),
413            ..*self
414        }
415    }
416
417    /// This frame with the primary direction reversed.
418    ///
419    /// `x` is kept, so `y` must flip to preserve handedness. Reversing a
420    /// plane's normal should not silently turn its parameterization inside out.
421    #[must_use]
422    pub const fn with_z_reversed(&self) -> Self {
423        Self {
424            z: self.z.reversed(),
425            y: self.y.reversed(),
426            ..*self
427        }
428    }
429
430    /// Local coordinates of a point given in world coordinates.
431    #[must_use]
432    pub fn to_local(&self, p: Point) -> Point {
433        let v = p - self.origin;
434        Point::new(
435            self.x.dot_vector(v),
436            self.y.dot_vector(v),
437            self.z.dot_vector(v),
438        )
439    }
440
441    /// World coordinates of a point given in this frame's local coordinates.
442    #[must_use]
443    pub fn to_world(&self, p: Point) -> Point {
444        self.origin + self.x * p.x + self.y * p.y + self.z * p.z
445    }
446
447    /// Local components of a world-space vector. Unaffected by the origin.
448    #[must_use]
449    pub fn vector_to_local(&self, v: Vector) -> Vector {
450        Vector::new(
451            self.x.dot_vector(v),
452            self.y.dot_vector(v),
453            self.z.dot_vector(v),
454        )
455    }
456
457    /// World components of a vector given in local coordinates.
458    #[must_use]
459    pub fn vector_to_world(&self, v: Vector) -> Vector {
460        self.x * v.x + self.y * v.y + self.z * v.z
461    }
462
463    /// The rotation taking local coordinates to world coordinates.
464    #[must_use]
465    pub fn to_matrix(&self) -> Matrix3 {
466        Matrix3::from_columns(self.x.vector(), self.y.vector(), self.z.vector())
467    }
468
469    /// Whether two frames agree in origin and all three directions.
470    #[must_use]
471    pub fn is_equal(&self, other: &Self, tol: Tolerances) -> bool {
472        self.origin.is_equal(other.origin, tol)
473            && self.x.is_equal(other.x, tol)
474            && self.y.is_equal(other.y, tol)
475            && self.z.is_equal(other.z, tol)
476    }
477
478    /// The signed distance from `p` to this frame's XY plane, positive on the
479    /// side the primary direction points to.
480    #[must_use]
481    pub fn signed_distance_to_plane(&self, p: Point) -> f64 {
482        self.z.dot_vector(p - self.origin)
483    }
484}
485
486impl Default for Frame2 {
487    fn default() -> Self {
488        Self::WORLD
489    }
490}
491
492impl Frame2 {
493    /// The identity frame.
494    pub const WORLD: Self = Self {
495        origin: Point2::ORIGIN,
496        x: Direction2::X,
497        y: Direction2::Y,
498        handedness: Handedness::Right,
499    };
500
501    /// A right-handed frame from an origin and a first axis.
502    #[must_use]
503    pub const fn new(origin: Point2, x: Direction2) -> Self {
504        Self {
505            origin,
506            x,
507            y: x.perpendicular(),
508            handedness: Handedness::Right,
509        }
510    }
511
512    /// A frame with an explicit second axis, whose handedness is inferred.
513    ///
514    /// # Errors
515    ///
516    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the two
517    /// axes are not perpendicular within `tol.angular()`.
518    pub fn from_axes(
519        origin: Point2,
520        x: Direction2,
521        y: Direction2,
522        tol: Tolerances,
523    ) -> OgeomResult<Self> {
524        if !x.is_normal(y, tol) {
525            ogeom_bail!(Construction, "frame axes are not perpendicular");
526        }
527        let handedness = if x.cross(y) > 0.0 {
528            Handedness::Right
529        } else {
530            Handedness::Left
531        };
532        Ok(Self {
533            origin,
534            x,
535            y,
536            handedness,
537        })
538    }
539
540    /// The frame's origin.
541    #[must_use]
542    pub const fn origin(&self) -> Point2 {
543        self.origin
544    }
545
546    /// The first axis.
547    #[must_use]
548    pub const fn x(&self) -> Direction2 {
549        self.x
550    }
551
552    /// The second axis.
553    #[must_use]
554    pub const fn y(&self) -> Direction2 {
555        self.y
556    }
557
558    /// This frame's handedness.
559    #[must_use]
560    pub const fn handedness(&self) -> Handedness {
561        self.handedness
562    }
563
564    /// This frame with its handedness flipped.
565    #[must_use]
566    pub const fn mirrored(&self) -> Self {
567        Self {
568            y: self.y.reversed(),
569            handedness: self.handedness.flipped(),
570            ..*self
571        }
572    }
573
574    /// Local coordinates of a point given in world coordinates.
575    #[must_use]
576    pub fn to_local(&self, p: Point2) -> Point2 {
577        let v = p - self.origin;
578        Point2::new(self.x.vector().dot(v), self.y.vector().dot(v))
579    }
580
581    /// World coordinates of a point given in local coordinates.
582    #[must_use]
583    pub fn to_world(&self, p: Point2) -> Point2 {
584        self.origin + self.x * p.x + self.y * p.y
585    }
586
587    /// Local components of a world-space vector.
588    #[must_use]
589    pub fn vector_to_local(&self, v: Vector2) -> Vector2 {
590        Vector2::new(self.x.vector().dot(v), self.y.vector().dot(v))
591    }
592
593    /// World components of a vector given in local coordinates.
594    #[must_use]
595    pub fn vector_to_world(&self, v: Vector2) -> Vector2 {
596        self.x * v.x + self.y * v.y
597    }
598
599    /// Whether two frames agree in origin and both directions.
600    #[must_use]
601    pub fn is_equal(&self, other: &Self, tol: Tolerances) -> bool {
602        self.origin.is_equal(other.origin, tol)
603            && self.x.is_equal(other.x, tol)
604            && self.y.is_equal(other.y, tol)
605    }
606}
607
608#[cfg(test)]
609#[allow(clippy::unwrap_used)]
610mod tests {
611    use super::*;
612    use approx::assert_relative_eq;
613
614    const T: Tolerances = Tolerances::millimetres();
615
616    #[test]
617    fn axis_projection_and_distance() {
618        let a = Axis::new(Point::new(1.0, 0.0, 0.0), Direction::Z);
619        let p = Point::new(4.0, 0.0, 7.0);
620        assert_relative_eq!(a.parameter_of(p), 7.0);
621        assert_eq!(a.project(p), Point::new(1.0, 0.0, 7.0));
622        assert_relative_eq!(a.distance_to(p), 3.0);
623        assert!(a.contains(Point::new(1.0, 0.0, -5.0), T));
624        assert!(!a.contains(p, T));
625    }
626
627    #[test]
628    fn axis_distance_stays_accurate_far_along_the_axis() {
629        // Subtracting the projection would cancel two numbers around 1e9 to
630        // recover a distance of 3. The cross-product form does not.
631        let a = Axis::Z;
632        let p = Point::new(3.0, 0.0, 1.0e9);
633        assert_relative_eq!(a.distance_to(p), 3.0, epsilon = 1e-9);
634    }
635
636    #[test]
637    fn axis_through_coincident_points_is_refused() {
638        let p = Point::new(1.0, 2.0, 3.0);
639        assert!(Axis::through(p, p, T).is_err());
640        assert!(Axis::through(p, Point::new(1.0, 2.0, 4.0), T).is_ok());
641    }
642
643    #[test]
644    fn coaxial_and_collinear_differ_by_sense() {
645        let a = Axis::Z;
646        let b = Axis::new(Point::new(0.0, 0.0, 5.0), Direction::Z);
647        let c = b.reversed();
648        assert!(a.is_coaxial(b, T));
649        assert!(!a.is_coaxial(c, T), "opposite sense is not coaxial");
650        assert!(a.is_collinear(c, T), "but it is collinear");
651        assert!(!a.is_collinear(Axis::X, T));
652    }
653
654    #[test]
655    fn frame_orthonormalizes_a_non_perpendicular_reference() {
656        // The reference leans heavily into z. Only its perpendicular part
657        // should survive.
658        let reference = Direction::from_coords(1.0, 0.0, 10.0, T).unwrap();
659        let f = Frame::new(Point::ORIGIN, Direction::Z, reference, T).unwrap();
660        assert!(f.x().is_equal(Direction::X, T));
661        assert!(f.y().is_equal(Direction::Y, T));
662        assert!(f.to_matrix().is_orthonormal(1e-14));
663    }
664
665    #[test]
666    fn frame_refuses_a_parallel_reference() {
667        assert!(Frame::new(Point::ORIGIN, Direction::Z, Direction::Z, T).is_err());
668        assert!(Frame::new(Point::ORIGIN, Direction::Z, -Direction::Z, T).is_err());
669    }
670
671    #[test]
672    fn frame_about_works_for_every_primary_direction() {
673        for z in [
674            Direction::X,
675            Direction::Y,
676            Direction::Z,
677            -Direction::Y,
678            Direction::from_coords(1.0, 1.0, 1.0, T).unwrap(),
679        ] {
680            let f = Frame::about(Point::new(1.0, 2.0, 3.0), z);
681            assert!(f.z().is_equal(z, T));
682            assert!(f.to_matrix().is_orthonormal(1e-14));
683            assert_eq!(f.handedness(), Handedness::Right);
684        }
685    }
686
687    #[test]
688    fn local_and_world_coordinates_round_trip() {
689        let f = Frame::new(
690            Point::new(10.0, -5.0, 2.0),
691            Direction::from_coords(1.0, 1.0, 1.0, T).unwrap(),
692            Direction::X,
693            T,
694        )
695        .unwrap();
696        for p in [
697            Point::ORIGIN,
698            Point::new(1.0, 2.0, 3.0),
699            Point::new(-100.0, 0.5, 7.0),
700        ] {
701            assert!(f.to_world(f.to_local(p)).is_equal(p, T));
702        }
703        // The origin maps to local zero, and the axes to the unit vectors.
704        assert!(f.to_local(f.origin()).is_equal(Point::ORIGIN, T));
705        assert!(
706            f.to_local(f.origin() + f.x() * 1.0)
707                .is_equal(Point::new(1.0, 0.0, 0.0), T)
708        );
709    }
710
711    #[test]
712    fn vectors_ignore_the_origin_but_points_do_not() {
713        let f = Frame::new(Point::new(100.0, 0.0, 0.0), Direction::Z, Direction::X, T).unwrap();
714        let v = Vector::new(1.0, 2.0, 3.0);
715        assert!(
716            f.vector_to_local(v).is_equal(v, T),
717            "aligned frame, offset origin"
718        );
719        assert!(
720            !f.to_local(Point::from_vector(v))
721                .is_equal(Point::from_vector(v), T)
722        );
723    }
724
725    #[test]
726    fn handedness_is_inferred_not_asserted() {
727        let right =
728            Frame::from_axes(Point::ORIGIN, Direction::X, Direction::Y, Direction::Z, T).unwrap();
729        assert_eq!(right.handedness(), Handedness::Right);
730
731        let left =
732            Frame::from_axes(Point::ORIGIN, Direction::X, Direction::Y, -Direction::Z, T).unwrap();
733        assert_eq!(left.handedness(), Handedness::Left);
734        assert_relative_eq!(left.handedness().sign(), -1.0);
735    }
736
737    #[test]
738    fn from_axes_rejects_non_orthogonal_and_coplanar_input() {
739        let skew = Direction::from_coords(1.0, 1.0, 0.0, T).unwrap();
740        assert!(Frame::from_axes(Point::ORIGIN, Direction::X, skew, Direction::Z, T).is_err());
741        assert!(
742            Frame::from_axes(Point::ORIGIN, Direction::X, Direction::Y, Direction::X, T).is_err()
743        );
744    }
745
746    #[test]
747    fn reversing_the_primary_direction_preserves_handedness() {
748        let f = Frame::WORLD;
749        let r = f.with_z_reversed();
750        assert!(r.z().is_equal(-Direction::Z, T));
751        assert!(r.x().is_equal(Direction::X, T), "x is kept");
752        assert!(r.y().is_equal(-Direction::Y, T), "y flips to compensate");
753        assert_eq!(r.handedness(), Handedness::Right);
754        assert_relative_eq!(
755            r.x().vector().triple(r.y().vector(), r.z().vector()),
756            1.0,
757            epsilon = 1e-15
758        );
759    }
760
761    #[test]
762    fn mirroring_flips_handedness() {
763        let m = Frame::WORLD.mirrored();
764        assert_eq!(m.handedness(), Handedness::Left);
765        assert_eq!(m.mirrored().handedness(), Handedness::Right);
766        assert_relative_eq!(
767            m.x().vector().triple(m.y().vector(), m.z().vector()),
768            -1.0,
769            epsilon = 1e-15
770        );
771    }
772
773    #[test]
774    fn signed_distance_to_the_frame_plane() {
775        let f = Frame::WORLD;
776        assert_relative_eq!(f.signed_distance_to_plane(Point::new(1.0, 2.0, 3.0)), 3.0);
777        assert_relative_eq!(f.signed_distance_to_plane(Point::new(1.0, 2.0, -3.0)), -3.0);
778        assert_relative_eq!(
779            f.with_z_reversed()
780                .signed_distance_to_plane(Point::new(0.0, 0.0, 3.0)),
781            -3.0
782        );
783    }
784
785    #[test]
786    fn frame2_round_trips_and_infers_handedness() {
787        let f = Frame2::new(Point2::new(3.0, 4.0), Direction2::from_angle(0.6));
788        assert_eq!(f.handedness(), Handedness::Right);
789        for p in [Point2::ORIGIN, Point2::new(-2.0, 7.0)] {
790            assert!(f.to_world(f.to_local(p)).is_equal(p, T));
791        }
792        let left = Frame2::from_axes(Point2::ORIGIN, Direction2::X, -Direction2::Y, T).unwrap();
793        assert_eq!(left.handedness(), Handedness::Left);
794        assert!(Frame2::from_axes(Point2::ORIGIN, Direction2::X, Direction2::X, T).is_err());
795    }
796
797    #[test]
798    fn axis2_signed_distance_is_positive_on_the_left() {
799        let a = Axis2::X;
800        assert_relative_eq!(a.signed_distance_to(Point2::new(5.0, 2.0)), 2.0);
801        assert_relative_eq!(a.signed_distance_to(Point2::new(5.0, -2.0)), -2.0);
802        assert_relative_eq!(a.distance_to(Point2::new(5.0, -2.0)), 2.0);
803        assert_eq!(a.project(Point2::new(5.0, 2.0)), Point2::new(5.0, 0.0));
804    }
805}