1use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
19
20use crate::{Direction, Direction2, Matrix3, Point, Point2, Vector, Vector2};
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum Handedness {
25 #[default]
27 Right,
28 Left,
30}
31
32impl Handedness {
33 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq)]
54pub struct Axis {
55 pub location: Point,
57 pub direction: Direction,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq)]
63pub struct Axis2 {
64 pub location: Point2,
66 pub direction: Direction2,
68}
69
70#[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#[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 pub const X: Self = Self {
96 location: Point::ORIGIN,
97 direction: Direction::X,
98 };
99 pub const Y: Self = Self {
101 location: Point::ORIGIN,
102 direction: Direction::Y,
103 };
104 pub const Z: Self = Self {
106 location: Point::ORIGIN,
107 direction: Direction::Z,
108 };
109
110 #[must_use]
112 pub const fn new(location: Point, direction: Direction) -> Self {
113 Self {
114 location,
115 direction,
116 }
117 }
118
119 pub fn through(from: Point, to: Point, tol: Tolerances) -> OgeomResult<Self> {
126 Ok(Self::new(from, Direction::new(to - from, tol)?))
127 }
128
129 #[must_use]
131 pub const fn reversed(self) -> Self {
132 Self::new(self.location, self.direction.reversed())
133 }
134
135 #[must_use]
138 pub fn point_at(self, t: f64) -> Point {
139 self.location + self.direction * t
140 }
141
142 #[must_use]
144 pub fn parameter_of(self, p: Point) -> f64 {
145 self.direction.dot_vector(p - self.location)
146 }
147
148 #[must_use]
150 pub fn project(self, p: Point) -> Point {
151 self.point_at(self.parameter_of(p))
152 }
153
154 #[must_use]
156 pub fn distance_to(self, p: Point) -> f64 {
157 self.direction.cross_with(p - self.location).magnitude()
161 }
162
163 #[must_use]
165 pub fn contains(self, p: Point, tol: Tolerances) -> bool {
166 self.distance_to(p) <= tol.confusion()
167 }
168
169 #[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 #[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 pub const X: Self = Self {
189 location: Point2::ORIGIN,
190 direction: Direction2::X,
191 };
192 pub const Y: Self = Self {
194 location: Point2::ORIGIN,
195 direction: Direction2::Y,
196 };
197
198 #[must_use]
200 pub const fn new(location: Point2, direction: Direction2) -> Self {
201 Self {
202 location,
203 direction,
204 }
205 }
206
207 pub fn through(from: Point2, to: Point2, tol: Tolerances) -> OgeomResult<Self> {
214 Ok(Self::new(from, Direction2::new(to - from, tol)?))
215 }
216
217 #[must_use]
219 pub const fn reversed(self) -> Self {
220 Self::new(self.location, self.direction.reversed())
221 }
222
223 #[must_use]
225 pub fn point_at(self, t: f64) -> Point2 {
226 self.location + self.direction * t
227 }
228
229 #[must_use]
231 pub fn parameter_of(self, p: Point2) -> f64 {
232 self.direction.vector().dot(p - self.location)
233 }
234
235 #[must_use]
237 pub fn project(self, p: Point2) -> Point2 {
238 self.point_at(self.parameter_of(p))
239 }
240
241 #[must_use]
243 pub fn signed_distance_to(self, p: Point2) -> f64 {
244 self.direction.vector().cross(p - self.location)
245 }
246
247 #[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 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 pub fn new(
282 origin: Point,
283 z: Direction,
284 x_reference: Direction,
285 tol: Tolerances,
286 ) -> OgeomResult<Self> {
287 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 #[must_use]
312 pub fn about(origin: Point, z: Direction) -> Self {
313 let x = z.any_perpendicular();
314 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 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 #[must_use]
368 pub const fn origin(&self) -> Point {
369 self.origin
370 }
371
372 #[must_use]
374 pub const fn z(&self) -> Direction {
375 self.z
376 }
377
378 #[must_use]
380 pub const fn x(&self) -> Direction {
381 self.x
382 }
383
384 #[must_use]
386 pub const fn y(&self) -> Direction {
387 self.y
388 }
389
390 #[must_use]
392 pub const fn handedness(&self) -> Handedness {
393 self.handedness
394 }
395
396 #[must_use]
398 pub const fn axis(&self) -> Axis {
399 Axis::new(self.origin, self.z)
400 }
401
402 #[must_use]
404 pub const fn with_origin(&self, origin: Point) -> Self {
405 Self { origin, ..*self }
406 }
407
408 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 pub const WORLD: Self = Self {
496 origin: Point2::ORIGIN,
497 x: Direction2::X,
498 y: Direction2::Y,
499 handedness: Handedness::Right,
500 };
501
502 #[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 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 #[must_use]
543 pub const fn origin(&self) -> Point2 {
544 self.origin
545 }
546
547 #[must_use]
549 pub const fn x(&self) -> Direction2 {
550 self.x
551 }
552
553 #[must_use]
555 pub const fn y(&self) -> Direction2 {
556 self.y
557 }
558
559 #[must_use]
561 pub const fn handedness(&self) -> Handedness {
562 self.handedness
563 }
564
565 #[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 #[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 #[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 #[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 #[must_use]
596 pub fn vector_to_world(&self, v: Vector2) -> Vector2 {
597 self.x * v.x + self.y * v.y
598 }
599
600 #[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 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 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 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}