Skip to main content

apex_manifolds/
so2.rs

1//! SO(2) - Special Orthogonal Group in 2D
2//!
3//! This module implements the Special Orthogonal group SO(2), which represents
4//! rotations in 2D space.
5//!
6//! SO(2) elements are stored as a single angle θ (radians). UnitComplex is
7//! constructed on-the-fly for math operations. This gives contiguous single-float
8//! storage compatible with zero-copy faer views.
9//!
10//! The implementation follows the [manif](https://github.com/artivis/manif) C++ library
11//! conventions and provides all operations required by the LieGroup and Tangent traits.
12
13use crate::{LieGroup, Tangent};
14use nalgebra::{Matrix1, Matrix2, Matrix3, UnitComplex, Vector2, Vector3};
15use std::{
16    fmt,
17    fmt::{Display, Formatter},
18};
19
20/// SO(2) group element representing rotations in 2D.
21///
22/// Stored as a single angle θ (radians). UnitComplex is derived on demand for
23/// math operations, keeping storage minimal and contiguous.
24#[derive(Clone, PartialEq)]
25pub struct SO2 {
26    /// Rotation angle in radians
27    theta: f64,
28}
29
30impl Display for SO2 {
31    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
32        write!(f, "SO2(angle: {:.4})", self.theta)
33    }
34}
35
36impl SO2 {
37    /// Space dimension - dimension of the ambient space that the group acts on
38    pub const DIM: usize = 2;
39
40    /// Degrees of freedom - dimension of the tangent space
41    pub const DOF: usize = 1;
42
43    /// Representation size - one angle θ
44    pub const REP_SIZE: usize = 1;
45
46    /// Get the identity element of the group.
47    pub fn identity() -> Self {
48        SO2 { theta: 0.0 }
49    }
50
51    /// Get the identity matrix for Jacobians.
52    pub fn jacobian_identity() -> Matrix1<f64> {
53        Matrix1::<f64>::identity()
54    }
55
56    /// Create a new SO(2) element from a unit complex number.
57    #[inline]
58    pub fn new(complex: UnitComplex<f64>) -> Self {
59        SO2 {
60            theta: complex.angle(),
61        }
62    }
63
64    /// Create SO(2) from an angle.
65    pub fn from_angle(angle: f64) -> Self {
66        SO2 { theta: angle }
67    }
68
69    /// Derive a UnitComplex from the stored angle (on-the-fly).
70    #[inline]
71    fn unit_complex(&self) -> UnitComplex<f64> {
72        UnitComplex::new(self.theta)
73    }
74
75    /// Get the underlying unit complex number.
76    pub fn complex(&self) -> UnitComplex<f64> {
77        self.unit_complex()
78    }
79
80    /// Get the rotation angle in radians.
81    #[inline]
82    pub fn angle(&self) -> f64 {
83        self.theta
84    }
85
86    /// Get the rotation matrix (2x2).
87    pub fn rotation_matrix(&self) -> Matrix2<f64> {
88        self.unit_complex().to_rotation_matrix().into_inner()
89    }
90}
91
92impl LieGroup for SO2 {
93    const NAME: &'static str = "SO2";
94
95    type TangentVector = SO2Tangent;
96    type JacobianMatrix = Matrix1<f64>;
97    type LieAlgebra = Matrix2<f64>;
98
99    /// SO2 inverse: R(θ)⁻¹ = R(-θ).
100    fn inverse(&self, jacobian: Option<&mut Self::JacobianMatrix>) -> Self {
101        if let Some(jac) = jacobian {
102            *jac = -self.adjoint();
103        }
104        SO2 { theta: -self.theta }
105    }
106
107    /// SO2 composition: angles add, wrapped via UnitComplex to stay in [-π, π].
108    fn compose(
109        &self,
110        other: &Self,
111        jacobian_self: Option<&mut Self::JacobianMatrix>,
112        jacobian_other: Option<&mut Self::JacobianMatrix>,
113    ) -> Self {
114        if let Some(jac_self) = jacobian_self {
115            *jac_self = other.inverse(None).adjoint();
116        }
117        if let Some(jac_other) = jacobian_other {
118            *jac_other = Matrix1::identity();
119        }
120        SO2 {
121            theta: (self.unit_complex() * other.unit_complex()).angle(),
122        }
123    }
124
125    /// Logarithmic map: returns the canonical angle in [-π, π].
126    fn log(&self, jacobian: Option<&mut Self::JacobianMatrix>) -> Self::TangentVector {
127        if let Some(jac) = jacobian {
128            *jac = Matrix1::identity();
129        }
130        SO2Tangent {
131            data: self.unit_complex().angle(),
132        }
133    }
134
135    /// Rotation action on a 3-vector (operates on the xy-plane).
136    fn act(
137        &self,
138        vector: &Vector3<f64>,
139        _jacobian_self: Option<&mut Self::JacobianMatrix>,
140        _jacobian_vector: Option<&mut Matrix3<f64>>,
141    ) -> Vector3<f64> {
142        let point2d = Vector2::new(vector.x, vector.y);
143        let rotated = self.unit_complex() * point2d;
144        Vector3::new(rotated.x, rotated.y, vector.z)
145    }
146
147    /// Adjoint for SO(2) is identity (abelian group).
148    fn adjoint(&self) -> Self::JacobianMatrix {
149        Matrix1::identity()
150    }
151
152    fn random() -> Self {
153        SO2::from_angle(rand::random::<f64>() * 2.0 * std::f64::consts::PI)
154    }
155
156    fn jacobian_identity() -> Self::JacobianMatrix {
157        Matrix1::<f64>::identity()
158    }
159
160    fn zero_jacobian() -> Self::JacobianMatrix {
161        Matrix1::<f64>::zeros()
162    }
163
164    /// Normalize: wrap angle to [-π, π] using UnitComplex.
165    fn normalize(&mut self) {
166        self.theta = self.unit_complex().angle();
167    }
168
169    /// Any finite angle is valid.
170    fn is_valid(&self, _tolerance: f64) -> bool {
171        self.theta.is_finite()
172    }
173
174    fn vee(&self) -> Self::TangentVector {
175        self.log(None)
176    }
177
178    fn is_approx(&self, other: &Self, tolerance: f64) -> bool {
179        let difference = self.right_minus(other, None, None);
180        difference.is_zero(tolerance)
181    }
182
183    fn as_param_slice(&self) -> &[f64] {
184        std::slice::from_ref(&self.theta)
185    }
186
187    fn as_param_slice_mut(&mut self) -> &mut [f64] {
188        std::slice::from_mut(&mut self.theta)
189    }
190
191    fn from_param_slice(s: &[f64]) -> Self {
192        debug_assert_eq!(s.len(), 1);
193        SO2 { theta: s[0] }
194    }
195}
196
197/// SO(2) tangent space element representing elements in the Lie algebra so(2).
198///
199/// Internally represented as a single scalar (angle in radians).
200#[derive(Clone, PartialEq)]
201pub struct SO2Tangent {
202    /// Internal data: angle (radians)
203    data: f64,
204}
205
206impl fmt::Display for SO2Tangent {
207    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
208        write!(f, "so2(angle: {:.4})", self.data)
209    }
210}
211
212impl SO2Tangent {
213    /// Create a new SO2Tangent from an angle.
214    ///
215    /// # Arguments
216    /// * `angle` - Angle in radians
217    #[inline]
218    pub fn new(angle: f64) -> Self {
219        SO2Tangent { data: angle }
220    }
221
222    /// Get the angle.
223    #[inline]
224    pub fn angle(&self) -> f64 {
225        self.data
226    }
227}
228
229impl Tangent<SO2> for SO2Tangent {
230    /// Dimension of the tangent space
231    const DIM: usize = 1;
232
233    /// SO2 exponential map.
234    ///
235    /// # Arguments
236    /// * `tangent` - Tangent vector (angle)
237    /// * `jacobian` - Optional Jacobian matrix of the SE(3) element wrt this.
238    fn exp(&self, jacobian: Option<&mut <SO2 as LieGroup>::JacobianMatrix>) -> SO2 {
239        let angle = self.angle();
240        let complex = UnitComplex::new(angle);
241
242        if let Some(jac) = jacobian {
243            *jac = Matrix1::identity();
244        }
245
246        SO2::new(complex)
247    }
248
249    /// Right Jacobian for SO(2) is identity.
250    fn right_jacobian(&self) -> <SO2 as LieGroup>::JacobianMatrix {
251        Matrix1::identity()
252    }
253
254    /// Left Jacobian for SO(2) is identity.
255    fn left_jacobian(&self) -> <SO2 as LieGroup>::JacobianMatrix {
256        Matrix1::identity()
257    }
258
259    /// Inverse of right Jacobian for SO(2) is identity.
260    fn right_jacobian_inv(&self) -> <SO2 as LieGroup>::JacobianMatrix {
261        Matrix1::identity()
262    }
263
264    /// Inverse of left Jacobian for SO(2) is identity.
265    fn left_jacobian_inv(&self) -> <SO2 as LieGroup>::JacobianMatrix {
266        Matrix1::identity()
267    }
268
269    /// Hat operator: θ^∧ (scalar to skew-symmetric matrix).
270    fn hat(&self) -> <SO2 as LieGroup>::LieAlgebra {
271        let theta = self.data;
272        Matrix2::new(0.0, -theta, theta, 0.0)
273    }
274
275    /// Zero tangent vector for SO2
276    fn zero() -> Self {
277        SO2Tangent { data: 0.0 }
278    }
279
280    /// Random tangent vector for SO2
281    fn random() -> Self {
282        SO2Tangent {
283            data: rand::random::<f64>() * 0.2 - 0.1,
284        }
285    }
286
287    /// Check if tangent vector is zero
288    fn is_zero(&self, tolerance: f64) -> bool {
289        self.data.abs() < tolerance
290    }
291
292    /// Normalize tangent vector
293    fn normalize(&mut self) {
294        // Normalizing a scalar doesn't make much sense unless it's a direction.
295        // For an angle, this is a no-op.
296    }
297
298    /// Return a unit tangent vector in the same direction.
299    fn normalized(&self) -> Self {
300        if self.data.abs() > f64::EPSILON {
301            SO2Tangent::new(self.data.signum())
302        } else {
303            SO2Tangent::new(0.0)
304        }
305    }
306
307    fn as_slice(&self) -> &[f64] {
308        std::slice::from_ref(&self.data)
309    }
310
311    fn from_slice(s: &[f64]) -> Self {
312        debug_assert_eq!(s.len(), 1);
313        SO2Tangent { data: s[0] }
314    }
315
316    /// Small adjoint matrix for SO(2).
317    ///
318    /// For SO(2), the small adjoint is zero (since it's commutative).
319    fn small_adj(&self) -> <SO2 as LieGroup>::JacobianMatrix {
320        Matrix1::zeros()
321    }
322
323    /// Lie bracket for SO(2).
324    ///
325    /// For SO(2), the Lie bracket is always zero since it's commutative.
326    fn lie_bracket(&self, _other: &Self) -> <SO2 as LieGroup>::TangentVector {
327        SO2Tangent::zero()
328    }
329
330    /// Check if this tangent vector is approximately equal to another.
331    ///
332    /// # Arguments
333    /// * `other` - The other tangent vector to compare with
334    /// * `tolerance` - The tolerance for the comparison
335    fn is_approx(&self, other: &Self, tolerance: f64) -> bool {
336        (self.data - other.data).abs() < tolerance
337    }
338
339    /// Get the ith generator of the SO(2) Lie algebra.
340    ///
341    /// # Arguments
342    /// * `i` - Index of the generator (must be 0 for SO(2))
343    ///
344    /// # Returns
345    /// The generator matrix
346    fn generator(&self, i: usize) -> <SO2 as LieGroup>::LieAlgebra {
347        assert_eq!(i, 0, "SO(2) only has one generator (index 0)");
348        Matrix2::new(0.0, -1.0, 1.0, 0.0)
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use nalgebra::{DMatrix, DVector};
356    use std::f64::consts::PI;
357
358    const TOLERANCE: f64 = 1e-12;
359
360    /// Numerically compute Jacobian using central difference
361    fn numerical_jacobian<F>(
362        func: F,
363        point: &DVector<f64>,
364        output_dim: usize,
365        epsilon: f64,
366    ) -> DMatrix<f64>
367    where
368        F: Fn(&DVector<f64>) -> DVector<f64>,
369    {
370        let input_dim = point.len();
371        let mut jacobian = DMatrix::zeros(output_dim, input_dim);
372
373        for i in 0..input_dim {
374            let mut point_plus = point.clone();
375            let mut point_minus = point.clone();
376            point_plus[i] += epsilon;
377            point_minus[i] -= epsilon;
378
379            let output_plus = func(&point_plus);
380            let output_minus = func(&point_minus);
381            let derivative = (output_plus - output_minus) / (2.0 * epsilon);
382
383            jacobian.set_column(i, &derivative);
384        }
385
386        jacobian
387    }
388
389    #[test]
390    fn test_so2_identity() {
391        let so2 = SO2::identity();
392        assert!((so2.angle() - 0.0).abs() < TOLERANCE);
393    }
394
395    #[test]
396    fn test_so2_inverse() {
397        let so2 = SO2::from_angle(PI / 4.0);
398        let so2_inv = so2.inverse(None);
399        assert!((so2_inv.angle() + PI / 4.0).abs() < TOLERANCE);
400    }
401
402    #[test]
403    fn test_so2_compose() {
404        let so2_a = SO2::from_angle(PI / 4.0);
405        let so2_b = SO2::from_angle(PI / 2.0);
406        let composed = so2_a.compose(&so2_b, None, None);
407        assert!((composed.angle() - (3.0 * PI / 4.0)).abs() < TOLERANCE);
408    }
409
410    #[test]
411    fn test_so2_exp_log_consistency() {
412        let angle = PI / 4.0;
413        let tangent = SO2Tangent::new(angle);
414        let so2 = tangent.exp(None);
415        let recovered_tangent = so2.log(None);
416
417        assert!((tangent.angle() - recovered_tangent.angle()).abs() < 1e-10);
418    }
419
420    // New tests for the additional functions
421
422    #[test]
423    fn test_so2_vee() {
424        let so2 = SO2::from_angle(PI / 3.0);
425        let tangent_log = so2.log(None);
426        let tangent_vee = so2.vee();
427
428        assert!((tangent_log.angle() - tangent_vee.angle()).abs() < 1e-10);
429    }
430
431    #[test]
432    fn test_so2_is_approx() {
433        let so2_1 = SO2::from_angle(PI / 4.0);
434        let so2_2 = SO2::from_angle(PI / 4.0 + 1e-12);
435        let so2_3 = SO2::from_angle(PI / 2.0);
436
437        assert!(so2_1.is_approx(&so2_1, 1e-10));
438        assert!(so2_1.is_approx(&so2_2, 1e-10));
439        assert!(!so2_1.is_approx(&so2_3, 1e-10));
440    }
441
442    #[test]
443    fn test_so2_tangent_small_adj() {
444        let tangent = SO2Tangent::new(PI / 6.0);
445        let small_adj = tangent.small_adj();
446
447        // For SO(2), small adjoint should be zero (commutative group)
448        assert!((small_adj[(0, 0)]).abs() < 1e-10);
449    }
450
451    #[test]
452    fn test_so2_tangent_lie_bracket() {
453        let tangent_a = SO2Tangent::new(0.1);
454        let tangent_b = SO2Tangent::new(0.2);
455
456        let bracket = tangent_a.lie_bracket(&tangent_b);
457
458        // For SO(2), Lie bracket should be zero (commutative group)
459        assert!(bracket.is_zero(1e-10));
460
461        // Anti-symmetry test: [a,b] = -[b,a]
462        let bracket_ba = tangent_b.lie_bracket(&tangent_a);
463        assert!(bracket.lie_bracket(&tangent_b).is_zero(1e-10)); // [a,a] = 0
464
465        // Since SO(2) is commutative, both should be zero
466        assert!(bracket_ba.is_zero(1e-10));
467    }
468
469    #[test]
470    fn test_so2_tangent_is_approx() {
471        let tangent_1 = SO2Tangent::new(0.5);
472        let tangent_2 = SO2Tangent::new(0.5 + 1e-12);
473        let tangent_3 = SO2Tangent::new(1.0);
474
475        assert!(tangent_1.is_approx(&tangent_1, 1e-10));
476        assert!(tangent_1.is_approx(&tangent_2, 1e-10));
477        assert!(!tangent_1.is_approx(&tangent_3, 1e-10));
478    }
479
480    #[test]
481    fn test_so2_generator() {
482        let tangent = SO2Tangent::new(1.0);
483        let generator = tangent.generator(0);
484
485        // SO(2) generator should be the skew-symmetric matrix
486        let expected = Matrix2::new(0.0, -1.0, 1.0, 0.0);
487
488        assert!((generator - expected).norm() < 1e-10);
489    }
490
491    #[test]
492    #[should_panic]
493    fn test_so2_generator_invalid_index() {
494        let tangent = SO2Tangent::new(1.0);
495        let _generator = tangent.generator(1); // Should panic for SO(2)
496    }
497
498    #[test]
499    fn test_so2_bracket_hat_relationship() {
500        let a = SO2Tangent::new(0.1);
501        let b = SO2Tangent::new(0.2);
502
503        // For SO(2): [a,b]^ = a^ * b^ - b^ * a^ should be zero (commutative)
504        let bracket_hat = a.lie_bracket(&b).hat();
505        let expected = a.hat() * b.hat() - b.hat() * a.hat();
506
507        assert!((bracket_hat - expected).norm() < 1e-10);
508        assert!(expected.norm() < 1e-10); // Should be zero for SO(2)
509    }
510
511    #[test]
512    fn test_so2_right_jacobian_numerical() {
513        let epsilon = 1e-7;
514        let tolerance = 1e-4;
515
516        let tangent = SO2Tangent::new(0.5);
517        let jr_analytical = tangent.right_jacobian();
518
519        // Numerical Jacobian through exp-log round trip
520        let angle_vec = DVector::from_vec(vec![tangent.angle()]);
521        let jr_numerical = numerical_jacobian(
522            |theta| {
523                let tang = SO2Tangent::new(theta[0]);
524                let so2 = tang.exp(None);
525                let log_result = so2.log(None);
526                DVector::from_vec(vec![log_result.angle()])
527            },
528            &angle_vec,
529            1,
530            epsilon,
531        );
532
533        assert!(
534            (jr_analytical - &jr_numerical).norm() < tolerance,
535            "Right Jacobian mismatch: analytical = {}, numerical = {}",
536            jr_analytical,
537            jr_numerical
538        );
539    }
540
541    #[test]
542    fn test_so2_left_jacobian_numerical() {
543        let epsilon = 1e-7;
544        let tolerance = 1e-4;
545
546        let tangent = SO2Tangent::new(0.5);
547        let jl_analytical = tangent.left_jacobian();
548
549        let angle_vec = DVector::from_vec(vec![tangent.angle()]);
550        let jl_numerical = numerical_jacobian(
551            |theta| {
552                let tang = SO2Tangent::new(theta[0]);
553                let so2 = tang.exp(None);
554                let log_result = so2.log(None);
555                DVector::from_vec(vec![log_result.angle()])
556            },
557            &angle_vec,
558            1,
559            epsilon,
560        );
561
562        assert!((jl_analytical - jl_numerical).norm() < tolerance);
563    }
564
565    // T4: Jacobian Inverse Identity Tests
566
567    #[test]
568    fn test_so2_jacobian_inverse_identity() {
569        // SO2 Jacobians are scalars (1x1 matrices)
570        let tangent = SO2Tangent::new(0.5);
571        let jr = tangent.right_jacobian();
572        let jr_inv = tangent.right_jacobian_inv();
573        let product = jr * jr_inv;
574
575        assert!((product[(0, 0)] - 1.0).abs() < 1e-10);
576
577        // Also test left Jacobian
578        let jl = tangent.left_jacobian();
579        let jl_inv = tangent.left_jacobian_inv();
580        let product_left = jl * jl_inv;
581
582        assert!((product_left[(0, 0)] - 1.0).abs() < 1e-10);
583    }
584
585    #[test]
586    fn test_so2_display() {
587        let r = SO2::from_angle(0.5);
588        let s = format!("{r}");
589        assert!(!s.is_empty(), "Display should produce output, got: {s}");
590
591        let t = SO2Tangent::new(1.2);
592        let st = format!("{t}");
593        assert!(
594            !st.is_empty(),
595            "Tangent Display should produce output, got: {st}"
596        );
597    }
598
599    #[test]
600    fn test_so2_from_slice_and_back() {
601        let angle = 1.0f64;
602        let r = SO2::from_param_slice(&[angle]);
603        let back = DVector::from_column_slice(r.as_param_slice());
604        assert_eq!(back.len(), 1);
605        assert!((back[0] - angle).abs() < 1e-9);
606    }
607
608    #[test]
609    fn test_so2_tangent_from_slice_and_back() {
610        let t = SO2Tangent::from_slice(&[0.7f64]);
611        let v2 = DVector::from_column_slice(t.as_slice());
612        assert!((v2[0] - 0.7).abs() < 1e-10);
613    }
614
615    #[test]
616    fn test_so2_rotation_matrix() {
617        let r = SO2::from_angle(0.0);
618        let mat = r.rotation_matrix();
619        assert!((mat[(0, 0)] - 1.0).abs() < 1e-10);
620        assert!(mat[(0, 1)].abs() < 1e-10);
621    }
622
623    #[test]
624    fn test_so2_complex_angle_accessors() {
625        let angle = std::f64::consts::FRAC_PI_4;
626        let r = SO2::from_angle(angle);
627        let c = r.complex();
628        assert!((c.re - angle.cos()).abs() < 1e-9);
629        assert!((c.im - angle.sin()).abs() < 1e-9);
630        assert!((r.angle() - angle).abs() < 1e-9);
631    }
632
633    #[test]
634    fn test_so2_normalize_is_valid() {
635        let mut r = SO2::from_angle(0.3);
636        r.normalize();
637        assert!(r.is_valid(1e-6));
638    }
639
640    #[test]
641    fn test_so2_tangent_normalized() {
642        let t_pos = SO2Tangent::new(3.0);
643        let tn = t_pos.normalized();
644        assert!((tn.angle() - 1.0).abs() < 1e-9);
645
646        let t_neg = SO2Tangent::new(-2.0);
647        let tn_neg = t_neg.normalized();
648        assert!((tn_neg.angle() - (-1.0)).abs() < 1e-9);
649
650        let t_zero = SO2Tangent::new(0.0);
651        let tn_zero = t_zero.normalized();
652        assert!((tn_zero.angle()).abs() < 1e-9);
653    }
654
655    #[test]
656    fn test_so2_tangent_is_zero() {
657        let zero = SO2Tangent::new(0.0);
658        assert!(zero.is_zero(1e-9));
659        let nonzero = SO2Tangent::new(0.1);
660        assert!(!nonzero.is_zero(1e-9));
661    }
662
663    #[test]
664    fn test_so2_random() {
665        let r = SO2::random();
666        assert!(r.is_valid(1e-6));
667
668        let t = SO2Tangent::random();
669        let _ = t; // just verify it doesn't panic
670    }
671
672    #[test]
673    fn test_so2_adjoint_zero_jacobian_identity() {
674        let r = SO2::from_angle(0.5);
675        let adj = r.adjoint();
676        assert!((adj[0] - 1.0).abs() < 1e-10);
677
678        let zj = SO2::zero_jacobian();
679        assert!(zj[0].abs() < 1e-10);
680
681        let ji = SO2::jacobian_identity();
682        assert!((ji[0] - 1.0).abs() < 1e-10);
683    }
684
685    #[test]
686    fn test_so2_compose_with_jacobians() {
687        let r1 = SO2::from_angle(0.3);
688        let r2 = SO2::from_angle(0.2);
689        let mut j_self = Matrix1::zeros();
690        let mut j_other = Matrix1::zeros();
691        let result = r1.compose(&r2, Some(&mut j_self), Some(&mut j_other));
692        assert!(result.is_valid(1e-9));
693        assert!(j_self[0].is_finite());
694        assert!(j_other[0].is_finite());
695    }
696
697    #[test]
698    fn test_so2_log_with_jacobian() {
699        let r = SO2::from_angle(0.5);
700        let mut jac = Matrix1::zeros();
701        let t = r.log(Some(&mut jac));
702        assert!((t.angle() - 0.5).abs() < 1e-9);
703        assert!((jac[0] - 1.0).abs() < 1e-10);
704    }
705
706    #[test]
707    fn test_so2_inverse_with_jacobian() {
708        let r = SO2::from_angle(0.4);
709        let mut jac = Matrix1::zeros();
710        let inv = r.inverse(Some(&mut jac));
711        assert!(inv.is_valid(1e-9));
712        assert!(jac[0].is_finite());
713    }
714
715    #[test]
716    fn test_so2_tangent_hat() {
717        let t = SO2Tangent::new(1.0);
718        let hat = t.hat();
719        // hat should be skew-symmetric: [[0, -theta], [theta, 0]]
720        assert!(hat[(0, 0)].abs() < 1e-10);
721        assert!((hat[(1, 0)] - 1.0).abs() < 1e-10);
722        assert!((hat[(0, 1)] - (-1.0)).abs() < 1e-10);
723    }
724
725    #[test]
726    fn test_so2_tangent_exp_with_jacobian() {
727        use crate::Tangent;
728        let t = SO2Tangent::new(0.3);
729        let mut jac = Matrix1::zeros();
730        let r = t.exp(Some(&mut jac));
731        assert!(r.is_valid(1e-9));
732        assert!((jac[0] - 1.0).abs() < 1e-10);
733    }
734
735    #[test]
736    fn test_so2_tangent_zero() {
737        use crate::Tangent;
738        let zero = SO2Tangent::zero();
739        assert!(zero.is_zero(1e-9));
740    }
741
742    #[test]
743    fn so2_param_slice_round_trip() {
744        let g = SO2::random();
745        let recovered = SO2::from_param_slice(g.as_param_slice());
746        assert!(g.is_approx(&recovered, 1e-14));
747    }
748
749    #[test]
750    fn so2_tangent_slice_round_trip() {
751        let t = SO2Tangent::random();
752        let recovered = SO2Tangent::from_slice(t.as_slice());
753        assert!(t.is_approx(&recovered, 1e-14));
754    }
755}