1use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
18
19use crate::{Direction, Direction2, Matrix3, Point, Point2, Vector, Vector2};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub enum Handedness {
24 #[default]
26 Right,
27 Left,
29}
30
31impl Handedness {
32 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq)]
53pub struct Axis {
54 pub location: Point,
56 pub direction: Direction,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq)]
62pub struct Axis2 {
63 pub location: Point2,
65 pub direction: Direction2,
67}
68
69#[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#[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 pub const X: Self = Self {
95 location: Point::ORIGIN,
96 direction: Direction::X,
97 };
98 pub const Y: Self = Self {
100 location: Point::ORIGIN,
101 direction: Direction::Y,
102 };
103 pub const Z: Self = Self {
105 location: Point::ORIGIN,
106 direction: Direction::Z,
107 };
108
109 #[must_use]
111 pub const fn new(location: Point, direction: Direction) -> Self {
112 Self {
113 location,
114 direction,
115 }
116 }
117
118 pub fn through(from: Point, to: Point, tol: Tolerances) -> OgeomResult<Self> {
125 Ok(Self::new(from, Direction::new(to - from, tol)?))
126 }
127
128 #[must_use]
130 pub const fn reversed(self) -> Self {
131 Self::new(self.location, self.direction.reversed())
132 }
133
134 #[must_use]
137 pub fn point_at(self, t: f64) -> Point {
138 self.location + self.direction * t
139 }
140
141 #[must_use]
143 pub fn parameter_of(self, p: Point) -> f64 {
144 self.direction.dot_vector(p - self.location)
145 }
146
147 #[must_use]
149 pub fn project(self, p: Point) -> Point {
150 self.point_at(self.parameter_of(p))
151 }
152
153 #[must_use]
155 pub fn distance_to(self, p: Point) -> f64 {
156 self.direction.cross_with(p - self.location).magnitude()
160 }
161
162 #[must_use]
164 pub fn contains(self, p: Point, tol: Tolerances) -> bool {
165 self.distance_to(p) <= tol.confusion()
166 }
167
168 #[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 #[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 pub const X: Self = Self {
188 location: Point2::ORIGIN,
189 direction: Direction2::X,
190 };
191 pub const Y: Self = Self {
193 location: Point2::ORIGIN,
194 direction: Direction2::Y,
195 };
196
197 #[must_use]
199 pub const fn new(location: Point2, direction: Direction2) -> Self {
200 Self {
201 location,
202 direction,
203 }
204 }
205
206 pub fn through(from: Point2, to: Point2, tol: Tolerances) -> OgeomResult<Self> {
213 Ok(Self::new(from, Direction2::new(to - from, tol)?))
214 }
215
216 #[must_use]
218 pub const fn reversed(self) -> Self {
219 Self::new(self.location, self.direction.reversed())
220 }
221
222 #[must_use]
224 pub fn point_at(self, t: f64) -> Point2 {
225 self.location + self.direction * t
226 }
227
228 #[must_use]
230 pub fn parameter_of(self, p: Point2) -> f64 {
231 self.direction.vector().dot(p - self.location)
232 }
233
234 #[must_use]
236 pub fn project(self, p: Point2) -> Point2 {
237 self.point_at(self.parameter_of(p))
238 }
239
240 #[must_use]
242 pub fn signed_distance_to(self, p: Point2) -> f64 {
243 self.direction.vector().cross(p - self.location)
244 }
245
246 #[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 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 pub fn new(
281 origin: Point,
282 z: Direction,
283 x_reference: Direction,
284 tol: Tolerances,
285 ) -> OgeomResult<Self> {
286 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 #[must_use]
311 pub fn about(origin: Point, z: Direction) -> Self {
312 let x = z.any_perpendicular();
313 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 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 #[must_use]
367 pub const fn origin(&self) -> Point {
368 self.origin
369 }
370
371 #[must_use]
373 pub const fn z(&self) -> Direction {
374 self.z
375 }
376
377 #[must_use]
379 pub const fn x(&self) -> Direction {
380 self.x
381 }
382
383 #[must_use]
385 pub const fn y(&self) -> Direction {
386 self.y
387 }
388
389 #[must_use]
391 pub const fn handedness(&self) -> Handedness {
392 self.handedness
393 }
394
395 #[must_use]
397 pub const fn axis(&self) -> Axis {
398 Axis::new(self.origin, self.z)
399 }
400
401 #[must_use]
403 pub const fn with_origin(&self, origin: Point) -> Self {
404 Self { origin, ..*self }
405 }
406
407 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 pub const WORLD: Self = Self {
495 origin: Point2::ORIGIN,
496 x: Direction2::X,
497 y: Direction2::Y,
498 handedness: Handedness::Right,
499 };
500
501 #[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 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 #[must_use]
542 pub const fn origin(&self) -> Point2 {
543 self.origin
544 }
545
546 #[must_use]
548 pub const fn x(&self) -> Direction2 {
549 self.x
550 }
551
552 #[must_use]
554 pub const fn y(&self) -> Direction2 {
555 self.y
556 }
557
558 #[must_use]
560 pub const fn handedness(&self) -> Handedness {
561 self.handedness
562 }
563
564 #[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 #[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 #[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 #[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 #[must_use]
595 pub fn vector_to_world(&self, v: Vector2) -> Vector2 {
596 self.x * v.x + self.y * v.y
597 }
598
599 #[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 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 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 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}