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