Skip to main content

ogeom_math/
transform.rs

1//! Rigid and similarity transforms, and general affine transforms.
2//!
3//! [`Transform`] is a similarity: an orthonormal linear part, a uniform scale
4//! and a translation. That covers everything a solid modeller applies to a
5//! shape (placement, rotation, mirroring, uniform scaling) while preserving
6//! the two properties the geometry depends on: angles are unchanged, and an
7//! analytic surface stays the same *kind* of analytic surface. A cylinder
8//! remains a cylinder.
9//!
10//! [`GeneralTransform`] drops both guarantees, allowing non-uniform scaling and
11//! shear. It is a separate type on purpose: applying one turns a circle into an
12//! ellipse and a cylinder into something with no analytic form at all, so it
13//! cannot be used interchangeably.
14//!
15//! # Form classification
16//!
17//! Every [`Transform`] carries a [`TransformKind`], and applying one dispatches
18//! on it: a translation adds a vector, the identity does nothing at all. That
19//! matters because transforms are applied to every control point of every
20//! curve, every vertex of every tessellation, over an entire model: the
21//! difference between a branch and nine multiplies, repeated a hundred million
22//! times, is real.
23//!
24//! The kind is *derived from* the data rather than asserted alongside it, so it
25//! cannot drift out of agreement with the matrix it describes. Every
26//! constructor routes through one private classifier. There is no way to build a
27//! transform that claims more structure than it has.
28
29use 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/// How much structure a [`Transform`] has, for dispatch.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
40pub enum TransformKind {
41    /// Does nothing.
42    #[default]
43    Identity,
44    /// Translation only.
45    Translation,
46    /// Rotation about an axis through the origin, possibly with a translation.
47    Rotation,
48    /// Reflection through a point (equivalently, a scale of `-1`).
49    PointMirror,
50    /// Reflection in a plane.
51    PlaneMirror,
52    /// Uniform scaling, possibly with a translation.
53    Scale,
54    /// Anything else: a combination with no simpler description.
55    Compound,
56}
57
58/// A similarity transform: orthonormal rotation or reflection, uniform scale,
59/// translation.
60///
61/// Applied as `p -> linear * (scale * p) + translation`.
62#[derive(Debug, Clone, Copy, PartialEq)]
63pub struct Transform {
64    linear: Matrix3,
65    scale: f64,
66    translation: Vector,
67    kind: TransformKind,
68}
69
70/// A similarity transform in the plane.
71#[derive(Debug, Clone, Copy, PartialEq)]
72pub struct Transform2 {
73    linear: Matrix2,
74    scale: f64,
75    translation: Vector2,
76    kind: TransformKind,
77}
78
79/// A general affine transform: any linear part, plus a translation.
80///
81/// Non-uniform scaling and shear are allowed, so angles are not preserved and
82/// analytic geometry does not survive intact. Kept distinct from [`Transform`]
83/// so that cannot happen by accident.
84#[derive(Debug, Clone, Copy, PartialEq)]
85pub struct GeneralTransform {
86    /// The linear part.
87    pub linear: Matrix3,
88    /// The translation.
89    pub translation: Vector,
90}
91
92/// How far from `1` a scale factor may sit and still count as unit, and how far
93/// a matrix may stray from a canonical form and still be classified as it.
94/// Dimensionless; classification is a fast-path hint, and a value that just
95/// misses the threshold is merely applied by the general path.
96const CLASSIFY_EPS: f64 = 1e-12;
97
98impl Default for Transform {
99    fn default() -> Self {
100        Self::IDENTITY
101    }
102}
103
104impl Transform {
105    /// The identity.
106    pub const IDENTITY: Self = Self {
107        linear: Matrix3::IDENTITY,
108        scale: 1.0,
109        translation: Vector::ZERO,
110        kind: TransformKind::Identity,
111    };
112
113    /// Derive the kind from the parts, and build the transform.
114    ///
115    /// The single constructor everything else routes through, so a transform's
116    /// kind can never disagree with what it actually does.
117    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    /// A transform from the parts it is stored as.
128    ///
129    /// `linear` is the orthonormal part alone and `scale` the uniform factor
130    /// beside it, which is how a [`Transform`] holds them. The kind is
131    /// re-derived rather than taken on trust, since it is a function of the
132    /// other three.
133    ///
134    /// For reading a document back. Going the long way round (multiplying the
135    /// scale into the matrix and asking
136    /// [`GeneralTransform::to_similarity`](crate::GeneralTransform::to_similarity)
137    /// to factor it out again) recovers a transform that is *close*, not the
138    /// one that was written, and a round trip that drifts a little each time is
139    /// not a round trip.
140    ///
141    /// # Errors
142    ///
143    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `linear` is
144    /// not orthonormal within `eps`, or `scale` is not finite and non-zero; a
145    /// placement that squashes space is not a placement.
146    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    /// Classify a similarity from its parts.
168    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            // Determinant separates a rotation from a reflection. Both are
189            // orthonormal, and confusing them flips the sense of every face.
190            return if linear.determinant() > 0.0 {
191                TransformKind::Rotation
192            } else {
193                TransformKind::PlaneMirror
194            };
195        }
196        TransformKind::Compound
197    }
198
199    /// A translation.
200    #[must_use]
201    pub fn translation(v: Vector) -> Self {
202        Self::build(Matrix3::IDENTITY, 1.0, v)
203    }
204
205    /// A rotation of `angle` radians about `axis`.
206    #[must_use]
207    pub fn rotation(axis: Axis, angle: f64) -> Self {
208        let linear = Matrix3::rotation(axis.direction, angle);
209        // Rotating about an axis that misses the origin: move the axis point to
210        // the origin, rotate, move back. Written as one translation so the
211        // result stays a single transform.
212        let p = axis.location.to_vector();
213        Self::build(linear, 1.0, p - linear * p)
214    }
215
216    /// A rotation given as a quaternion, about an axis through the origin.
217    #[must_use]
218    pub fn from_quaternion(q: Quaternion) -> Self {
219        Self::build(q.to_matrix(), 1.0, Vector::ZERO)
220    }
221
222    /// A uniform scaling about `centre`.
223    ///
224    /// # Errors
225    ///
226    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `factor` is
227    /// zero or non-finite. A zero scale collapses every shape to a point and is
228    /// not invertible, so it is refused rather than allowed to produce
229    /// degenerate geometry.
230    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    /// Reflection through a point.
239    #[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    /// Reflection in the plane through `origin` with the given `normal`.
246    #[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    /// Reflection in a line: a half turn about it.
254    #[must_use]
255    pub fn axis_mirror(axis: Axis) -> Self {
256        Self::rotation(axis, core::f64::consts::PI)
257    }
258
259    /// The transform taking world coordinates into `frame`'s local coordinates.
260    #[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    /// The transform taking `frame`'s local coordinates into world coordinates.
267    #[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    /// The transform taking `from`'s local coordinates into `to`'s.
273    #[must_use]
274    pub fn between_frames(from: &Frame, to: &Frame) -> Self {
275        Self::to_frame(to) * Self::from_frame(from)
276    }
277
278    /// This transform's classification.
279    #[must_use]
280    pub const fn kind(&self) -> TransformKind {
281        self.kind
282    }
283
284    /// The orthonormal part.
285    #[must_use]
286    pub const fn linear(&self) -> Matrix3 {
287        self.linear
288    }
289
290    /// The uniform scale factor. Negative for a point mirror.
291    #[must_use]
292    pub const fn scale_factor(&self) -> f64 {
293        self.scale
294    }
295
296    /// The translation.
297    #[must_use]
298    pub const fn translation_vector(&self) -> Vector {
299        self.translation
300    }
301
302    /// Whether this transform preserves handedness.
303    ///
304    /// A shape transformed by a transform that does not must have its
305    /// orientation flipped to stay consistent. Otherwise a mirrored solid ends
306    /// up inside out.
307    #[must_use]
308    pub fn preserves_handedness(&self) -> bool {
309        self.linear.determinant() * self.scale.signum() > 0.0
310    }
311
312    /// Apply to a point.
313    #[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    /// Apply to a free vector. Translation does not affect it.
331    #[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    /// Apply to a direction, renormalizing.
342    ///
343    /// A similarity maps unit vectors to vectors of length `|scale|`, so the
344    /// result is rescaled. Under a negative scale the direction reverses, which
345    /// is the correct behaviour and not a sign error.
346    ///
347    /// # Errors
348    ///
349    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the result
350    /// cannot be normalized, which a valid similarity never produces.
351    pub fn apply_direction(&self, d: Direction, tol: Tolerances) -> OgeomResult<Direction> {
352        match self.kind {
353            TransformKind::Identity | TransformKind::Translation => Ok(d),
354            // A negative scale is a point mirror scaled: it reverses.
355            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    /// Apply to a frame, transforming origin and all three axes.
362    ///
363    /// # Errors
364    ///
365    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
366    /// transformed axes cannot be renormalized.
367    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    /// The inverse.
378    ///
379    /// # Errors
380    ///
381    /// [`OgeomError::Numeric`](ogeom_core::OgeomError::Numeric) if the linear part is
382    /// singular, which a valid similarity never is.
383    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        // The linear part is orthonormal, so its inverse is its transpose. No
394        // need to go through a general inversion, and no rounding beyond the
395        // transpose itself.
396        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    /// Whether two transforms agree in effect.
406    #[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    /// This transform as a general affine one.
414    #[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    /// Composition. `(a * b)` applies `b` first, then `a`.
426    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    /// The identity.
449    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    /// A translation.
487    #[must_use]
488    pub fn translation(v: Vector2) -> Self {
489        Self::build(Matrix2::IDENTITY, 1.0, v)
490    }
491
492    /// A rotation about `centre`.
493    #[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    /// A uniform scaling about `centre`.
501    ///
502    /// # Errors
503    ///
504    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `factor` is
505    /// degenerate.
506    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    /// Reflection in the line through `origin` with the given `normal`.
515    #[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    /// This transform's classification.
527    #[must_use]
528    pub const fn kind(&self) -> TransformKind {
529        self.kind
530    }
531
532    /// The orthonormal part.
533    #[must_use]
534    pub const fn linear(&self) -> Matrix2 {
535        self.linear
536    }
537
538    /// The uniform scale factor. Negative for a point mirror.
539    #[must_use]
540    pub const fn scale_factor(&self) -> f64 {
541        self.scale
542    }
543
544    /// The translation.
545    #[must_use]
546    pub const fn translation_vector(&self) -> Vector2 {
547        self.translation
548    }
549
550    /// Whether this transform preserves handedness. In the plane a
551    /// negative scale is a half turn, which does: only the linear part's
552    /// reflection decides.
553    #[must_use]
554    pub fn preserves_handedness(&self) -> bool {
555        self.linear.determinant() > 0.0
556    }
557
558    /// Apply to a point.
559    #[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    /// Apply to a direction, renormalizing.
577    ///
578    /// # Errors
579    ///
580    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the result
581    /// cannot be normalized, which a valid similarity never produces.
582    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    /// Apply to a frame, transforming the origin and both axes.
592    ///
593    /// # Errors
594    ///
595    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
596    /// transformed axes cannot be renormalized or are no longer perpendicular.
597    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    /// Apply to a free vector.
607    #[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    /// The inverse.
618    ///
619    /// # Errors
620    ///
621    /// [`OgeomError::Numeric`](ogeom_core::OgeomError::Numeric) if the transform is
622    /// degenerate.
623    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    /// Whether two transforms agree in effect.
640    #[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    /// The identity.
673    pub const IDENTITY: Self = Self {
674        linear: Matrix3::IDENTITY,
675        translation: Vector::ZERO,
676    };
677
678    /// From a linear part and a translation.
679    #[must_use]
680    pub const fn new(linear: Matrix3, translation: Vector) -> Self {
681        Self {
682            linear,
683            translation,
684        }
685    }
686
687    /// Non-uniform scaling about the origin.
688    #[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    /// Apply to a point.
694    #[must_use]
695    pub fn apply(&self, p: Point) -> Point {
696        Point::from_vector(self.linear * p.to_vector() + self.translation)
697    }
698
699    /// Apply to a free vector.
700    #[must_use]
701    pub fn apply_vector(&self, v: Vector) -> Vector {
702        self.linear * v
703    }
704
705    /// Apply to a normal vector.
706    ///
707    /// Normals transform by the inverse transpose, not by the linear part
708    /// itself. Using the linear part directly is only correct for a similarity;
709    /// under any shear or non-uniform scale it tilts normals off the surface
710    /// they belong to, which then breaks every orientation test downstream.
711    ///
712    /// The result is not renormalized. It is a direction, not a length.
713    ///
714    /// # Errors
715    ///
716    /// [`OgeomError::Numeric`](ogeom_core::OgeomError::Numeric) if the linear part is
717    /// singular.
718    pub fn apply_normal(&self, n: Vector) -> OgeomResult<Vector> {
719        Ok(self.linear.inverse()?.transposed() * n)
720    }
721
722    /// Whether this transform preserves handedness.
723    #[must_use]
724    pub fn preserves_handedness(&self) -> bool {
725        self.linear.determinant() > 0.0
726    }
727
728    /// The factor by which volumes are multiplied. Negative if handedness flips.
729    #[must_use]
730    pub fn volume_ratio(&self) -> f64 {
731        self.linear.determinant()
732    }
733
734    /// The inverse.
735    ///
736    /// # Errors
737    ///
738    /// [`OgeomError::Numeric`](ogeom_core::OgeomError::Numeric) if the linear part is
739    /// singular.
740    pub fn inverse(&self) -> OgeomResult<Self> {
741        let inv = self.linear.inverse()?;
742        Ok(Self::new(inv, -(inv * self.translation)))
743    }
744
745    /// Whether this is a similarity, and so can be narrowed to a [`Transform`].
746    #[must_use]
747    pub fn is_similarity(&self, eps: f64) -> bool {
748        self.to_similarity(eps).is_some()
749    }
750
751    /// This transform as a [`Transform`], if it is in fact a similarity.
752    ///
753    /// Returns `None` when the linear part contains shear or non-uniform
754    /// scaling, since no similarity describes it.
755    #[must_use]
756    pub fn to_similarity(&self, eps: f64) -> Option<Transform> {
757        // A similarity's linear part is `s * R` with `R` orthonormal, so its
758        // columns are mutually orthogonal and all of length |s|.
759        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    /// Composition. `(a * b)` applies `b` first, then `a`.
775    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    /// A negative scale is a point mirror with a scale: a direction comes
798    /// out as the image of a vector along it does, reversed, in space and
799    /// in the plane.
800    #[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        // A positive scale keeps them.
811        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        // The classification is derived from the data, so it cannot claim more
855        // structure than the transform has, or less.
856        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        // Classification exists for speed, so each fast path must agree
873        // exactly with the general formula it replaces.
874        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        // A point off the axis moves, staying at the same radius.
906        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        // Two mirrors make a rotation.
942        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        // And the two orders genuinely differ.
984        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        // A positive scale does not.
1017        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            // And they agree with the frame's own conversion.
1039            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        // A point at local (1,2,3) in `a` must land at the same world position
1050        // when read back out of `b`.
1051        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        // Non-uniform scaling: the plane z = x has normal (1, 0, -1) up to
1058        // scale. Scale x by 2 and the plane becomes z = x/2, whose normal is
1059        // (1, 0, -2) up to scale, not (2, 0, -1), which is what applying the
1060        // linear part directly would give.
1061        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        // The naive answer does not stay perpendicular.
1074        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}