Skip to main content

motion_core/geometry/
transform.rs

1//! Double-precision affine authoring transforms, with reading-order composition.
2
3use super::{Point, Vector2};
4
5/// A two-dimensional affine transform in double-precision design coordinates.
6///
7/// Coefficients are column-major `[xx, yx, xy, yy, translation_x, translation_y]`.
8/// Positive rotation is clockwise in the downward-positive coordinate system.
9/// [`Self::then`] applies transforms in reading order, not matrix-product order.
10/// Device-space geometry retains these f64 coefficients until explicit storage boundaries.
11#[derive(Clone, Copy, Debug, PartialEq)]
12pub struct Transform {
13    coefficients: [f64; 6],
14}
15
16impl Default for Transform {
17    fn default() -> Self {
18        Self::IDENTITY
19    }
20}
21
22impl Transform {
23    /// Leaves positions and vectors unchanged
24    pub const IDENTITY: Self = Self::from_coefficients([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
25
26    /// Preserves raw affine coefficients; consuming geometry validates them.
27    pub const fn from_coefficients(coefficients: [f64; 6]) -> Self {
28        Self { coefficients }
29    }
30
31    /// Returns column-major affine coefficients without reducing precision.
32    pub const fn coefficients(self) -> [f64; 6] {
33        self.coefficients
34    }
35
36    /// Translates positions without changing vectors.
37    pub const fn translate(horizontal: f64, vertical: f64) -> Self {
38        Self::from_coefficients([1.0, 0.0, 0.0, 1.0, horizontal, vertical])
39    }
40
41    /// Rotates clockwise around the coordinate origin, in radians.
42    pub fn rotate(radians: f64) -> Self {
43        let direction = Vector2::from_angle(radians);
44        Self::from_coefficients([
45            direction.x,
46            direction.y,
47            -direction.y,
48            direction.x,
49            0.0,
50            0.0,
51        ])
52    }
53
54    /// Uniformly scales around the coordinate origin; negative values reflect.
55    pub const fn scale(factor: f64) -> Self {
56        Self::scale_non_uniform(factor, factor)
57    }
58
59    /// Scales each axis independently around the coordinate origin.
60    pub const fn scale_non_uniform(horizontal: f64, vertical: f64) -> Self {
61        Self::from_coefficients([horizontal, 0.0, 0.0, vertical, 0.0, 0.0])
62    }
63
64    /// Skews each axis by the tangent of its angle in radians.
65    /// Both shears act in one matrix, rather than as sequential operations.
66    pub fn skew(horizontal: f64, vertical: f64) -> Self {
67        Self::from_coefficients([
68            1.0,
69            libm::tan(vertical),
70            libm::tan(horizontal),
71            1.0,
72            0.0,
73            0.0,
74        ])
75    }
76
77    /// Applies this transform first, followed by `next`.
78    ///
79    /// ```
80    /// use motion_core::{Point, Transform};
81    /// let transform = Transform::translate(10.0, 0.0).then(Transform::scale(2.0));
82    /// assert_eq!(transform.apply(Point::new(1.0, 3.0)), Point::new(22.0, 6.0));
83    /// ```
84    pub fn then(self, next: Self) -> Self {
85        let first = self.coefficients;
86        let second = next.coefficients;
87        let translation = next.apply(Point::new(first[4], first[5]));
88        Self::from_coefficients([
89            second[0] * first[0] + second[2] * first[1],
90            second[1] * first[0] + second[3] * first[1],
91            second[0] * first[2] + second[2] * first[3],
92            second[1] * first[2] + second[3] * first[3],
93            translation.x,
94            translation.y,
95        ])
96    }
97
98    /// Maps a position, including translation.
99    pub fn apply(self, point: Point) -> Point {
100        let matrix = self.coefficients;
101        Point::new(
102            matrix[0] * point.x + matrix[2] * point.y + matrix[4],
103            matrix[1] * point.x + matrix[3] * point.y + matrix[5],
104        )
105    }
106
107    /// Maps a displacement, ignoring translation.
108    pub fn apply_vector(self, vector: Vector2) -> Vector2 {
109        let matrix = self.coefficients;
110        Vector2::new(
111            matrix[0] * vector.x + matrix[2] * vector.y,
112            matrix[1] * vector.x + matrix[3] * vector.y,
113        )
114    }
115
116    /// Returns whether all affine coefficients are finite.
117    pub fn is_finite(self) -> bool {
118        self.coefficients.iter().all(|value| value.is_finite())
119    }
120
121    /// Inverts a finite nonsingular transform when its inverse remains finite.
122    /// Returns `None` for singular/nonfinite inputs or unrepresentable results.
123    /// Rows are scaled before computing the determinant to avoid ordinary
124    /// underflow/overflow for extremely small, large or unequal axis scales.
125    pub fn inverse(self) -> Option<Self> {
126        if !self.is_finite() {
127            return None;
128        }
129        let matrix = self.coefficients;
130        let horizontal_scale = matrix[0].abs().max(matrix[2].abs());
131        let vertical_scale = matrix[1].abs().max(matrix[3].abs());
132        if horizontal_scale == 0.0 || vertical_scale == 0.0 {
133            return None;
134        }
135        let normalized = [
136            matrix[0] / horizontal_scale,
137            matrix[1] / vertical_scale,
138            matrix[2] / horizontal_scale,
139            matrix[3] / vertical_scale,
140        ];
141        let determinant = normalized[0] * normalized[3] - normalized[1] * normalized[2];
142        if determinant == 0.0 {
143            return None;
144        }
145        let divide = |coefficient: f64, scale: f64| {
146            let quotient = coefficient / determinant;
147            if quotient.is_finite() {
148                quotient / scale
149            } else {
150                (coefficient / scale) / determinant
151            }
152        };
153        let linear = [
154            divide(normalized[3], horizontal_scale),
155            divide(-normalized[1], horizontal_scale),
156            divide(-normalized[2], vertical_scale),
157            divide(normalized[0], vertical_scale),
158        ];
159        let inverse = Self::from_coefficients([
160            linear[0],
161            linear[1],
162            linear[2],
163            linear[3],
164            -linear[0] * matrix[4] - linear[2] * matrix[5],
165            -linear[1] * matrix[4] - linear[3] * matrix[5],
166        ]);
167        inverse.is_finite().then_some(inverse)
168    }
169
170    /// Decomposes into scale, horizontal shear, rotation, then translation.
171    ///
172    /// The horizontal scale is positive; reflection is carried by vertical scale.
173    /// Returns `None` for singular/nonfinite inputs or nonfinite components.
174    /// Shear is stored as a coefficient, not an angle, to avoid losing precision
175    /// when a large tangent is rounded to a right angle.
176    pub fn decompose(self) -> Option<TransformComponents> {
177        if !self.is_finite() {
178            return None;
179        }
180        let matrix = self.coefficients;
181        let horizontal_scale = libm::hypot(matrix[0], matrix[1]);
182        if horizontal_scale == 0.0 || !horizontal_scale.is_finite() {
183            return None;
184        }
185        let cosine = matrix[0] / horizontal_scale;
186        let sine = matrix[1] / horizontal_scale;
187        let projection = cosine * matrix[2] + sine * matrix[3];
188        let vertical_scale = cosine * matrix[3] - sine * matrix[2];
189        if vertical_scale == 0.0 || !vertical_scale.is_finite() {
190            return None;
191        }
192        let shear = projection / vertical_scale;
193        if !shear.is_finite() {
194            return None;
195        }
196        Some(TransformComponents {
197            translation: Vector2::new(matrix[4], matrix[5]),
198            rotation: libm::atan2(matrix[1], matrix[0]),
199            scale: Vector2::new(horizontal_scale, vertical_scale),
200            shear,
201        })
202    }
203
204    /// Interpolates decomposed components and takes the shortest rotation arc.
205    /// A half-turn tie rotates clockwise; progress may extrapolate. Exact zero/one
206    /// return the original matrices. If either endpoint cannot be decomposed,
207    /// coefficients interpolate directly, allowing singular endpoints such as
208    /// zero-scale entrances. Reflection changes can pass through a singular scale.
209    pub fn interpolate(&self, target: &Self, progress: f64) -> Self {
210        if progress == 0.0 {
211            return *self;
212        }
213        if progress == 1.0 {
214            return *target;
215        }
216        let (Some(first), Some(second)) = (self.decompose(), target.decompose()) else {
217            return Self::from_coefficients(std::array::from_fn(|index| {
218                self.coefficients[index] * (1.0 - progress) + target.coefficients[index] * progress
219            }));
220        };
221        let mut rotation = (second.rotation - first.rotation).rem_euclid(std::f64::consts::TAU);
222        if rotation > std::f64::consts::PI {
223            rotation -= std::f64::consts::TAU;
224        }
225        TransformComponents {
226            translation: first.translation.interpolate(&second.translation, progress),
227            rotation: first.rotation + rotation * progress,
228            scale: first.scale.interpolate(&second.scale, progress),
229            shear: first.shear * (1.0 - progress) + second.shear * progress,
230        }
231        .compose()
232    }
233}
234
235/// Canonical affine factors in scale, shear, rotation, translation application order.
236#[derive(Clone, Copy, Debug, PartialEq)]
237pub struct TransformComponents {
238    /// Position-independent displacement applied last.
239    pub translation: Vector2,
240    /// Clockwise radians around the origin.
241    pub rotation: f64,
242    /// Per-axis scale; decomposition places any reflection in the vertical factor.
243    pub scale: Vector2,
244    /// Horizontal shear coefficient: adds `shear * vertical` to the horizontal axis.
245    /// Unlike [`Transform::skew`], this field is not an angle.
246    pub shear: f64,
247}
248
249impl TransformComponents {
250    /// Reconstructs an affine matrix without converting shear into an angle.
251    pub fn compose(self) -> Transform {
252        let direction = Vector2::from_angle(self.rotation);
253        Transform::from_coefficients([
254            direction.x * self.scale.x,
255            direction.y * self.scale.x,
256            (direction.x * self.shear - direction.y) * self.scale.y,
257            (direction.y * self.shear + direction.x) * self.scale.y,
258            self.translation.x,
259            self.translation.y,
260        ])
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use crate::AngleLiteral;
268
269    fn close(actual: f64, expected: f64) {
270        let tolerance = 1e-12 * actual.abs().max(expected.abs()).max(1.0);
271        assert!(
272            (actual - expected).abs() <= tolerance,
273            "{actual} != {expected}"
274        );
275    }
276
277    fn point_close(actual: Point, expected: Point) {
278        close(actual.x, expected.x);
279        close(actual.y, expected.y);
280    }
281
282    #[test]
283    fn reading_order_matches_sequential_application_and_preserves_vector_semantics() {
284        let first = Transform::translate(10.0, -4.0);
285        let second = Transform::rotate(90.degrees());
286        let point = Point::new(2.0, 3.0);
287        point_close(
288            first.then(second).apply(point),
289            second.apply(first.apply(point)),
290        );
291        point_close(first.then(second).apply(point), Point::new(1.0, 12.0));
292        point_close(second.then(first).apply(point), Point::new(7.0, -2.0));
293        assert_eq!(
294            first.apply_vector(Vector2::new(2.0, 3.0)),
295            Vector2::new(2.0, 3.0)
296        );
297        assert_eq!(Transform::default(), Transform::IDENTITY);
298        let offset = Transform::translate(16_777_217.125, 0.0);
299        assert_eq!(offset.apply(Point::ORIGIN).x, 16_777_217.125);
300    }
301
302    #[test]
303    fn inverse_handles_reflection_shear_and_extreme_axis_scales() {
304        let transforms = [
305            Transform::IDENTITY,
306            Transform::scale_non_uniform(-2.0, 3.0)
307                .then(Transform::skew(15.degrees(), -10.degrees()))
308                .then(Transform::rotate(37.degrees()))
309                .then(Transform::translate(31.0, -17.0)),
310            Transform::scale_non_uniform(1e-200, 1e200),
311            Transform::scale_non_uniform(1e200, 1e200),
312            Transform::scale_non_uniform(1e-200, 1e-200),
313        ];
314        for transform in transforms {
315            let inverse = transform.inverse().unwrap();
316            for point in [
317                Point::ORIGIN,
318                Point::new(-2.5, 3.75),
319                Point::new(17.0, -9.0),
320            ] {
321                point_close(inverse.apply(transform.apply(point)), point);
322                point_close(transform.apply(inverse.apply(point)), point);
323            }
324        }
325        assert!(Transform::scale_non_uniform(0.0, 1.0).inverse().is_none());
326        assert!(Transform::scale(f64::from_bits(1)).inverse().is_none());
327        assert!(Transform::translate(f64::NAN, 1.0).inverse().is_none());
328        assert!(Transform::scale(f64::INFINITY).inverse().is_none());
329    }
330
331    #[test]
332    fn decomposition_recomposes_and_carries_reflection_in_vertical_scale() {
333        for scale in [
334            Vector2::new(2.0, 3.0),
335            Vector2::new(-2.0, 3.0),
336            Vector2::new(-2.0, -3.0),
337        ] {
338            for shear in [-2.5, 0.0, 1.25, 1e20] {
339                let transform = TransformComponents {
340                    translation: Vector2::new(21.5, -12.25),
341                    rotation: 0.0,
342                    scale,
343                    shear,
344                }
345                .compose();
346                let parts = transform.decompose().unwrap();
347                assert!(parts.scale.x > 0.0);
348                assert_eq!(
349                    parts.scale.y.is_sign_negative(),
350                    (scale.x * scale.y).is_sign_negative()
351                );
352                for (actual, expected) in parts
353                    .compose()
354                    .coefficients()
355                    .into_iter()
356                    .zip(transform.coefficients())
357                {
358                    close(actual, expected);
359                }
360            }
361        }
362        let rotated = Transform::scale_non_uniform(-2.0, 3.0)
363            .then(Transform::skew(0.2, -0.15))
364            .then(Transform::rotate(-127.degrees()));
365        let recomposed = rotated.decompose().unwrap().compose();
366        point_close(
367            recomposed.apply(Point::new(13.0, -8.0)),
368            rotated.apply(Point::new(13.0, -8.0)),
369        );
370        assert!(Transform::scale(0.0).decompose().is_none());
371        assert!(Transform::rotate(f64::NAN).decompose().is_none());
372    }
373
374    #[test]
375    fn transform_interpolation_uses_short_rotation_arcs_and_preserves_endpoints() {
376        let first = Transform::rotate(170.degrees());
377        let second = Transform::rotate(-170.degrees());
378        let midpoint = first.interpolate(&second, 0.5);
379        point_close(midpoint.apply(Point::new(1.0, 0.0)), Point::new(-1.0, 0.0));
380        assert_eq!(first.interpolate(&second, 0.0), first);
381        assert_eq!(first.interpolate(&second, 1.0), second);
382        let half_turn = Transform::IDENTITY.interpolate(&Transform::rotate(180.degrees()), 0.5);
383        point_close(half_turn.apply(Point::new(1.0, 0.0)), Point::new(0.0, 1.0));
384        let singular = Transform::scale(0.0).interpolate(&Transform::scale(2.0), 0.5);
385        point_close(singular.apply(Point::new(2.0, 3.0)), Point::new(2.0, 3.0));
386        let extended = Transform::IDENTITY.interpolate(&Transform::translate(10.0, -20.0), 1.5);
387        assert_eq!(extended.apply(Point::ORIGIN), Point::new(15.0, -30.0));
388    }
389}