1use core::ops::Mul;
30
31use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
32
33use crate::{
34 Axis, Direction, Direction2, Frame, Frame2, Matrix2, Matrix3, Point, Point2, Quaternion,
35 Vector, Vector2,
36};
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
40pub enum TransformKind {
41 #[default]
43 Identity,
44 Translation,
46 Rotation,
48 PointMirror,
50 PlaneMirror,
52 Scale,
54 Compound,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq)]
63pub struct Transform {
64 linear: Matrix3,
65 scale: f64,
66 translation: Vector,
67 kind: TransformKind,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq)]
72pub struct Transform2 {
73 linear: Matrix2,
74 scale: f64,
75 translation: Vector2,
76 kind: TransformKind,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq)]
85pub struct GeneralTransform {
86 pub linear: Matrix3,
88 pub translation: Vector,
90}
91
92const CLASSIFY_EPS: f64 = 1e-12;
97
98impl Default for Transform {
99 fn default() -> Self {
100 Self::IDENTITY
101 }
102}
103
104impl Transform {
105 pub const IDENTITY: Self = Self {
107 linear: Matrix3::IDENTITY,
108 scale: 1.0,
109 translation: Vector::ZERO,
110 kind: TransformKind::Identity,
111 };
112
113 fn build(linear: Matrix3, scale: f64, translation: Vector) -> Self {
118 let kind = Self::classify(&linear, scale, translation);
119 Self {
120 linear,
121 scale,
122 translation,
123 kind,
124 }
125 }
126
127 pub fn from_parts(
147 linear: Matrix3,
148 scale: f64,
149 translation: Vector,
150 eps: f64,
151 ) -> OgeomResult<Self> {
152 if !scale.is_finite() || scale == 0.0 {
153 ogeom_bail!(
154 Construction,
155 "a placement's scale must be finite and non-zero; got {scale}"
156 );
157 }
158 if !linear.is_orthonormal(eps) {
159 ogeom_bail!(
160 Construction,
161 "a placement's linear part must be orthonormal; this one shears or scales unevenly"
162 );
163 }
164 Ok(Self::build(linear, scale, translation))
165 }
166
167 fn classify(linear: &Matrix3, scale: f64, translation: Vector) -> TransformKind {
169 let is_identity_linear = linear.is_equal(&Matrix3::IDENTITY, CLASSIFY_EPS);
170 let unit_scale = (scale - 1.0).abs() <= CLASSIFY_EPS;
171 let negative_unit_scale = (scale + 1.0).abs() <= CLASSIFY_EPS;
172 let no_translation = translation.square_magnitude() == 0.0;
173
174 if is_identity_linear && unit_scale {
175 return if no_translation {
176 TransformKind::Identity
177 } else {
178 TransformKind::Translation
179 };
180 }
181 if is_identity_linear && negative_unit_scale {
182 return TransformKind::PointMirror;
183 }
184 if is_identity_linear {
185 return TransformKind::Scale;
186 }
187 if unit_scale && linear.is_orthonormal(CLASSIFY_EPS) {
188 return if linear.determinant() > 0.0 {
191 TransformKind::Rotation
192 } else {
193 TransformKind::PlaneMirror
194 };
195 }
196 TransformKind::Compound
197 }
198
199 #[must_use]
201 pub fn translation(v: Vector) -> Self {
202 Self::build(Matrix3::IDENTITY, 1.0, v)
203 }
204
205 #[must_use]
207 pub fn rotation(axis: Axis, angle: f64) -> Self {
208 let linear = Matrix3::rotation(axis.direction, angle);
209 let p = axis.location.to_vector();
213 Self::build(linear, 1.0, p - linear * p)
214 }
215
216 #[must_use]
218 pub fn from_quaternion(q: Quaternion) -> Self {
219 Self::build(q.to_matrix(), 1.0, Vector::ZERO)
220 }
221
222 pub fn scaling(centre: Point, factor: f64, tol: Tolerances) -> OgeomResult<Self> {
231 if !factor.is_finite() || factor.abs() <= tol.confusion() {
232 ogeom_bail!(Construction, "scale factor {factor} is degenerate");
233 }
234 let c = centre.to_vector();
235 Ok(Self::build(Matrix3::IDENTITY, factor, c - c * factor))
236 }
237
238 #[must_use]
240 pub fn point_mirror(centre: Point) -> Self {
241 let c = centre.to_vector();
242 Self::build(Matrix3::IDENTITY, -1.0, c + c)
243 }
244
245 #[must_use]
247 pub fn plane_mirror(origin: Point, normal: Direction) -> Self {
248 let linear = Matrix3::reflection(normal);
249 let p = origin.to_vector();
250 Self::build(linear, 1.0, p - linear * p)
251 }
252
253 #[must_use]
255 pub fn axis_mirror(axis: Axis) -> Self {
256 Self::rotation(axis, core::f64::consts::PI)
257 }
258
259 #[must_use]
261 pub fn to_frame(frame: &Frame) -> Self {
262 let linear = frame.to_matrix().transposed();
263 Self::build(linear, 1.0, -(linear * frame.origin().to_vector()))
264 }
265
266 #[must_use]
268 pub fn from_frame(frame: &Frame) -> Self {
269 Self::build(frame.to_matrix(), 1.0, frame.origin().to_vector())
270 }
271
272 #[must_use]
274 pub fn between_frames(from: &Frame, to: &Frame) -> Self {
275 Self::to_frame(to) * Self::from_frame(from)
276 }
277
278 #[must_use]
280 pub const fn kind(&self) -> TransformKind {
281 self.kind
282 }
283
284 #[must_use]
286 pub const fn linear(&self) -> Matrix3 {
287 self.linear
288 }
289
290 #[must_use]
292 pub const fn scale_factor(&self) -> f64 {
293 self.scale
294 }
295
296 #[must_use]
298 pub const fn translation_vector(&self) -> Vector {
299 self.translation
300 }
301
302 #[must_use]
308 pub fn preserves_handedness(&self) -> bool {
309 self.linear.determinant() * self.scale.signum() > 0.0
310 }
311
312 #[must_use]
314 pub fn apply(&self, p: Point) -> Point {
315 match self.kind {
316 TransformKind::Identity => p,
317 TransformKind::Translation => p + self.translation,
318 TransformKind::PointMirror | TransformKind::Scale => {
319 Point::from_vector(p.to_vector() * self.scale + self.translation)
320 }
321 TransformKind::Rotation | TransformKind::PlaneMirror => {
322 Point::from_vector(self.linear * p.to_vector() + self.translation)
323 }
324 TransformKind::Compound => {
325 Point::from_vector(self.linear * (p.to_vector() * self.scale) + self.translation)
326 }
327 }
328 }
329
330 #[must_use]
332 pub fn apply_vector(&self, v: Vector) -> Vector {
333 match self.kind {
334 TransformKind::Identity | TransformKind::Translation => v,
335 TransformKind::PointMirror | TransformKind::Scale => v * self.scale,
336 TransformKind::Rotation | TransformKind::PlaneMirror => self.linear * v,
337 TransformKind::Compound => self.linear * (v * self.scale),
338 }
339 }
340
341 pub fn apply_direction(&self, d: Direction, tol: Tolerances) -> OgeomResult<Direction> {
352 match self.kind {
353 TransformKind::Identity | TransformKind::Translation => Ok(d),
354 TransformKind::Scale if self.scale > 0.0 => Ok(d),
356 TransformKind::PointMirror | TransformKind::Scale => Ok(d.reversed()),
357 _ => Direction::new(self.apply_vector(d.vector()), tol),
358 }
359 }
360
361 pub fn apply_frame(&self, f: &Frame, tol: Tolerances) -> OgeomResult<Frame> {
368 Frame::from_axes(
369 self.apply(f.origin()),
370 self.apply_direction(f.x(), tol)?,
371 self.apply_direction(f.y(), tol)?,
372 self.apply_direction(f.z(), tol)?,
373 tol,
374 )
375 }
376
377 pub fn inverse(&self) -> OgeomResult<Self> {
384 if self.kind == TransformKind::Identity {
385 return Ok(Self::IDENTITY);
386 }
387 if self.kind == TransformKind::Translation {
388 return Ok(Self::translation(-self.translation));
389 }
390 if self.scale == 0.0 {
391 ogeom_bail!(Numeric, "transform has a zero scale and no inverse");
392 }
393 let inv_linear = self.linear.transposed();
397 let inv_scale = 1.0 / self.scale;
398 Ok(Self::build(
399 inv_linear,
400 inv_scale,
401 -(inv_linear * self.translation) * inv_scale,
402 ))
403 }
404
405 #[must_use]
407 pub fn is_equal(&self, other: &Self, tol: Tolerances) -> bool {
408 (self.scale - other.scale).abs() <= CLASSIFY_EPS
409 && self.linear.is_equal(&other.linear, CLASSIFY_EPS)
410 && self.translation.is_equal(other.translation, tol)
411 }
412
413 #[must_use]
415 pub fn to_general(&self) -> GeneralTransform {
416 GeneralTransform {
417 linear: self.linear * self.scale,
418 translation: self.translation,
419 }
420 }
421}
422
423impl Mul for Transform {
424 type Output = Self;
425 fn mul(self, b: Self) -> Self {
427 if self.kind == TransformKind::Identity {
428 return b;
429 }
430 if b.kind == TransformKind::Identity {
431 return self;
432 }
433 Self::build(
434 self.linear * b.linear,
435 self.scale * b.scale,
436 self.linear * (b.translation * self.scale) + self.translation,
437 )
438 }
439}
440
441impl Default for Transform2 {
442 fn default() -> Self {
443 Self::IDENTITY
444 }
445}
446
447impl Transform2 {
448 pub const IDENTITY: Self = Self {
450 linear: Matrix2::IDENTITY,
451 scale: 1.0,
452 translation: Vector2::ZERO,
453 kind: TransformKind::Identity,
454 };
455
456 fn build(linear: Matrix2, scale: f64, translation: Vector2) -> Self {
457 let is_identity_linear = linear.is_equal(&Matrix2::IDENTITY, CLASSIFY_EPS);
458 let unit_scale = (scale - 1.0).abs() <= CLASSIFY_EPS;
459 let kind = if is_identity_linear && unit_scale {
460 if translation.square_magnitude() == 0.0 {
461 TransformKind::Identity
462 } else {
463 TransformKind::Translation
464 }
465 } else if is_identity_linear && (scale + 1.0).abs() <= CLASSIFY_EPS {
466 TransformKind::PointMirror
467 } else if is_identity_linear {
468 TransformKind::Scale
469 } else if unit_scale && (linear.determinant().abs() - 1.0).abs() <= CLASSIFY_EPS {
470 if linear.determinant() > 0.0 {
471 TransformKind::Rotation
472 } else {
473 TransformKind::PlaneMirror
474 }
475 } else {
476 TransformKind::Compound
477 };
478 Self {
479 linear,
480 scale,
481 translation,
482 kind,
483 }
484 }
485
486 #[must_use]
488 pub fn translation(v: Vector2) -> Self {
489 Self::build(Matrix2::IDENTITY, 1.0, v)
490 }
491
492 #[must_use]
494 pub fn rotation(centre: Point2, angle: f64) -> Self {
495 let linear = Matrix2::rotation(angle);
496 let c = centre.to_vector();
497 Self::build(linear, 1.0, c - linear * c)
498 }
499
500 pub fn scaling(centre: Point2, factor: f64, tol: Tolerances) -> OgeomResult<Self> {
507 if !factor.is_finite() || factor.abs() <= tol.confusion() {
508 ogeom_bail!(Construction, "scale factor {factor} is degenerate");
509 }
510 let c = centre.to_vector();
511 Ok(Self::build(Matrix2::IDENTITY, factor, c - c * factor))
512 }
513
514 #[must_use]
516 pub fn line_mirror(origin: Point2, normal: Direction2) -> Self {
517 let (x, y) = (normal.x(), normal.y());
518 let linear = Matrix2::new([
519 [(-2.0f64).mul_add(x * x, 1.0), -2.0 * x * y],
520 [-2.0 * x * y, (-2.0f64).mul_add(y * y, 1.0)],
521 ]);
522 let p = origin.to_vector();
523 Self::build(linear, 1.0, p - linear * p)
524 }
525
526 #[must_use]
528 pub const fn kind(&self) -> TransformKind {
529 self.kind
530 }
531
532 #[must_use]
534 pub const fn linear(&self) -> Matrix2 {
535 self.linear
536 }
537
538 #[must_use]
540 pub const fn scale_factor(&self) -> f64 {
541 self.scale
542 }
543
544 #[must_use]
546 pub const fn translation_vector(&self) -> Vector2 {
547 self.translation
548 }
549
550 #[must_use]
554 pub fn preserves_handedness(&self) -> bool {
555 self.linear.determinant() > 0.0
556 }
557
558 #[must_use]
560 pub fn apply(&self, p: Point2) -> Point2 {
561 match self.kind {
562 TransformKind::Identity => p,
563 TransformKind::Translation => p + self.translation,
564 TransformKind::PointMirror | TransformKind::Scale => {
565 Point2::from_vector(p.to_vector() * self.scale + self.translation)
566 }
567 TransformKind::Rotation | TransformKind::PlaneMirror => {
568 Point2::from_vector(self.linear * p.to_vector() + self.translation)
569 }
570 TransformKind::Compound => {
571 Point2::from_vector(self.linear * (p.to_vector() * self.scale) + self.translation)
572 }
573 }
574 }
575
576 pub fn apply_direction(&self, d: Direction2, tol: Tolerances) -> OgeomResult<Direction2> {
583 match self.kind {
584 TransformKind::Identity | TransformKind::Translation => Ok(d),
585 TransformKind::Scale if self.scale > 0.0 => Ok(d),
586 TransformKind::PointMirror | TransformKind::Scale => Ok(d.reversed()),
587 _ => Direction2::new(self.apply_vector(d.vector()), tol),
588 }
589 }
590
591 pub fn apply_frame(&self, f: &Frame2, tol: Tolerances) -> OgeomResult<Frame2> {
598 Frame2::from_axes(
599 self.apply(f.origin()),
600 self.apply_direction(f.x(), tol)?,
601 self.apply_direction(f.y(), tol)?,
602 tol,
603 )
604 }
605
606 #[must_use]
608 pub fn apply_vector(&self, v: Vector2) -> Vector2 {
609 match self.kind {
610 TransformKind::Identity | TransformKind::Translation => v,
611 TransformKind::PointMirror | TransformKind::Scale => v * self.scale,
612 TransformKind::Rotation | TransformKind::PlaneMirror => self.linear * v,
613 TransformKind::Compound => self.linear * (v * self.scale),
614 }
615 }
616
617 pub fn inverse(&self) -> OgeomResult<Self> {
624 if self.kind == TransformKind::Identity {
625 return Ok(Self::IDENTITY);
626 }
627 if self.scale == 0.0 {
628 ogeom_bail!(Numeric, "transform has a zero scale and no inverse");
629 }
630 let inv_linear = self.linear.transposed();
631 let inv_scale = 1.0 / self.scale;
632 Ok(Self::build(
633 inv_linear,
634 inv_scale,
635 -(inv_linear * self.translation) * inv_scale,
636 ))
637 }
638
639 #[must_use]
641 pub fn is_equal(&self, other: &Self, tol: Tolerances) -> bool {
642 (self.scale - other.scale).abs() <= CLASSIFY_EPS
643 && self.linear.is_equal(&other.linear, CLASSIFY_EPS)
644 && self.translation.is_equal(other.translation, tol)
645 }
646}
647
648impl Mul for Transform2 {
649 type Output = Self;
650 fn mul(self, b: Self) -> Self {
651 if self.kind == TransformKind::Identity {
652 return b;
653 }
654 if b.kind == TransformKind::Identity {
655 return self;
656 }
657 Self::build(
658 self.linear * b.linear,
659 self.scale * b.scale,
660 self.linear * (b.translation * self.scale) + self.translation,
661 )
662 }
663}
664
665impl Default for GeneralTransform {
666 fn default() -> Self {
667 Self::IDENTITY
668 }
669}
670
671impl GeneralTransform {
672 pub const IDENTITY: Self = Self {
674 linear: Matrix3::IDENTITY,
675 translation: Vector::ZERO,
676 };
677
678 #[must_use]
680 pub const fn new(linear: Matrix3, translation: Vector) -> Self {
681 Self {
682 linear,
683 translation,
684 }
685 }
686
687 #[must_use]
689 pub const fn scaling_xyz(x: f64, y: f64, z: f64) -> Self {
690 Self::new(Matrix3::scaling_xyz(x, y, z), Vector::ZERO)
691 }
692
693 #[must_use]
695 pub fn apply(&self, p: Point) -> Point {
696 Point::from_vector(self.linear * p.to_vector() + self.translation)
697 }
698
699 #[must_use]
701 pub fn apply_vector(&self, v: Vector) -> Vector {
702 self.linear * v
703 }
704
705 pub fn apply_normal(&self, n: Vector) -> OgeomResult<Vector> {
719 Ok(self.linear.inverse()?.transposed() * n)
720 }
721
722 #[must_use]
724 pub fn preserves_handedness(&self) -> bool {
725 self.linear.determinant() > 0.0
726 }
727
728 #[must_use]
730 pub fn volume_ratio(&self) -> f64 {
731 self.linear.determinant()
732 }
733
734 pub fn inverse(&self) -> OgeomResult<Self> {
741 let inv = self.linear.inverse()?;
742 Ok(Self::new(inv, -(inv * self.translation)))
743 }
744
745 #[must_use]
747 pub fn is_similarity(&self, eps: f64) -> bool {
748 self.to_similarity(eps).is_some()
749 }
750
751 #[must_use]
756 pub fn to_similarity(&self, eps: f64) -> Option<Transform> {
757 let det = self.linear.determinant();
760 if det == 0.0 {
761 return None;
762 }
763 let scale = det.abs().cbrt() * det.signum();
764 let rotation = self.linear * (1.0 / scale);
765 if !rotation.is_orthonormal(eps) {
766 return None;
767 }
768 Some(Transform::build(rotation, scale, self.translation))
769 }
770}
771
772impl Mul for GeneralTransform {
773 type Output = Self;
774 fn mul(self, b: Self) -> Self {
776 Self::new(
777 self.linear * b.linear,
778 self.linear * b.translation + self.translation,
779 )
780 }
781}
782
783impl From<Transform> for GeneralTransform {
784 fn from(t: Transform) -> Self {
785 t.to_general()
786 }
787}
788
789#[cfg(test)]
790#[allow(clippy::unwrap_used)]
791mod tests {
792 use super::*;
793 use approx::assert_relative_eq;
794
795 const T: Tolerances = Tolerances::millimetres();
796
797 #[test]
801 fn a_negative_scale_reverses_directions() {
802 let s = Transform::scaling(Point::ORIGIN, -2.0, T).unwrap();
803 let d = s.apply_direction(Direction::X, T).unwrap();
804 let v = s.apply_vector(Vector::new(1.0, 0.0, 0.0));
805 assert!(d.vector().dot(v) > 0.0, "{d:?} against {v:?}");
806 assert!(!s.preserves_handedness());
807 let s2 = Transform2::scaling(Point2::ORIGIN, -3.0, T).unwrap();
808 let d2 = s2.apply_direction(Direction2::X, T).unwrap();
809 assert!(d2.vector().dot(s2.apply_vector(Direction2::X.vector())) > 0.0);
810 let p = Transform::scaling(Point::ORIGIN, 2.0, T).unwrap();
812 assert_eq!(p.apply_direction(Direction::X, T).unwrap(), Direction::X);
813 }
814
815 fn sample_points() -> [Point; 4] {
816 [
817 Point::ORIGIN,
818 Point::new(1.0, 0.0, 0.0),
819 Point::new(-3.0, 7.5, 2.25),
820 Point::new(1e3, -1e3, 0.5),
821 ]
822 }
823
824 #[test]
825 fn classification_matches_what_the_transform_does() {
826 assert_eq!(Transform::IDENTITY.kind(), TransformKind::Identity);
827 assert_eq!(
828 Transform::translation(Vector::X).kind(),
829 TransformKind::Translation
830 );
831 assert_eq!(
832 Transform::rotation(Axis::Z, 0.5).kind(),
833 TransformKind::Rotation
834 );
835 assert_eq!(
836 Transform::point_mirror(Point::ORIGIN).kind(),
837 TransformKind::PointMirror
838 );
839 assert_eq!(
840 Transform::plane_mirror(Point::ORIGIN, Direction::Z).kind(),
841 TransformKind::PlaneMirror
842 );
843 assert_eq!(
844 Transform::scaling(Point::ORIGIN, 3.0, T).unwrap().kind(),
845 TransformKind::Scale
846 );
847 let compound =
848 Transform::rotation(Axis::Z, 0.5) * Transform::scaling(Point::ORIGIN, 3.0, T).unwrap();
849 assert_eq!(compound.kind(), TransformKind::Compound);
850 }
851
852 #[test]
853 fn a_zero_rotation_classifies_as_identity_not_rotation() {
854 assert_eq!(
857 Transform::rotation(Axis::Z, 0.0).kind(),
858 TransformKind::Identity
859 );
860 assert_eq!(
861 Transform::translation(Vector::ZERO).kind(),
862 TransformKind::Identity
863 );
864 assert_eq!(
865 Transform::scaling(Point::ORIGIN, 1.0, T).unwrap().kind(),
866 TransformKind::Identity
867 );
868 }
869
870 #[test]
871 fn every_dispatch_path_gives_the_same_answer_as_the_general_one() {
872 let cases = [
875 Transform::IDENTITY,
876 Transform::translation(Vector::new(1.0, -2.0, 3.0)),
877 Transform::rotation(Axis::new(Point::new(1.0, 0.0, 0.0), Direction::Z), 0.7),
878 Transform::point_mirror(Point::new(2.0, 0.0, -1.0)),
879 Transform::plane_mirror(Point::new(0.0, 1.0, 0.0), Direction::Y),
880 Transform::scaling(Point::new(1.0, 1.0, 1.0), 2.5, T).unwrap(),
881 ];
882 for t in cases {
883 for p in sample_points() {
884 let general = Point::from_vector(
885 t.linear() * (p.to_vector() * t.scale_factor()) + t.translation_vector(),
886 );
887 assert!(
888 t.apply(p).is_equal(general, T),
889 "fast path for {:?} disagrees",
890 t.kind()
891 );
892 }
893 }
894 }
895
896 #[test]
897 fn rotation_about_an_off_origin_axis_leaves_the_axis_fixed() {
898 let axis = Axis::new(Point::new(5.0, 3.0, 0.0), Direction::Z);
899 let t = Transform::rotation(axis, 1.234);
900 assert!(t.apply(axis.location).is_equal(axis.location, T));
901 assert!(
902 t.apply(axis.point_at(10.0))
903 .is_equal(axis.point_at(10.0), T)
904 );
905 let p = Point::new(6.0, 3.0, 0.0);
907 assert_relative_eq!(axis.distance_to(t.apply(p)), 1.0, epsilon = 1e-14);
908 }
909
910 #[test]
911 fn scaling_about_a_centre_leaves_the_centre_fixed() {
912 let c = Point::new(3.0, -1.0, 2.0);
913 let t = Transform::scaling(c, 4.0, T).unwrap();
914 assert!(t.apply(c).is_equal(c, T));
915 let p = c + Vector::new(1.0, 0.0, 0.0);
916 assert!(t.apply(p).is_equal(c + Vector::new(4.0, 0.0, 0.0), T));
917 }
918
919 #[test]
920 fn degenerate_scales_are_refused() {
921 assert!(Transform::scaling(Point::ORIGIN, 0.0, T).is_err());
922 assert!(Transform::scaling(Point::ORIGIN, f64::NAN, T).is_err());
923 assert!(Transform::scaling(Point::ORIGIN, f64::INFINITY, T).is_err());
924 assert!(
925 Transform::scaling(Point::ORIGIN, -2.0, T).is_ok(),
926 "negative is fine"
927 );
928 }
929
930 #[test]
931 fn handedness_tracks_mirroring() {
932 assert!(Transform::rotation(Axis::Z, 1.0).preserves_handedness());
933 assert!(Transform::translation(Vector::X).preserves_handedness());
934 assert!(
935 Transform::scaling(Point::ORIGIN, 3.0, T)
936 .unwrap()
937 .preserves_handedness()
938 );
939 assert!(!Transform::plane_mirror(Point::ORIGIN, Direction::Z).preserves_handedness());
940 assert!(!Transform::point_mirror(Point::ORIGIN).preserves_handedness());
941 let twice = Transform::plane_mirror(Point::ORIGIN, Direction::Z)
943 * Transform::plane_mirror(Point::ORIGIN, Direction::X);
944 assert!(twice.preserves_handedness());
945 }
946
947 #[test]
948 fn axis_mirror_is_a_half_turn() {
949 let t = Transform::axis_mirror(Axis::Z);
950 assert!(
951 t.apply(Point::new(1.0, 0.0, 5.0))
952 .is_equal(Point::new(-1.0, 0.0, 5.0), T)
953 );
954 assert!(t.preserves_handedness(), "a half turn is a rotation");
955 }
956
957 #[test]
958 fn inverse_round_trips_for_every_kind() {
959 let cases = [
960 Transform::IDENTITY,
961 Transform::translation(Vector::new(1.0, -2.0, 3.0)),
962 Transform::rotation(Axis::new(Point::new(1.0, 2.0, 3.0), Direction::Y), 2.1),
963 Transform::point_mirror(Point::new(1.0, 1.0, 1.0)),
964 Transform::plane_mirror(Point::new(0.0, 0.0, 4.0), Direction::Z),
965 Transform::scaling(Point::new(-1.0, 0.0, 0.0), 0.25, T).unwrap(),
966 ];
967 for t in cases {
968 let inv = t.inverse().unwrap();
969 for p in sample_points() {
970 assert!(inv.apply(t.apply(p)).is_equal(p, T), "{:?}", t.kind());
971 assert!(t.apply(inv.apply(p)).is_equal(p, T), "{:?}", t.kind());
972 }
973 }
974 }
975
976 #[test]
977 fn composition_applies_right_to_left() {
978 let a = Transform::translation(Vector::new(10.0, 0.0, 0.0));
979 let b = Transform::rotation(Axis::Z, core::f64::consts::FRAC_PI_2);
980 let p = Point::new(1.0, 0.0, 0.0);
981 assert!((a * b).apply(p).is_equal(a.apply(b.apply(p)), T));
982 assert!((b * a).apply(p).is_equal(b.apply(a.apply(p)), T));
983 assert!(!(a * b).is_equal(&(b * a), T));
985 }
986
987 #[test]
988 fn composition_is_associative() {
989 let a = Transform::rotation(Axis::X, 0.3);
990 let b = Transform::scaling(Point::new(1.0, 0.0, 0.0), 2.0, T).unwrap();
991 let c = Transform::translation(Vector::new(0.0, 5.0, 0.0));
992 assert!(((a * b) * c).is_equal(&(a * (b * c)), T));
993 }
994
995 #[test]
996 fn vectors_ignore_translation_and_directions_stay_unit() {
997 let t = Transform::translation(Vector::new(100.0, 0.0, 0.0))
998 * Transform::rotation(Axis::Z, 0.9);
999 let v = Vector::new(1.0, 2.0, 3.0);
1000 assert!(
1001 t.apply_vector(v)
1002 .is_equal(Transform::rotation(Axis::Z, 0.9).apply_vector(v), T)
1003 );
1004 let d = t.apply_direction(Direction::X, T).unwrap();
1005 assert_relative_eq!(d.vector().magnitude(), 1.0, epsilon = 1e-15);
1006 }
1007
1008 #[test]
1009 fn a_point_mirror_reverses_directions() {
1010 let t = Transform::point_mirror(Point::new(5.0, 5.0, 5.0));
1011 assert!(
1012 t.apply_direction(Direction::X, T)
1013 .unwrap()
1014 .is_equal(-Direction::X, T)
1015 );
1016 let s = Transform::scaling(Point::ORIGIN, 3.0, T).unwrap();
1018 assert!(
1019 s.apply_direction(Direction::X, T)
1020 .unwrap()
1021 .is_equal(Direction::X, T)
1022 );
1023 }
1024
1025 #[test]
1026 fn frame_transforms_round_trip_through_world() {
1027 let f = Frame::new(
1028 Point::new(1.0, 2.0, 3.0),
1029 Direction::from_coords(1.0, 1.0, 0.0, T).unwrap(),
1030 Direction::Z,
1031 T,
1032 )
1033 .unwrap();
1034 let to = Transform::to_frame(&f);
1035 let from = Transform::from_frame(&f);
1036 for p in sample_points() {
1037 assert!(from.apply(to.apply(p)).is_equal(p, T));
1038 assert!(to.apply(p).is_equal(f.to_local(p), T));
1040 assert!(from.apply(f.to_local(p)).is_equal(p, T));
1041 }
1042 }
1043
1044 #[test]
1045 fn between_frames_composes_correctly() {
1046 let a = Frame::new(Point::new(1.0, 0.0, 0.0), Direction::Z, Direction::X, T).unwrap();
1047 let b = Frame::new(Point::new(0.0, 5.0, 0.0), Direction::X, Direction::Y, T).unwrap();
1048 let t = Transform::between_frames(&a, &b);
1049 let local = Point::new(1.0, 2.0, 3.0);
1052 assert!(b.to_world(t.apply(local)).is_equal(a.to_world(local), T));
1053 }
1054
1055 #[test]
1056 fn general_transform_normals_use_the_inverse_transpose() {
1057 let g = GeneralTransform::scaling_xyz(2.0, 1.0, 1.0);
1062 let n = Vector::new(1.0, 0.0, -1.0);
1063 let transformed = g.apply_normal(n).unwrap();
1064
1065 let on_plane = Vector::new(1.0, 0.0, 1.0);
1066 assert_relative_eq!(n.dot(on_plane), 0.0, epsilon = 1e-15);
1067 assert_relative_eq!(
1068 transformed.dot(g.apply_vector(on_plane)),
1069 0.0,
1070 epsilon = 1e-14,
1071 max_relative = 1e-14
1072 );
1073 assert!(g.apply_vector(n).dot(g.apply_vector(on_plane)).abs() > 1e-6);
1075 }
1076
1077 #[test]
1078 fn general_transform_recognizes_similarities() {
1079 let similar: GeneralTransform = Transform::rotation(Axis::Z, 0.4).into();
1080 assert!(similar.is_similarity(1e-12));
1081 let narrowed = similar.to_similarity(1e-12).unwrap();
1082 assert_eq!(narrowed.kind(), TransformKind::Rotation);
1083
1084 let scaled: GeneralTransform = Transform::scaling(Point::ORIGIN, 3.0, T).unwrap().into();
1085 assert!(scaled.is_similarity(1e-12));
1086 assert_relative_eq!(
1087 scaled.to_similarity(1e-12).unwrap().scale_factor(),
1088 3.0,
1089 epsilon = 1e-12
1090 );
1091
1092 assert!(!GeneralTransform::scaling_xyz(1.0, 2.0, 3.0).is_similarity(1e-12));
1093 let shear = GeneralTransform::new(
1094 Matrix3::new([[1.0, 0.5, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]),
1095 Vector::ZERO,
1096 );
1097 assert!(!shear.is_similarity(1e-12));
1098 }
1099
1100 #[test]
1101 fn general_transform_volume_ratio_and_inverse() {
1102 let g = GeneralTransform::scaling_xyz(2.0, 3.0, 4.0);
1103 assert_relative_eq!(g.volume_ratio(), 24.0);
1104 assert!(g.preserves_handedness());
1105 let inv = g.inverse().unwrap();
1106 for p in sample_points() {
1107 assert!(inv.apply(g.apply(p)).is_equal(p, T));
1108 }
1109 let flip = GeneralTransform::scaling_xyz(-1.0, 1.0, 1.0);
1110 assert!(!flip.preserves_handedness());
1111 assert!(
1112 GeneralTransform::scaling_xyz(0.0, 1.0, 1.0)
1113 .inverse()
1114 .is_err()
1115 );
1116 }
1117
1118 #[test]
1119 fn transform2_behaves_like_its_3d_counterpart() {
1120 let r = Transform2::rotation(Point2::new(1.0, 1.0), core::f64::consts::FRAC_PI_2);
1121 assert_eq!(r.kind(), TransformKind::Rotation);
1122 assert!(
1123 r.apply(Point2::new(1.0, 1.0))
1124 .is_equal(Point2::new(1.0, 1.0), T)
1125 );
1126 assert!(
1127 r.apply(Point2::new(2.0, 1.0))
1128 .is_equal(Point2::new(1.0, 2.0), T)
1129 );
1130 assert!(
1131 r.inverse()
1132 .unwrap()
1133 .apply(r.apply(Point2::ORIGIN))
1134 .is_equal(Point2::ORIGIN, T)
1135 );
1136
1137 let m = Transform2::line_mirror(Point2::ORIGIN, Direction2::Y);
1138 assert_eq!(m.kind(), TransformKind::PlaneMirror);
1139 assert!(!m.preserves_handedness());
1140 assert!(
1141 m.apply(Point2::new(3.0, 2.0))
1142 .is_equal(Point2::new(3.0, -2.0), T)
1143 );
1144
1145 assert!(Transform2::scaling(Point2::ORIGIN, 0.0, T).is_err());
1146 }
1147}