Skip to main content

ranim_core/traits/transform/
group.rs

1//! Transformation group element types.
2//!
3//! These types model the transformation-group hierarchy that ranim supports.
4//! Model transforms stop at the affine group — projective transforms live
5//! solely in the camera projection:
6//!
7//! ```text
8//! Translation ──> Rigid ──> Similarity ──> DAffine3 (Aff(3))
9//!    (T(3))      (SE(3))     (Sim(3))          ▲
10//!                     Diag (axis-aligned scaling) ──┘
11//! ```
12//!
13//! Group containment is encoded as **lossless [`From`] conversions**
14//! (embeddings up the hierarchy); the fallible downward direction is
15//! [`TryFrom`] (e.g. [`DAffine3`] → [`Similarity`] requires the linear part
16//! to be a uniform-scale rotation).
17//!
18//! Shapes declare their *closure group* by implementing
19//! [`ApplyTransform`] with a `G: Into<...>` bound (e.g. `G: Into<DAffine3>`
20//! for affine-closure point data, or `G: Into<Similarity>` for circles and
21//! spheres); the operation traits (`ShiftTransform`, `RotateTransform`,
22//! `ScaleTransform`, `UniformScaleTransform`) are blanket-derived from it.
23
24use core::ops::{Deref, DerefMut};
25
26use glam::{DAffine3, DMat3, DQuat, DVec3};
27
28use crate::traits::Interpolatable;
29
30/// A transformation representation closed under identity and composition.
31///
32/// `compose(outer, inner)` follows the same order as affine matrix
33/// multiplication: the `inner` transform is applied first, then `outer`.
34pub trait TransformGroup: Sized {
35    /// The identity transformation.
36    fn identity() -> Self;
37
38    /// Compose `self` outside `inner`, returning `self * inner`.
39    fn compose(&self, inner: &Self) -> Self;
40}
41
42// MARK: Translation
43/// The translation group T(3): pure displacements.
44#[repr(transparent)]
45#[derive(Debug, Clone, Copy, PartialEq)]
46pub struct Translation(pub DVec3);
47
48impl From<DVec3> for Translation {
49    fn from(v: DVec3) -> Self {
50        Self(v)
51    }
52}
53
54impl From<Translation> for DVec3 {
55    fn from(t: Translation) -> Self {
56        t.0
57    }
58}
59
60impl Deref for Translation {
61    type Target = DVec3;
62    fn deref(&self) -> &Self::Target {
63        &self.0
64    }
65}
66
67impl DerefMut for Translation {
68    fn deref_mut(&mut self) -> &mut Self::Target {
69        &mut self.0
70    }
71}
72
73impl Interpolatable for Translation {
74    fn lerp(&self, target: &Self, t: f64) -> Self {
75        Self(self.0.lerp(target.0, t))
76    }
77}
78
79// MARK: Rigid
80/// The rigid (Euclidean) group SE(3): rotation + translation.
81#[derive(Debug, Clone, Copy, PartialEq)]
82pub struct Rigid {
83    /// The rotation part.
84    pub rotation: DQuat,
85    /// The translation part.
86    pub translation: DVec3,
87}
88
89impl Rigid {
90    /// The identity rigid transform.
91    pub const IDENTITY: Self = Self {
92        rotation: DQuat::IDENTITY,
93        translation: DVec3::ZERO,
94    };
95
96    /// A pure rotation.
97    pub fn from_rotation(rotation: DQuat) -> Self {
98        Self {
99            rotation,
100            translation: DVec3::ZERO,
101        }
102    }
103
104    /// A pure translation.
105    pub fn from_translation(translation: DVec3) -> Self {
106        Self {
107            rotation: DQuat::IDENTITY,
108            translation,
109        }
110    }
111
112    /// A pure rotation of `angle` radians about `axis` (normalized internally).
113    pub fn from_axis_angle(axis: DVec3, angle: f64) -> Self {
114        Self::from_rotation(DQuat::from_axis_angle(axis.normalize(), angle))
115    }
116}
117
118impl Interpolatable for Rigid {
119    fn lerp(&self, target: &Self, t: f64) -> Self {
120        Self {
121            rotation: self.rotation.slerp(target.rotation, t),
122            translation: self.translation.lerp(target.translation, t),
123        }
124    }
125}
126
127// MARK: Similarity
128/// The similarity group Sim(3): uniform scale + rotation + translation.
129///
130/// This is the closure group of shapes defined by angles and length ratios
131/// (circles, spheres, squares, regular polygons, circular arcs): baking any
132/// similarity into them preserves their semantics.
133#[derive(Debug, Clone, Copy, PartialEq)]
134pub struct Similarity {
135    /// The finite, strictly positive uniform scale factor.
136    pub scale: f64,
137    /// The rotation part.
138    pub rotation: DQuat,
139    /// The translation part.
140    pub translation: DVec3,
141}
142
143impl Similarity {
144    /// The identity similarity.
145    pub const IDENTITY: Self = Self {
146        scale: 1.0,
147        rotation: DQuat::IDENTITY,
148        translation: DVec3::ZERO,
149    };
150
151    /// A pure uniform scale.
152    ///
153    /// # Panics
154    ///
155    /// Panics if `scale` is not finite and strictly positive, because zero and
156    /// orientation-reversing scales are not elements of `Sim(3)` as modeled
157    /// here.
158    pub fn from_scale(scale: f64) -> Self {
159        assert!(
160            scale.is_finite() && scale > 0.0,
161            "similarity scale must be finite and strictly positive"
162        );
163        Self {
164            scale,
165            rotation: DQuat::IDENTITY,
166            translation: DVec3::ZERO,
167        }
168    }
169
170    /// Transform a point: `s * (R * p) + t`.
171    pub fn transform_point(&self, p: DVec3) -> DVec3 {
172        self.scale * (self.rotation * p) + self.translation
173    }
174
175    /// Transform a direction vector (rotation only, preserving unit length).
176    pub fn transform_direction(&self, v: DVec3) -> DVec3 {
177        self.rotation * v
178    }
179}
180
181impl Interpolatable for Similarity {
182    fn lerp(&self, target: &Self, t: f64) -> Self {
183        Self {
184            scale: self.scale.lerp(&target.scale, t),
185            rotation: self.rotation.slerp(target.rotation, t),
186            translation: self.translation.lerp(target.translation, t),
187        }
188    }
189}
190
191// MARK: Diag
192/// The diagonal group (R\*)³: axis-aligned non-uniform scaling.
193///
194/// Note that `Diag` does **not** compose with rotations in a closed way
195/// (`R₁ · Diag · R₂` generally introduces shear), which is why it is not a
196/// subgroup of [`Similarity`] — only of the affine group.
197#[repr(transparent)]
198#[derive(Debug, Clone, Copy, PartialEq)]
199pub struct Diag(pub DVec3);
200
201impl From<DVec3> for Diag {
202    fn from(v: DVec3) -> Self {
203        Self(v)
204    }
205}
206
207impl From<Diag> for DVec3 {
208    fn from(d: Diag) -> Self {
209        d.0
210    }
211}
212
213impl Deref for Diag {
214    type Target = DVec3;
215    fn deref(&self) -> &Self::Target {
216        &self.0
217    }
218}
219
220impl DerefMut for Diag {
221    fn deref_mut(&mut self) -> &mut Self::Target {
222        &mut self.0
223    }
224}
225
226impl Interpolatable for Diag {
227    fn lerp(&self, target: &Self, t: f64) -> Self {
228        Self(self.0.lerp(target.0, t))
229    }
230}
231
232// MARK: Composition
233impl TransformGroup for Translation {
234    fn identity() -> Self {
235        Self(DVec3::ZERO)
236    }
237
238    fn compose(&self, inner: &Self) -> Self {
239        Self(self.0 + inner.0)
240    }
241}
242
243impl TransformGroup for Rigid {
244    fn identity() -> Self {
245        Self::IDENTITY
246    }
247
248    fn compose(&self, inner: &Self) -> Self {
249        Self {
250            rotation: self.rotation * inner.rotation,
251            translation: self.rotation * inner.translation + self.translation,
252        }
253    }
254}
255
256impl TransformGroup for Similarity {
257    fn identity() -> Self {
258        Self::IDENTITY
259    }
260
261    fn compose(&self, inner: &Self) -> Self {
262        Self {
263            scale: self.scale * inner.scale,
264            rotation: self.rotation * inner.rotation,
265            translation: self.scale * (self.rotation * inner.translation) + self.translation,
266        }
267    }
268}
269
270impl TransformGroup for Diag {
271    fn identity() -> Self {
272        Self(DVec3::ONE)
273    }
274
275    fn compose(&self, inner: &Self) -> Self {
276        Self(self.0 * inner.0)
277    }
278}
279
280impl TransformGroup for DAffine3 {
281    fn identity() -> Self {
282        Self::IDENTITY
283    }
284
285    fn compose(&self, inner: &Self) -> Self {
286        *self * *inner
287    }
288}
289
290// MARK: Embeddings
291// Lossless conversions up the group hierarchy (group containment).
292// Note: `From` is not transitive in Rust, so every edge is written out.
293
294impl From<Translation> for Rigid {
295    fn from(t: Translation) -> Self {
296        Rigid::from_translation(t.0)
297    }
298}
299
300impl From<Translation> for Similarity {
301    fn from(t: Translation) -> Self {
302        Similarity {
303            scale: 1.0,
304            rotation: DQuat::IDENTITY,
305            translation: t.0,
306        }
307    }
308}
309
310impl From<Translation> for DAffine3 {
311    fn from(t: Translation) -> Self {
312        DAffine3::from_translation(t.0)
313    }
314}
315
316impl From<Rigid> for Similarity {
317    fn from(r: Rigid) -> Self {
318        Similarity {
319            scale: 1.0,
320            rotation: r.rotation,
321            translation: r.translation,
322        }
323    }
324}
325
326impl From<Rigid> for DAffine3 {
327    fn from(r: Rigid) -> Self {
328        DAffine3::from_rotation_translation(r.rotation, r.translation)
329    }
330}
331
332impl From<Similarity> for DAffine3 {
333    fn from(s: Similarity) -> Self {
334        DAffine3::from_scale_rotation_translation(DVec3::splat(s.scale), s.rotation, s.translation)
335    }
336}
337
338impl From<Diag> for DAffine3 {
339    fn from(d: Diag) -> Self {
340        DAffine3::from_scale(d.0)
341    }
342}
343
344// MARK: NotSimilarity
345/// Error returned when an affine transform is not a similarity transform
346/// (its linear part has non-uniform scale, shear, or reflection).
347#[derive(Debug, Clone, Copy, PartialEq, Eq)]
348pub struct NotSimilarity;
349
350impl core::fmt::Display for NotSimilarity {
351    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
352        f.write_str("the affine transform is not a similarity transform")
353    }
354}
355
356impl std::error::Error for NotSimilarity {}
357
358impl TryFrom<DAffine3> for Similarity {
359    type Error = NotSimilarity;
360
361    /// Extract a similarity from an affine transform, checking that the
362    /// linear part is a uniform-scale rotation (three equal singular
363    /// values, no shear, no reflection).
364    fn try_from(affine: DAffine3) -> Result<Self, Self::Error> {
365        const EPS: f64 = 1e-9;
366
367        let m = affine.matrix3;
368        if !m.is_finite() || !affine.translation.is_finite() {
369            return Err(NotSimilarity);
370        }
371        let cols = [m.x_axis, m.y_axis, m.z_axis];
372        let s = cols[0].length();
373        if s <= EPS {
374            return Err(NotSimilarity);
375        }
376        // Equal column lengths (uniform scale) ...
377        if (cols[1].length() - s).abs() > EPS * s || (cols[2].length() - s).abs() > EPS * s {
378            return Err(NotSimilarity);
379        }
380        // ... mutual orthogonality (no shear) ...
381        if cols[0].dot(cols[1]).abs() > EPS * s * s
382            || cols[0].dot(cols[2]).abs() > EPS * s * s
383            || cols[1].dot(cols[2]).abs() > EPS * s * s
384        {
385            return Err(NotSimilarity);
386        }
387        // ... and positive orientation (no reflection).
388        if m.determinant() <= 0.0 {
389            return Err(NotSimilarity);
390        }
391        let rotation = DQuat::from_mat3(&DMat3::from_cols(cols[0] / s, cols[1] / s, cols[2] / s));
392        Ok(Self {
393            scale: s,
394            rotation,
395            translation: affine.translation,
396        })
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403    use glam::dvec3;
404
405    #[test]
406    fn test_similarity_try_from_accepts_similarity() {
407        let sim = Similarity {
408            scale: 2.5,
409            rotation: DQuat::from_axis_angle(DVec3::Z, 0.7),
410            translation: dvec3(1.0, 2.0, 3.0),
411        };
412        let affine = DAffine3::from(sim);
413        let back = Similarity::try_from(affine).unwrap();
414        assert!((back.scale - 2.5).abs() < 1e-9);
415        assert!(back.translation.abs_diff_eq(sim.translation, 1e-9));
416        // Rotation: q and -q represent the same rotation
417        let rotated = back.rotation * DVec3::X;
418        let expected = sim.rotation * DVec3::X;
419        assert!(rotated.abs_diff_eq(expected, 1e-9));
420    }
421
422    #[test]
423    fn test_similarity_try_from_rejects_invalid_affines() {
424        let cases = [
425            (
426                "non-uniform scale",
427                DAffine3::from_scale(dvec3(1.0, 2.0, 1.0)),
428            ),
429            ("reflection", DAffine3::from_scale(dvec3(-1.0, 1.0, 1.0))),
430            (
431                "non-finite values",
432                DAffine3::from_scale(DVec3::splat(f64::NAN)),
433            ),
434        ];
435        for (name, affine) in cases {
436            assert!(
437                Similarity::try_from(affine).is_err(),
438                "{name} must be rejected"
439            );
440        }
441    }
442
443    #[test]
444    #[should_panic(expected = "similarity scale must be finite and strictly positive")]
445    fn test_similarity_from_scale_rejects_non_positive_values() {
446        Similarity::from_scale(0.0);
447    }
448
449    fn assert_identity<G>(value: G)
450    where
451        G: TransformGroup + PartialEq + core::fmt::Debug,
452    {
453        assert_eq!(G::identity().compose(&value), value);
454        assert_eq!(value.compose(&G::identity()), value);
455    }
456
457    #[test]
458    fn composition_identity_holds_for_every_storage_family() {
459        assert_identity(Translation(dvec3(1.0, 2.0, 3.0)));
460        assert_identity(Rigid {
461            rotation: DQuat::from_rotation_z(0.4),
462            translation: DVec3::X,
463        });
464        assert_identity(Similarity {
465            scale: 2.0,
466            rotation: DQuat::from_rotation_y(0.3),
467            translation: DVec3::Y,
468        });
469        assert_identity(Diag(dvec3(2.0, 0.0, -1.0)));
470        assert_identity(DAffine3::from_scale_rotation_translation(
471            dvec3(2.0, 1.0, 3.0),
472            DQuat::from_rotation_x(0.2),
473            DVec3::Z,
474        ));
475    }
476
477    #[test]
478    fn composition_matches_affine_semantics() {
479        let outer = Rigid::from_axis_angle(DVec3::Z, core::f64::consts::FRAC_PI_2);
480        let inner = Rigid::from_translation(DVec3::X);
481        let composed = outer.compose(&inner);
482        assert!(composed.translation.abs_diff_eq(dvec3(0.0, 1.0, 0.0), 1e-9));
483        assert_eq!(composed.rotation, outer.rotation);
484
485        let outer = Similarity::from_scale(2.0);
486        let inner = Similarity::from(Translation(DVec3::X));
487        let composed = outer.compose(&inner);
488        assert_eq!(composed.scale, 2.0);
489        assert_eq!(composed.translation, dvec3(2.0, 0.0, 0.0));
490    }
491}