1use core::ops::{Mul, Neg};
13
14use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
15
16use crate::{Vector, Vector2};
17
18#[derive(Debug, Clone, Copy, PartialEq)]
20pub struct Direction(Vector);
21
22#[derive(Debug, Clone, Copy, PartialEq)]
24pub struct Direction2(Vector2);
25
26impl Direction {
27 pub const X: Self = Self(Vector::X);
29 pub const Y: Self = Self(Vector::Y);
31 pub const Z: Self = Self(Vector::Z);
33
34 pub fn new(v: Vector, tol: Tolerances) -> OgeomResult<Self> {
41 Ok(Self(v.normalized(tol)?))
42 }
43
44 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 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 #[must_use]
83 pub const fn vector(self) -> Vector {
84 self.0
85 }
86
87 #[must_use]
89 pub const fn x(self) -> f64 {
90 self.0.x
91 }
92
93 #[must_use]
95 pub const fn y(self) -> f64 {
96 self.0.y
97 }
98
99 #[must_use]
101 pub const fn z(self) -> f64 {
102 self.0.z
103 }
104
105 #[must_use]
107 pub const fn to_array(self) -> [f64; 3] {
108 self.0.to_array()
109 }
110
111 #[must_use]
114 pub fn dot(self, other: Self) -> f64 {
115 self.0.dot(other.0)
116 }
117
118 #[must_use]
120 pub fn dot_vector(self, v: Vector) -> f64 {
121 self.0.dot(v)
122 }
123
124 #[must_use]
128 pub fn cross_vector(self, other: Self) -> Vector {
129 self.0.cross(other.0)
130 }
131
132 #[must_use]
139 pub fn cross_with(self, v: Vector) -> Vector {
140 self.0.cross(v)
141 }
142
143 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 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 #[must_use]
189 pub fn angle(self, other: Self) -> f64 {
190 self.cross_vector(other).magnitude().atan2(self.dot(other))
193 }
194
195 #[must_use]
197 pub fn is_equal(self, other: Self, tol: Tolerances) -> bool {
198 self.angle(other) <= tol.angular()
199 }
200
201 #[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 #[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 #[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 #[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 Self(v / v.magnitude())
239 }
240
241 #[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 pub fn to_2d(self, tol: Tolerances) -> OgeomResult<Direction2> {
254 Direction2::new(self.0.xy(), tol)
255 }
256}
257
258impl Direction2 {
259 pub const X: Self = Self(Vector2::X);
261 pub const Y: Self = Self(Vector2::Y);
263
264 pub fn new(v: Vector2, tol: Tolerances) -> OgeomResult<Self> {
271 Ok(Self(v.normalized(tol)?))
272 }
273
274 pub fn from_coords(x: f64, y: f64, tol: Tolerances) -> OgeomResult<Self> {
280 Self::new(Vector2::new(x, y), tol)
281 }
282
283 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 #[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 #[must_use]
317 pub const fn vector(self) -> Vector2 {
318 self.0
319 }
320
321 #[must_use]
323 pub const fn x(self) -> f64 {
324 self.0.x
325 }
326
327 #[must_use]
329 pub const fn y(self) -> f64 {
330 self.0.y
331 }
332
333 #[must_use]
335 pub const fn to_array(self) -> [f64; 2] {
336 self.0.to_array()
337 }
338
339 #[must_use]
341 pub fn dot(self, other: Self) -> f64 {
342 self.0.dot(other.0)
343 }
344
345 #[must_use]
348 pub fn cross(self, other: Self) -> f64 {
349 self.0.cross(other.0)
350 }
351
352 #[must_use]
354 pub const fn perpendicular(self) -> Self {
355 Self(self.0.perpendicular())
356 }
357
358 #[must_use]
360 pub fn to_angle(self) -> f64 {
361 self.0.y.atan2(self.0.x)
362 }
363
364 #[must_use]
366 pub fn angle(self, other: Self) -> f64 {
367 self.cross(other).atan2(self.dot(other))
368 }
369
370 #[must_use]
372 pub fn is_equal(self, other: Self, tol: Tolerances) -> bool {
373 self.angle(other).abs() <= tol.angular()
374 }
375
376 #[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 #[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 #[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 #[must_use]
396 pub const fn reversed(self) -> Self {
397 Self(Vector2::new(-self.0.x, -self.0.y))
398 }
399
400 #[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 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 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 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 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 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 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 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 assert!(Direction::Z.to_2d(T).is_err());
611 }
612}