Skip to main content

u_geometry/
transform.rs

1//! Rigid transformations for 2D and 3D.
2//!
3//! Built on nalgebra's `Isometry2`/`Isometry3` for numerical correctness.
4//! Provides a simpler API for common transform operations.
5
6use crate::primitives::{Point2, Point3};
7use nalgebra::{
8    Isometry2, Isometry3, Point2 as NaPoint2, Point3 as NaPoint3, UnitQuaternion,
9    Vector2 as NaVector2, Vector3 as NaVector3,
10};
11
12/// A 2D rigid transformation (rotation + translation).
13///
14/// Internally uses nalgebra `Isometry2` for correct composition
15/// and inversion. The representation stores translation (tx, ty)
16/// and rotation angle in radians.
17///
18/// # Example
19///
20/// ```
21/// use u_geometry::transform::Transform2D;
22/// use std::f64::consts::PI;
23///
24/// let t = Transform2D::new(10.0, 20.0, PI / 2.0);
25/// let (x, y) = t.apply(1.0, 0.0);
26/// assert!((x - 10.0).abs() < 1e-10);
27/// assert!((y - 21.0).abs() < 1e-10);
28/// ```
29#[derive(Debug, Clone, Copy, PartialEq)]
30#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
31pub struct Transform2D {
32    /// Translation x.
33    pub tx: f64,
34    /// Translation y.
35    pub ty: f64,
36    /// Rotation angle in radians.
37    pub angle: f64,
38}
39
40impl Transform2D {
41    /// Identity transform (no rotation, no translation).
42    pub fn identity() -> Self {
43        Self {
44            tx: 0.0,
45            ty: 0.0,
46            angle: 0.0,
47        }
48    }
49
50    /// Creates a translation-only transform.
51    pub fn translation(tx: f64, ty: f64) -> Self {
52        Self { tx, ty, angle: 0.0 }
53    }
54
55    /// Creates a rotation-only transform (about the origin).
56    pub fn rotation(angle: f64) -> Self {
57        Self {
58            tx: 0.0,
59            ty: 0.0,
60            angle,
61        }
62    }
63
64    /// Creates a transform with translation and rotation.
65    pub fn new(tx: f64, ty: f64, angle: f64) -> Self {
66        Self { tx, ty, angle }
67    }
68
69    /// Converts to a nalgebra `Isometry2`.
70    #[inline]
71    pub fn to_isometry(&self) -> Isometry2<f64> {
72        Isometry2::new(NaVector2::new(self.tx, self.ty), self.angle)
73    }
74
75    /// Creates from a nalgebra `Isometry2`.
76    pub fn from_isometry(iso: &Isometry2<f64>) -> Self {
77        Self {
78            tx: iso.translation.x,
79            ty: iso.translation.y,
80            angle: iso.rotation.angle(),
81        }
82    }
83
84    /// Applies this transform to a point.
85    #[inline]
86    pub fn apply(&self, x: f64, y: f64) -> (f64, f64) {
87        let iso = self.to_isometry();
88        let p = iso.transform_point(&NaPoint2::new(x, y));
89        (p.x, p.y)
90    }
91
92    /// Applies this transform to a `Point2`.
93    #[inline]
94    pub fn apply_point(&self, p: &Point2) -> Point2 {
95        let (x, y) = self.apply(p.x, p.y);
96        Point2::new(x, y)
97    }
98
99    /// Transforms a slice of points.
100    pub fn apply_points(&self, points: &[(f64, f64)]) -> Vec<(f64, f64)> {
101        let iso = self.to_isometry();
102        points
103            .iter()
104            .map(|(x, y)| {
105                let p = iso.transform_point(&NaPoint2::new(*x, *y));
106                (p.x, p.y)
107            })
108            .collect()
109    }
110
111    /// Composes two transforms: applies `self` first, then `other`.
112    pub fn then(&self, other: &Self) -> Self {
113        let iso1 = self.to_isometry();
114        let iso2 = other.to_isometry();
115        Self::from_isometry(&(iso1 * iso2))
116    }
117
118    /// Returns the inverse transform.
119    pub fn inverse(&self) -> Self {
120        Self::from_isometry(&self.to_isometry().inverse())
121    }
122
123    /// Whether this is approximately an identity transform.
124    pub fn is_identity(&self, epsilon: f64) -> bool {
125        self.tx.abs() < epsilon && self.ty.abs() < epsilon && self.angle.abs() < epsilon
126    }
127}
128
129impl Default for Transform2D {
130    fn default() -> Self {
131        Self::identity()
132    }
133}
134
135/// A 3D rigid transformation (rotation + translation).
136///
137/// Internally uses nalgebra `Isometry3` with quaternion rotation
138/// for gimbal-lock-free composition and inversion.
139/// The representation stores translation (tx, ty, tz) and
140/// Euler angles (roll, pitch, yaw) in radians for human readability.
141///
142/// # Euler Angle Convention
143/// - Roll (rx): rotation about X axis
144/// - Pitch (ry): rotation about Y axis
145/// - Yaw (rz): rotation about Z axis
146///
147/// Composition order: Rz * Ry * Rx (extrinsic rotations)
148///
149/// # Reference
150/// Diebel (2006), "Representing Attitude: Euler Angles, Unit Quaternions, and Rotation Vectors"
151#[derive(Debug, Clone, Copy, PartialEq)]
152#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
153pub struct Transform3D {
154    /// Translation x.
155    pub tx: f64,
156    /// Translation y.
157    pub ty: f64,
158    /// Translation z.
159    pub tz: f64,
160    /// Roll (rotation about X axis) in radians.
161    pub rx: f64,
162    /// Pitch (rotation about Y axis) in radians.
163    pub ry: f64,
164    /// Yaw (rotation about Z axis) in radians.
165    pub rz: f64,
166}
167
168impl Transform3D {
169    /// Identity transform.
170    pub fn identity() -> Self {
171        Self {
172            tx: 0.0,
173            ty: 0.0,
174            tz: 0.0,
175            rx: 0.0,
176            ry: 0.0,
177            rz: 0.0,
178        }
179    }
180
181    /// Creates a translation-only transform.
182    pub fn translation(tx: f64, ty: f64, tz: f64) -> Self {
183        Self {
184            tx,
185            ty,
186            tz,
187            rx: 0.0,
188            ry: 0.0,
189            rz: 0.0,
190        }
191    }
192
193    /// Creates a transform with translation and Euler angles.
194    pub fn new(tx: f64, ty: f64, tz: f64, rx: f64, ry: f64, rz: f64) -> Self {
195        Self {
196            tx,
197            ty,
198            tz,
199            rx,
200            ry,
201            rz,
202        }
203    }
204
205    /// Converts to a nalgebra `Isometry3`.
206    ///
207    /// Uses the Euler angle convention: Rz * Ry * Rx.
208    #[inline]
209    pub fn to_isometry(&self) -> Isometry3<f64> {
210        let rotation = UnitQuaternion::from_euler_angles(self.rx, self.ry, self.rz);
211        Isometry3::from_parts(NaVector3::new(self.tx, self.ty, self.tz).into(), rotation)
212    }
213
214    /// Creates from a nalgebra `Isometry3`.
215    pub fn from_isometry(iso: &Isometry3<f64>) -> Self {
216        let (rx, ry, rz) = iso.rotation.euler_angles();
217        Self {
218            tx: iso.translation.x,
219            ty: iso.translation.y,
220            tz: iso.translation.z,
221            rx,
222            ry,
223            rz,
224        }
225    }
226
227    /// Applies this transform to coordinates.
228    #[inline]
229    pub fn apply(&self, x: f64, y: f64, z: f64) -> (f64, f64, f64) {
230        let iso = self.to_isometry();
231        let p = iso.transform_point(&NaPoint3::new(x, y, z));
232        (p.x, p.y, p.z)
233    }
234
235    /// Applies this transform to a `Point3`.
236    #[inline]
237    pub fn apply_point(&self, p: &Point3) -> Point3 {
238        let (x, y, z) = self.apply(p.x, p.y, p.z);
239        Point3::new(x, y, z)
240    }
241
242    /// Transforms a slice of points.
243    pub fn apply_points(&self, points: &[Point3]) -> Vec<Point3> {
244        let iso = self.to_isometry();
245        points
246            .iter()
247            .map(|p| {
248                let tp = iso.transform_point(&NaPoint3::new(p.x, p.y, p.z));
249                Point3::new(tp.x, tp.y, tp.z)
250            })
251            .collect()
252    }
253
254    /// Composes two transforms: applies `self` first, then `other`.
255    pub fn then(&self, other: &Self) -> Self {
256        let iso1 = self.to_isometry();
257        let iso2 = other.to_isometry();
258        Self::from_isometry(&(iso1 * iso2))
259    }
260
261    /// Returns the inverse transform.
262    pub fn inverse(&self) -> Self {
263        Self::from_isometry(&self.to_isometry().inverse())
264    }
265
266    /// Whether this is approximately an identity transform.
267    pub fn is_identity(&self, epsilon: f64) -> bool {
268        self.tx.abs() < epsilon
269            && self.ty.abs() < epsilon
270            && self.tz.abs() < epsilon
271            && self.rx.abs() < epsilon
272            && self.ry.abs() < epsilon
273            && self.rz.abs() < epsilon
274    }
275}
276
277impl Default for Transform3D {
278    fn default() -> Self {
279        Self::identity()
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use std::f64::consts::PI;
287
288    #[test]
289    fn test_identity() {
290        let t = Transform2D::identity();
291        let (x, y) = t.apply(1.0, 2.0);
292        assert!((x - 1.0).abs() < 1e-10);
293        assert!((y - 2.0).abs() < 1e-10);
294    }
295
296    #[test]
297    fn test_translation() {
298        let t = Transform2D::translation(10.0, 20.0);
299        let (x, y) = t.apply(1.0, 2.0);
300        assert!((x - 11.0).abs() < 1e-10);
301        assert!((y - 22.0).abs() < 1e-10);
302    }
303
304    #[test]
305    fn test_rotation_90() {
306        let t = Transform2D::rotation(PI / 2.0);
307        let (x, y) = t.apply(1.0, 0.0);
308        assert!((x - 0.0).abs() < 1e-10);
309        assert!((y - 1.0).abs() < 1e-10);
310    }
311
312    #[test]
313    fn test_rotation_180() {
314        let t = Transform2D::rotation(PI);
315        let (x, y) = t.apply(1.0, 0.0);
316        assert!((x - (-1.0)).abs() < 1e-10);
317        assert!((y - 0.0).abs() < 1e-10);
318    }
319
320    #[test]
321    fn test_compose() {
322        let t1 = Transform2D::translation(10.0, 0.0);
323        let t2 = Transform2D::translation(0.0, 20.0);
324        let composed = t1.then(&t2);
325        let (x, y) = composed.apply(0.0, 0.0);
326        assert!((x - 10.0).abs() < 1e-10);
327        assert!((y - 20.0).abs() < 1e-10);
328    }
329
330    #[test]
331    fn test_inverse() {
332        let t = Transform2D::new(10.0, 20.0, PI / 4.0);
333        let inv = t.inverse();
334        let composed = t.then(&inv);
335        assert!(composed.is_identity(1e-10));
336    }
337
338    #[test]
339    fn test_apply_point() {
340        let t = Transform2D::translation(5.0, 3.0);
341        let p = Point2::new(1.0, 2.0);
342        let q = t.apply_point(&p);
343        assert!((q.x - 6.0).abs() < 1e-10);
344        assert!((q.y - 5.0).abs() < 1e-10);
345    }
346
347    #[test]
348    fn test_apply_points() {
349        let t = Transform2D::translation(1.0, 1.0);
350        let points = vec![(0.0, 0.0), (1.0, 0.0), (0.0, 1.0)];
351        let transformed = t.apply_points(&points);
352        assert!((transformed[0].0 - 1.0).abs() < 1e-10);
353        assert!((transformed[0].1 - 1.0).abs() < 1e-10);
354        assert!((transformed[1].0 - 2.0).abs() < 1e-10);
355        assert!((transformed[2].1 - 2.0).abs() < 1e-10);
356    }
357
358    #[test]
359    fn test_default_is_identity() {
360        let t = Transform2D::default();
361        assert!(t.is_identity(1e-15));
362    }
363
364    // ======================== 3D Transform Tests ========================
365
366    #[test]
367    fn test_3d_identity() {
368        let t = Transform3D::identity();
369        let (x, y, z) = t.apply(1.0, 2.0, 3.0);
370        assert!((x - 1.0).abs() < 1e-10);
371        assert!((y - 2.0).abs() < 1e-10);
372        assert!((z - 3.0).abs() < 1e-10);
373    }
374
375    #[test]
376    fn test_3d_translation() {
377        let t = Transform3D::translation(10.0, 20.0, 30.0);
378        let (x, y, z) = t.apply(1.0, 2.0, 3.0);
379        assert!((x - 11.0).abs() < 1e-10);
380        assert!((y - 22.0).abs() < 1e-10);
381        assert!((z - 33.0).abs() < 1e-10);
382    }
383
384    #[test]
385    fn test_3d_rotation_z_90() {
386        // Rotate 90 degrees about Z: (1,0,0) → (0,1,0)
387        let t = Transform3D::new(0.0, 0.0, 0.0, 0.0, 0.0, PI / 2.0);
388        let (x, y, z) = t.apply(1.0, 0.0, 0.0);
389        assert!((x - 0.0).abs() < 1e-10);
390        assert!((y - 1.0).abs() < 1e-10);
391        assert!((z - 0.0).abs() < 1e-10);
392    }
393
394    #[test]
395    fn test_3d_rotation_x_90() {
396        // Rotate 90 degrees about X: (0,1,0) → (0,0,1)
397        let t = Transform3D::new(0.0, 0.0, 0.0, PI / 2.0, 0.0, 0.0);
398        let (x, y, z) = t.apply(0.0, 1.0, 0.0);
399        assert!((x - 0.0).abs() < 1e-10);
400        assert!((y - 0.0).abs() < 1e-10);
401        assert!((z - 1.0).abs() < 1e-10);
402    }
403
404    #[test]
405    fn test_3d_rotation_y_90() {
406        // Rotate 90 degrees about Y: (0,0,1) → (1,0,0)
407        let t = Transform3D::new(0.0, 0.0, 0.0, 0.0, PI / 2.0, 0.0);
408        let (x, y, z) = t.apply(0.0, 0.0, 1.0);
409        assert!((x - 1.0).abs() < 1e-10);
410        assert!((y - 0.0).abs() < 1e-10);
411        assert!((z - 0.0).abs() < 1e-10);
412    }
413
414    #[test]
415    fn test_3d_compose() {
416        let t1 = Transform3D::translation(10.0, 0.0, 0.0);
417        let t2 = Transform3D::translation(0.0, 20.0, 0.0);
418        let composed = t1.then(&t2);
419        let (x, y, z) = composed.apply(0.0, 0.0, 0.0);
420        assert!((x - 10.0).abs() < 1e-10);
421        assert!((y - 20.0).abs() < 1e-10);
422        assert!((z - 0.0).abs() < 1e-10);
423    }
424
425    #[test]
426    fn test_3d_inverse() {
427        let t = Transform3D::new(10.0, 20.0, 30.0, PI / 4.0, PI / 6.0, PI / 3.0);
428        let inv = t.inverse();
429        let composed = t.then(&inv);
430        assert!(composed.is_identity(1e-10));
431    }
432
433    #[test]
434    fn test_3d_apply_point() {
435        let t = Transform3D::translation(5.0, 3.0, 1.0);
436        let p = Point3::new(1.0, 2.0, 3.0);
437        let q = t.apply_point(&p);
438        assert!((q.x - 6.0).abs() < 1e-10);
439        assert!((q.y - 5.0).abs() < 1e-10);
440        assert!((q.z - 4.0).abs() < 1e-10);
441    }
442
443    #[test]
444    fn test_3d_apply_points() {
445        let t = Transform3D::translation(1.0, 1.0, 1.0);
446        let points = vec![
447            Point3::new(0.0, 0.0, 0.0),
448            Point3::new(1.0, 0.0, 0.0),
449            Point3::new(0.0, 1.0, 0.0),
450        ];
451        let transformed = t.apply_points(&points);
452        assert!((transformed[0].x - 1.0).abs() < 1e-10);
453        assert!((transformed[1].x - 2.0).abs() < 1e-10);
454        assert!((transformed[2].y - 2.0).abs() < 1e-10);
455    }
456
457    #[test]
458    fn test_3d_default_is_identity() {
459        let t = Transform3D::default();
460        assert!(t.is_identity(1e-15));
461    }
462
463    // ======================== Serde Tests ========================
464
465    #[cfg(feature = "serde")]
466    mod serde_tests {
467        use super::*;
468
469        #[test]
470        fn test_transform2d_roundtrip() {
471            let t = Transform2D::new(10.0, 20.0, std::f64::consts::PI / 4.0);
472            let json = serde_json::to_string(&t).unwrap();
473            let t2: Transform2D = serde_json::from_str(&json).unwrap();
474            assert_eq!(t, t2);
475        }
476
477        #[test]
478        fn test_transform3d_roundtrip() {
479            let t = Transform3D::new(1.0, 2.0, 3.0, 0.1, 0.2, 0.3);
480            let json = serde_json::to_string(&t).unwrap();
481            let t2: Transform3D = serde_json::from_str(&json).unwrap();
482            assert_eq!(t, t2);
483        }
484    }
485}