Skip to main content

ogeom_math/
direction.rs

1//! Unit vectors, with the invariant enforced by the type.
2//!
3//! A [`Direction`] is always unit length. Every constructor normalizes and can
4//! fail; there is no way to build one from components without that check.
5//!
6//! This matters more than it looks. Surface normals, axis directions and
7//! parameterization references are all directions, and an algorithm that
8//! assumes unit length (as almost all of them do, implicitly, when they skip a
9//! division) silently produces scaled results when handed a vector that is not.
10//! Making the invariant unrepresentable-if-false removes the whole class.
11
12use core::ops::{Mul, Neg};
13
14use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
15
16use crate::{Vector, Vector2};
17
18/// A unit vector in space.
19#[derive(Debug, Clone, Copy, PartialEq)]
20pub struct Direction(Vector);
21
22/// A unit vector in the plane.
23#[derive(Debug, Clone, Copy, PartialEq)]
24pub struct Direction2(Vector2);
25
26impl Direction {
27    /// +X.
28    pub const X: Self = Self(Vector::X);
29    /// +Y.
30    pub const Y: Self = Self(Vector::Y);
31    /// +Z.
32    pub const Z: Self = Self(Vector::Z);
33
34    /// Normalize `v` into a direction.
35    ///
36    /// # Errors
37    ///
38    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `v` is
39    /// non-finite or shorter than `tol.confusion()`.
40    pub fn new(v: Vector, tol: Tolerances) -> OgeomResult<Self> {
41        Ok(Self(v.normalized(tol)?))
42    }
43
44    /// Normalize components into a direction.
45    ///
46    /// # Errors
47    ///
48    /// As [`Direction::new`].
49    pub fn from_coords(x: f64, y: f64, z: f64, tol: Tolerances) -> OgeomResult<Self> {
50        Self::new(Vector::new(x, y, z), tol)
51    }
52
53    /// A direction from a vector that is *already* a unit vector.
54    ///
55    /// Checks rather than normalizes, and the distinction is the whole reason
56    /// it exists: dividing a unit vector by its own magnitude does not give it
57    /// back, it gives something a bit or two away. That is invisible until
58    /// something has to reproduce a direction exactly: reading a document back
59    /// from a file, above all, where the drift turns a round trip that should
60    /// be the identity into one that changes the model a little every time.
61    ///
62    /// # Errors
63    ///
64    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `v` is
65    /// non-finite, or its length differs from one by more than
66    /// `tol.confusion()`.
67    pub fn unit(v: Vector, tol: Tolerances) -> OgeomResult<Self> {
68        if !v.is_finite() {
69            ogeom_bail!(Construction, "a direction must be finite; got {v:?}");
70        }
71        let length = v.magnitude();
72        if (length - 1.0).abs() > tol.confusion() {
73            ogeom_bail!(
74                Construction,
75                "expected a unit vector, got one of length {length}"
76            );
77        }
78        Ok(Self(v))
79    }
80
81    /// The underlying unit vector.
82    #[must_use]
83    pub const fn vector(self) -> Vector {
84        self.0
85    }
86
87    /// X component.
88    #[must_use]
89    pub const fn x(self) -> f64 {
90        self.0.x
91    }
92
93    /// Y component.
94    #[must_use]
95    pub const fn y(self) -> f64 {
96        self.0.y
97    }
98
99    /// Z component.
100    #[must_use]
101    pub const fn z(self) -> f64 {
102        self.0.z
103    }
104
105    /// Components as an array.
106    #[must_use]
107    pub const fn to_array(self) -> [f64; 3] {
108        self.0.to_array()
109    }
110
111    /// Dot product with another direction: the cosine of the angle between
112    /// them, in `[-1, 1]` up to rounding.
113    #[must_use]
114    pub fn dot(self, other: Self) -> f64 {
115        self.0.dot(other.0)
116    }
117
118    /// Dot product with a free vector.
119    #[must_use]
120    pub fn dot_vector(self, v: Vector) -> f64 {
121        self.0.dot(v)
122    }
123
124    /// Cross product, as a free vector. Its magnitude is the sine of the angle
125    /// between the two directions, so it is *not* itself a direction; for
126    /// nearly parallel inputs it is nearly null.
127    #[must_use]
128    pub fn cross_vector(self, other: Self) -> Vector {
129        self.0.cross(other.0)
130    }
131
132    /// Cross product with a free vector.
133    ///
134    /// Its magnitude is the component of `v` perpendicular to this direction,
135    /// which makes it the accurate way to get a perpendicular distance:
136    /// subtracting the parallel component instead cancels catastrophically for
137    /// a point far along the direction.
138    #[must_use]
139    pub fn cross_with(self, v: Vector) -> Vector {
140        self.0.cross(v)
141    }
142
143    /// Cross product, renormalized into a direction.
144    ///
145    /// Collinearity is judged against the *angular* tolerance, not the linear
146    /// one: for unit inputs the cross product's magnitude is the sine of the
147    /// angle between them, a dimensionless quantity that a length tolerance
148    /// does not describe.
149    ///
150    /// # Errors
151    ///
152    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the two
153    /// directions are collinear.
154    pub fn cross(self, other: Self, tol: Tolerances) -> OgeomResult<Self> {
155        let v = self.cross_vector(other);
156        let m = v.magnitude();
157        if m <= tol.angular() {
158            ogeom_bail!(Construction, "cross product of collinear directions");
159        }
160        Ok(Self(v / m))
161    }
162
163    /// The unit normal to two free vectors.
164    ///
165    /// The right way to build a normal from two edges of a triangle. Naively
166    /// normalizing `a.cross(b)` compares its magnitude (which is twice the
167    /// triangle's area, and so scales as the *square* of the size) against a
168    /// length tolerance. A triangle a micron across then looks degenerate even
169    /// though its normal is perfectly well determined. The test here is
170    /// relative: `|a x b| > tol.angular() * |a| * |b|`, which asks the question
171    /// that actually matters, whether the two vectors are collinear, and gives
172    /// the same answer at every scale.
173    ///
174    /// # Errors
175    ///
176    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `a` and `b`
177    /// are collinear, or either is null.
178    pub fn from_cross(a: Vector, b: Vector, tol: Tolerances) -> OgeomResult<Self> {
179        let v = a.cross(b);
180        let m = v.magnitude();
181        if m <= tol.angular() * a.magnitude() * b.magnitude() {
182            ogeom_bail!(Construction, "cannot take a normal to collinear vectors");
183        }
184        Ok(Self(v / m))
185    }
186
187    /// Angle to `other`, in `[0, π]`.
188    #[must_use]
189    pub fn angle(self, other: Self) -> f64 {
190        // atan2 rather than acos of the dot product: acos loses roughly half its
191        // significant digits near 0 and π, which is where these tests matter.
192        self.cross_vector(other).magnitude().atan2(self.dot(other))
193    }
194
195    /// Whether the two point the same way, within `tol.angular()`.
196    #[must_use]
197    pub fn is_equal(self, other: Self, tol: Tolerances) -> bool {
198        self.angle(other) <= tol.angular()
199    }
200
201    /// Whether the two point opposite ways, within `tol.angular()`.
202    #[must_use]
203    pub fn is_opposite(self, other: Self, tol: Tolerances) -> bool {
204        core::f64::consts::PI - self.angle(other) <= tol.angular()
205    }
206
207    /// Whether the two are parallel, ignoring sense.
208    #[must_use]
209    pub fn is_parallel(self, other: Self, tol: Tolerances) -> bool {
210        self.is_equal(other, tol) || self.is_opposite(other, tol)
211    }
212
213    /// Whether the two are perpendicular, within `tol.angular()`.
214    #[must_use]
215    pub fn is_normal(self, other: Self, tol: Tolerances) -> bool {
216        (core::f64::consts::FRAC_PI_2 - self.angle(other)).abs() <= tol.angular()
217    }
218
219    /// Some direction perpendicular to this one.
220    ///
221    /// Which one is unspecified but deterministic. Chosen by crossing with
222    /// whichever axis this direction is least aligned with, so the cross product
223    /// is never near-degenerate and the result is numerically sound for every
224    /// input.
225    #[must_use]
226    pub fn any_perpendicular(self) -> Self {
227        let [ax, ay, az] = [self.x().abs(), self.y().abs(), self.z().abs()];
228        let axis = if ax <= ay && ax <= az {
229            Vector::X
230        } else if ay <= az {
231            Vector::Y
232        } else {
233            Vector::Z
234        };
235        let v = self.0.cross(axis);
236        // Guaranteed non-degenerate: `axis` is the least-aligned unit axis, so
237        // the angle between them is at least acos(1/sqrt(3)) ~= 54.7 degrees.
238        Self(v / v.magnitude())
239    }
240
241    /// This direction reflected through the origin.
242    #[must_use]
243    pub const fn reversed(self) -> Self {
244        Self(Vector::new(-self.0.x, -self.0.y, -self.0.z))
245    }
246
247    /// This direction with the Z component dropped, renormalized.
248    ///
249    /// # Errors
250    ///
251    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if this
252    /// direction is parallel to Z, leaving nothing to project.
253    pub fn to_2d(self, tol: Tolerances) -> OgeomResult<Direction2> {
254        Direction2::new(self.0.xy(), tol)
255    }
256}
257
258impl Direction2 {
259    /// +X.
260    pub const X: Self = Self(Vector2::X);
261    /// +Y.
262    pub const Y: Self = Self(Vector2::Y);
263
264    /// Normalize `v` into a direction.
265    ///
266    /// # Errors
267    ///
268    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `v` is
269    /// non-finite or shorter than `tol.confusion()`.
270    pub fn new(v: Vector2, tol: Tolerances) -> OgeomResult<Self> {
271        Ok(Self(v.normalized(tol)?))
272    }
273
274    /// Normalize components into a direction.
275    ///
276    /// # Errors
277    ///
278    /// As [`Direction2::new`].
279    pub fn from_coords(x: f64, y: f64, tol: Tolerances) -> OgeomResult<Self> {
280        Self::new(Vector2::new(x, y), tol)
281    }
282
283    /// A direction from a vector that is *already* a unit vector.
284    ///
285    /// As [`Direction::unit`]: it checks rather than normalizes, so a direction
286    /// read back from a document is the one that was written and not something
287    /// a bit or two away from it.
288    ///
289    /// # Errors
290    ///
291    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `v` is
292    /// non-finite, or its length differs from one by more than
293    /// `tol.confusion()`.
294    pub fn unit(v: Vector2, tol: Tolerances) -> OgeomResult<Self> {
295        if !v.is_finite() {
296            ogeom_bail!(Construction, "a direction must be finite; got {v:?}");
297        }
298        let length = v.magnitude();
299        if (length - 1.0).abs() > tol.confusion() {
300            ogeom_bail!(
301                Construction,
302                "expected a unit vector, got one of length {length}"
303            );
304        }
305        Ok(Self(v))
306    }
307
308    /// The direction at `angle` radians counter-clockwise from +X.
309    #[must_use]
310    pub fn from_angle(angle: f64) -> Self {
311        let (sin, cos) = angle.sin_cos();
312        Self(Vector2::new(cos, sin))
313    }
314
315    /// The underlying unit vector.
316    #[must_use]
317    pub const fn vector(self) -> Vector2 {
318        self.0
319    }
320
321    /// X component.
322    #[must_use]
323    pub const fn x(self) -> f64 {
324        self.0.x
325    }
326
327    /// Y component.
328    #[must_use]
329    pub const fn y(self) -> f64 {
330        self.0.y
331    }
332
333    /// Components as an array.
334    #[must_use]
335    pub const fn to_array(self) -> [f64; 2] {
336        self.0.to_array()
337    }
338
339    /// Dot product: the cosine of the angle between the two.
340    #[must_use]
341    pub fn dot(self, other: Self) -> f64 {
342        self.0.dot(other.0)
343    }
344
345    /// Scalar cross product: the sine of the signed angle from `self` to
346    /// `other`.
347    #[must_use]
348    pub fn cross(self, other: Self) -> f64 {
349        self.0.cross(other.0)
350    }
351
352    /// This direction rotated a quarter turn counter-clockwise. Exact.
353    #[must_use]
354    pub const fn perpendicular(self) -> Self {
355        Self(self.0.perpendicular())
356    }
357
358    /// Angle from +X, in `(-π, π]`.
359    #[must_use]
360    pub fn to_angle(self) -> f64 {
361        self.0.y.atan2(self.0.x)
362    }
363
364    /// Signed angle to `other`, in `(-π, π]`, positive counter-clockwise.
365    #[must_use]
366    pub fn angle(self, other: Self) -> f64 {
367        self.cross(other).atan2(self.dot(other))
368    }
369
370    /// Whether the two point the same way, within `tol.angular()`.
371    #[must_use]
372    pub fn is_equal(self, other: Self, tol: Tolerances) -> bool {
373        self.angle(other).abs() <= tol.angular()
374    }
375
376    /// Whether the two point opposite ways, within `tol.angular()`.
377    #[must_use]
378    pub fn is_opposite(self, other: Self, tol: Tolerances) -> bool {
379        core::f64::consts::PI - self.angle(other).abs() <= tol.angular()
380    }
381
382    /// Whether the two are parallel, ignoring sense.
383    #[must_use]
384    pub fn is_parallel(self, other: Self, tol: Tolerances) -> bool {
385        self.is_equal(other, tol) || self.is_opposite(other, tol)
386    }
387
388    /// Whether the two are perpendicular, within `tol.angular()`.
389    #[must_use]
390    pub fn is_normal(self, other: Self, tol: Tolerances) -> bool {
391        (core::f64::consts::FRAC_PI_2 - self.angle(other).abs()).abs() <= tol.angular()
392    }
393
394    /// This direction reflected through the origin.
395    #[must_use]
396    pub const fn reversed(self) -> Self {
397        Self(Vector2::new(-self.0.x, -self.0.y))
398    }
399
400    /// This direction embedded in the XY plane.
401    #[must_use]
402    pub const fn to_3d(self) -> Direction {
403        Direction(Vector::new(self.0.x, self.0.y, 0.0))
404    }
405}
406
407impl Neg for Direction {
408    type Output = Self;
409    fn neg(self) -> Self {
410        self.reversed()
411    }
412}
413
414impl Neg for Direction2 {
415    type Output = Self;
416    fn neg(self) -> Self {
417        self.reversed()
418    }
419}
420
421impl Mul<f64> for Direction {
422    type Output = Vector;
423    /// Scaling a direction yields a free vector: the result is no longer unit
424    /// length, so it is no longer a direction.
425    fn mul(self, s: f64) -> Vector {
426        self.0 * s
427    }
428}
429
430impl Mul<Direction> for f64 {
431    type Output = Vector;
432    fn mul(self, d: Direction) -> Vector {
433        d.0 * self
434    }
435}
436
437impl Mul<f64> for Direction2 {
438    type Output = Vector2;
439    fn mul(self, s: f64) -> Vector2 {
440        self.0 * s
441    }
442}
443
444impl Mul<Direction2> for f64 {
445    type Output = Vector2;
446    fn mul(self, d: Direction2) -> Vector2 {
447        d.0 * self
448    }
449}
450
451impl From<Direction> for Vector {
452    fn from(d: Direction) -> Self {
453        d.0
454    }
455}
456
457impl From<Direction2> for Vector2 {
458    fn from(d: Direction2) -> Self {
459        d.0
460    }
461}
462
463#[cfg(test)]
464#[allow(clippy::unwrap_used)]
465mod tests {
466    use super::*;
467    use approx::assert_relative_eq;
468
469    const T: Tolerances = Tolerances::millimetres();
470
471    #[test]
472    fn every_construction_path_yields_unit_length() {
473        let cases = [
474            Direction::new(Vector::new(3.0, 4.0, 12.0), T).unwrap(),
475            Direction::from_coords(-1.0, 2.0, -0.5, T).unwrap(),
476            Direction::X.any_perpendicular(),
477            Direction::new(Vector::new(1.0, 1.0, 1.0), T)
478                .unwrap()
479                .reversed(),
480            Direction::X.cross(Direction::Y, T).unwrap(),
481        ];
482        for d in cases {
483            assert_relative_eq!(d.vector().magnitude(), 1.0, epsilon = 1e-15);
484        }
485    }
486
487    #[test]
488    fn degenerate_input_is_refused() {
489        assert!(Direction::new(Vector::ZERO, T).is_err());
490        assert!(Direction::from_coords(f64::NAN, 0.0, 0.0, T).is_err());
491        assert!(Direction2::new(Vector2::ZERO, T).is_err());
492        // Collinear directions have a null cross product.
493        assert!(Direction::X.cross(Direction::X, T).is_err());
494        assert!(Direction::X.cross(-Direction::X, T).is_err());
495        assert!(Direction::from_cross(Vector::X, Vector::X * 3.0, T).is_err());
496        assert!(Direction::from_cross(Vector::ZERO, Vector::Y, T).is_err());
497    }
498
499    #[test]
500    fn a_normal_to_a_tiny_triangle_is_still_well_defined() {
501        // The trap: |a x b| is twice the triangle's area, so it scales as the
502        // square of the size. Comparing it against a length tolerance rejects
503        // small-but-perfectly-valid triangles.
504        for scale in [1e-6_f64, 1e-3, 1.0, 1e3] {
505            let a = Vector::new(scale, 0.0, 0.0);
506            let b = Vector::new(0.0, scale, 0.0);
507            let n = Direction::from_cross(a, b, T).unwrap();
508            assert!(n.is_equal(Direction::Z, T), "failed at scale {scale}");
509        }
510        // Whereas the naive route does reject them, which is why it is not used.
511        assert!(
512            Direction::new(
513                Vector::new(1e-6, 0.0, 0.0).cross(Vector::new(0.0, 1e-6, 0.0)),
514                T
515            )
516            .is_err()
517        );
518    }
519
520    #[test]
521    fn any_perpendicular_is_sound_for_every_axis_alignment() {
522        // The failure mode this guards: crossing with a fixed axis gives a
523        // near-null result when the input happens to be parallel to that axis.
524        let cases = [
525            Direction::X,
526            Direction::Y,
527            Direction::Z,
528            -Direction::X,
529            -Direction::Z,
530            Direction::from_coords(1.0, 1.0, 1.0, T).unwrap(),
531            Direction::from_coords(1.0, 1e-14, 1e-14, T).unwrap(),
532            Direction::from_coords(1e-14, 1e-14, 1.0, T).unwrap(),
533        ];
534        for d in cases {
535            let p = d.any_perpendicular();
536            assert_relative_eq!(p.vector().magnitude(), 1.0, epsilon = 1e-14);
537            assert_relative_eq!(d.dot(p), 0.0, epsilon = 1e-14);
538        }
539    }
540
541    #[test]
542    fn angle_relations() {
543        assert_relative_eq!(Direction::X.angle(Direction::X), 0.0);
544        assert_relative_eq!(Direction::X.angle(-Direction::X), core::f64::consts::PI);
545        assert_relative_eq!(
546            Direction::X.angle(Direction::Y),
547            core::f64::consts::FRAC_PI_2
548        );
549        assert!(Direction::X.is_equal(Direction::X, T));
550        assert!(Direction::X.is_opposite(-Direction::X, T));
551        assert!(Direction::X.is_parallel(-Direction::X, T));
552        assert!(!Direction::X.is_equal(-Direction::X, T));
553        assert!(Direction::X.is_normal(Direction::Y, T));
554    }
555
556    #[test]
557    fn scaling_a_direction_gives_a_free_vector() {
558        // The type change is the point: the result is not unit length, so it
559        // must not keep claiming to be a direction.
560        let v: Vector = Direction::X * 5.0;
561        assert_eq!(v, Vector::new(5.0, 0.0, 0.0));
562        assert_eq!(5.0 * Direction::X, v);
563    }
564
565    #[test]
566    fn direction2_angle_round_trips() {
567        // Compare directions rather than angles. Angles are only defined modulo
568        // 2*pi and `to_angle` has a branch cut, so a direct comparison fails at
569        // the cut for reasons that say nothing about correctness: `from_angle`
570        // of exactly -pi produces a tiny negative y, which `to_angle` maps back
571        // to -pi rather than +pi. Both name the same direction.
572        for turns in 0..32 {
573            let a = f64::from(turns) * core::f64::consts::PI / 16.0 - core::f64::consts::PI;
574            let d = Direction2::from_angle(a);
575            assert_relative_eq!(d.vector().magnitude(), 1.0, epsilon = 1e-15);
576            assert!(
577                Direction2::from_angle(d.to_angle()).is_equal(d, T),
578                "round trip failed at {a}"
579            );
580        }
581    }
582
583    #[test]
584    fn direction2_perpendicular_is_exact_and_has_period_four() {
585        let d = Direction2::from_angle(0.37);
586        assert_eq!(
587            d.perpendicular()
588                .perpendicular()
589                .perpendicular()
590                .perpendicular(),
591            d
592        );
593        assert_eq!(d.perpendicular().dot(d), 0.0, "exactly zero");
594    }
595
596    #[test]
597    fn direction2_signed_angle() {
598        let quarter = core::f64::consts::FRAC_PI_2;
599        assert_relative_eq!(Direction2::X.angle(Direction2::Y), quarter);
600        assert_relative_eq!(Direction2::Y.angle(Direction2::X), -quarter);
601    }
602
603    #[test]
604    fn dimension_round_trip() {
605        let d = Direction2::from_angle(0.9);
606        let up = d.to_3d();
607        assert_relative_eq!(up.z(), 0.0);
608        assert!(up.to_2d(T).unwrap().is_equal(d, T));
609        // A direction with nothing in the XY plane cannot be projected into it.
610        assert!(Direction::Z.to_2d(T).is_err());
611    }
612}