Skip to content

Commit 8afc801

Browse files
authored
feat: quaternion constructors for Rotation and Orientation (#73)
This is useful when converting rotations and poses from other systems that expose them only as quaternions. Quaternions are represented in the API as pairs of their real and imaginary parts. The former is a scalar, the latter is a vector. This split also somewhat ensures that the scalar and vector aren't swapped, the conventions seem to differ a bit between libraries. Creating rotations and orientations from a `UnitQuaternion` would've been nice type safty-wise, but that would have exposed the `nalgebra` internals to squaba's public interface. The APIs are marked as unsafe, as we can't ensure that a given quaternion does _really_ convert from `From` to `To` when creating a `Rotation` from a quaternion. Furthermore, we (have to) normalize any quaternions provided, so any non-normalized quaternion that doesn't represent a rotation is normalized so that it becomes a rotation but looses its original meaning. The quaternions that are provided to `from_quaternion` are are, contrary to the internal storage in `Rotation`, the quaternion provided must actually be a quaternion transforming from `From` into `To`, because that's less surprising than if the transformation is the other way around.
1 parent ffe0b7d commit 8afc801

File tree

3 files changed

+300
-3
lines changed

3 files changed

+300
-3
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- Add `Orientation::from_quaternion` and `Rotation::from_quaternion`
13+
([#73](https://github.com/helsing-ai/sguaba/pull/73)).
14+
1215
### Changed
1316

1417
### Deprecated

src/engineering.rs

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,74 @@ impl<In> Orientation<In> {
148148
}
149149
}
150150

151+
/// Constructs an orientation from the components of a [versor] / unit quaternion.
152+
///
153+
/// The components are given as `w` (the scalar/real part) and `[i, j, k]` (the vector /
154+
/// imaginary part).
155+
///
156+
/// The quaternion should represent the desired orientation in space.
157+
///
158+
/// # Examples
159+
///
160+
/// ```rust
161+
/// use approx::assert_abs_diff_eq;
162+
/// use sguaba::{system, engineering::Orientation};
163+
/// use uom::si::{f64::Angle, angle::degree};
164+
///
165+
/// system!(struct PlaneNed using NED);
166+
///
167+
/// // construct from Tait-Bryan angles
168+
/// let from_angles = Orientation::<PlaneNed>::tait_bryan_builder()
169+
/// .yaw(Angle::new::<degree>(90.0))
170+
/// .pitch(Angle::new::<degree>(45.0))
171+
/// .roll(Angle::new::<degree>(5.0))
172+
/// .build();
173+
///
174+
/// // extract quaternion and reconstruct
175+
/// let (w, i, j, k) = from_angles.to_quaternion();
176+
/// // SAFETY: We just extracted the quaternion from a valid orientation
177+
/// let from_quat = unsafe {
178+
/// Orientation::<PlaneNed>::from_quaternion(w, i, j, k)
179+
/// };
180+
///
181+
/// let (y1, p1, r1) = from_angles.to_tait_bryan_angles();
182+
/// let (y2, p2, r2) = from_quat.to_tait_bryan_angles();
183+
/// assert_abs_diff_eq!(y1.get::<degree>(), y2.get::<degree>(), epsilon = 1e-10);
184+
/// assert_abs_diff_eq!(p1.get::<degree>(), p2.get::<degree>(), epsilon = 1e-10);
185+
/// assert_abs_diff_eq!(r1.get::<degree>(), r2.get::<degree>(), epsilon = 1e-10);
186+
/// ```
187+
///
188+
/// # Safety
189+
///
190+
/// The quaternion must be non-zero (ie, not `(0, 0, 0, 0)`).
191+
/// A zero quaternion has no meaningful rotation associated with it.
192+
/// Non-unit quaternions (which don't represent rotations or orientations) are normalized
193+
/// internally and may change their meaning.
194+
///
195+
/// [versor]: https://en.wikipedia.org/wiki/Versor
196+
#[doc(alias = "from_versor")]
197+
#[must_use]
198+
pub unsafe fn from_quaternion(w: f64, i: f64, j: f64, k: f64) -> Self {
199+
Self {
200+
inner: unsafe { Rotation::from_quaternion(w, i, j, k) },
201+
}
202+
}
203+
204+
/// Returns the components of the [unit quaternion] that describes this orientation as
205+
/// `(w, [i, j, k])` where `w` is the scalar/real part and `[i, j, k]` is the vector / imaginary
206+
/// part.
207+
///
208+
/// The returned quaternion represents the rotation of the object's body axes relative to
209+
/// the coordinate system `In`, matching the convention of
210+
/// [`Orientation::from_quaternion`].
211+
///
212+
/// [unit quaternion]: https://en.wikipedia.org/wiki/Versor
213+
#[doc(alias = "to_versor")]
214+
#[must_use]
215+
pub fn to_quaternion(&self) -> (f64, f64, f64, f64) {
216+
self.inner.to_quaternion()
217+
}
218+
151219
/// Constructs an orientation that is aligned with the axes of the [`CoordinateSystem`] `In`.
152220
///
153221
/// That is equivalent to calling [`Orientation::from_tait_bryan_angles`] with all
@@ -1246,4 +1314,40 @@ mod tests {
12461314
assert_relative_eq!(right_in_frd_2, right_chained);
12471315
assert_relative_eq!(down_in_frd_2, down_chained);
12481316
}
1317+
1318+
#[test]
1319+
fn orientation_quaternion_identity() {
1320+
let identity = unsafe { Orientation::<PlaneNed>::from_quaternion(1.0, 0.0, 0.0, 0.0) };
1321+
assert_eq!(identity, Orientation::<PlaneNed>::aligned());
1322+
}
1323+
1324+
#[rstest]
1325+
#[case(d(0.), d(0.), d(0.))]
1326+
#[case(d(90.), d(0.), d(0.))]
1327+
#[case(d(0.), d(45.), d(0.))]
1328+
#[case(d(0.), d(0.), d(30.))]
1329+
#[case(d(90.), d(45.), d(5.))]
1330+
#[case(d(39.), d(45.), d(55.))]
1331+
#[case(d(180.), d(0.), d(0.))]
1332+
#[case(d(-45.), d(20.), d(10.))]
1333+
fn orientation_quaternion_round_trip(
1334+
#[case] yaw: Angle,
1335+
#[case] pitch: Angle,
1336+
#[case] roll: Angle,
1337+
) {
1338+
let from_angles = Orientation::<PlaneNed>::tait_bryan_builder()
1339+
.yaw(yaw)
1340+
.pitch(pitch)
1341+
.roll(roll)
1342+
.build();
1343+
1344+
let (w, i, j, k) = from_angles.to_quaternion();
1345+
let from_quat = unsafe { Orientation::<PlaneNed>::from_quaternion(w, i, j, k) };
1346+
1347+
let (y1, p1, r1) = from_angles.to_tait_bryan_angles();
1348+
let (y2, p2, r2) = from_quat.to_tait_bryan_angles();
1349+
for (a, b) in [(y1, y2), (p1, p2), (r1, r2)] {
1350+
assert_abs_diff_eq!(&BoundedAngle::new(a), &BoundedAngle::new(b));
1351+
}
1352+
}
12491353
}

src/math.rs

Lines changed: 193 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,9 @@ use crate::{engineering, systems::FrdLike};
9494
pub struct Rotation<From, To> {
9595
/// This is _actually_ the rotation from `To` into `From`, because that's a much more natural
9696
/// constructor. This means that what we store here is equivalent to a rotation matrix whose
97-
/// rows are `From` and columns are `To` (rather than the other way around). If we want convert
98-
/// a vector `x` from `From` to `To`, we can't do a straight matrix multiplication of the
99-
/// (equivalent) rotation matrix in `inner` with `x`, because that would yield (From x To) *
97+
/// rows are `From` and columns are `To` (rather than the other way around). If we want to
98+
/// convert a vector `x` from `From` to `To`, we can't do a straight matrix multiplication of
99+
/// the (equivalent) rotation matrix in `inner` with `x`, because that would yield (From x To) *
100100
/// (From x 1), which doesn't math. Instead, we need to matrix multiply the _inverse_ (which is
101101
/// transpose for a rotation matrix) of `inner` with `x`, which does work:
102102
///
@@ -625,6 +625,92 @@ impl<From, To> Rotation<From, To> {
625625
)
626626
}
627627

628+
/// Constructs a rotation from the components of a [versor] / unit quaternion.
629+
///
630+
/// The components are given as `w` (the scalar/real part) and `[i, j, k]` (the
631+
/// vector/imaginary part).
632+
///
633+
/// The quaternion should represent the rotation that transforms points in `From` into points
634+
/// in `To`. This is the intuitive "forward" direction matching the type parameters. The
635+
/// quaternion is normalized internally, so the input does not need to be exactly unit length.
636+
///
637+
/// # Examples
638+
///
639+
/// ```rust
640+
/// use approx::assert_relative_eq;
641+
/// use sguaba::{system, math::Rotation};
642+
/// use uom::si::{f64::Angle, angle::degree};
643+
///
644+
/// system!(struct PlaneNed using NED);
645+
/// system!(struct PlaneFrd using FRD);
646+
///
647+
/// // construct from Tait-Bryan angles
648+
/// let from_angles = unsafe {
649+
/// Rotation::<PlaneNed, PlaneFrd>::tait_bryan_builder()
650+
/// .yaw(Angle::new::<degree>(90.0))
651+
/// .pitch(Angle::new::<degree>(45.0))
652+
/// .roll(Angle::new::<degree>(5.0))
653+
/// .build()
654+
/// };
655+
///
656+
/// // extract quaternion and reconstruct
657+
/// let (w, i, j, k) = from_angles.to_quaternion();
658+
/// // SAFETY: We know that this quaternion is valid, as we got it from the rotation above.
659+
/// let from_quat = unsafe {
660+
/// Rotation::<PlaneNed, PlaneFrd>::from_quaternion(w, i, j, k)
661+
/// };
662+
///
663+
/// assert_relative_eq!(from_angles, from_quat);
664+
/// ```
665+
///
666+
/// # Safety
667+
///
668+
/// The caller must uphold two invariants:
669+
///
670+
/// 1. The provided quaternion correctly rotates from `From` to `To` (and crucially, that no
671+
/// translation is needed). If this is _not_ the correct transform, then this allows moving
672+
/// values between different coordinate system types without adjusting the values correctly,
673+
/// leading to a defeat of their type safety.
674+
///
675+
/// 2. The quaternion must be non-zero (ie, not `(0, [0, 0, 0])`).
676+
/// A zero quaternion has no meaningful rotation associated with it.
677+
/// Non-unit quaternions (which don't represent rotations or orientations) are normalized
678+
/// internally and may change their meaning.
679+
///
680+
/// [versor]: https://en.wikipedia.org/wiki/Versor
681+
#[doc(alias = "from_versor")]
682+
#[must_use]
683+
pub unsafe fn from_quaternion(w: f64, i: f64, j: f64, k: f64) -> Self {
684+
debug_assert_ne!(
685+
[w, i, j, k],
686+
[0.0, 0.0, 0.0, 0.0],
687+
"Quaternion must be non-zero"
688+
);
689+
let from_to = UnitQuaternion::new_normalize(nalgebra::Quaternion::new(w, i, j, k));
690+
Self {
691+
inner: from_to.inverse(),
692+
from: PhantomData::<From>,
693+
to: PhantomData::<To>,
694+
}
695+
}
696+
697+
/// Returns the components of the [unit quaternion] that rotates from `From` to `To` as
698+
/// `(w, [i, j, k])` where `w` is the scalar/real part and `[i, j, k]` is the vector / imaginary
699+
/// part.
700+
///
701+
/// The returned quaternion represents the "forward" direction of this rotation, (ie, rotation
702+
/// required to go from `From` to `To`), matching the type parameters and the convention of
703+
/// [`Rotation::from_quaternion`].
704+
///
705+
/// [unit quaternion]: https://en.wikipedia.org/wiki/Versor
706+
#[doc(alias = "to_versor")]
707+
#[must_use]
708+
pub fn to_quaternion(&self) -> (f64, f64, f64, f64) {
709+
let from_to = self.inner.inverse();
710+
let q = from_to.quaternion();
711+
(q.w, q.i, q.j, q.k)
712+
}
713+
628714
/// Returns the [Euler angles] describing this rotation.
629715
///
630716
/// The use of this method is discouraged as Eulers angles are both hard to work with and do
@@ -2871,4 +2957,108 @@ mod tests {
28712957
assert!(debug_str.contains("pitch"));
28722958
assert!(debug_str.contains("roll"));
28732959
}
2960+
2961+
#[test]
2962+
fn rotation_quaternion_identity() {
2963+
let identity =
2964+
unsafe { Rotation::<PlaneNed, PlaneFrd>::from_quaternion(1.0, 0.0, 0.0, 0.0) };
2965+
let expected = unsafe { Rotation::<PlaneNed, PlaneFrd>::identity() };
2966+
assert_relative_eq!(identity, expected);
2967+
}
2968+
2969+
#[test]
2970+
fn rotation_quaternion_normalization() {
2971+
let from_unit =
2972+
unsafe { Rotation::<PlaneNed, PlaneFrd>::from_quaternion(1.0, 0.0, 0.0, 0.0) };
2973+
let from_scaled =
2974+
unsafe { Rotation::<PlaneNed, PlaneFrd>::from_quaternion(5.0, 0.0, 0.0, 0.0) };
2975+
assert_relative_eq!(from_unit, from_scaled);
2976+
}
2977+
2978+
#[test]
2979+
fn rotation_quaternion_transform_equivalence() {
2980+
let yaw = d(90.);
2981+
let pitch = d(45.);
2982+
let roll = d(5.);
2983+
2984+
let from_angles = unsafe {
2985+
Rotation::<PlaneNed, PlaneFrd>::tait_bryan_builder()
2986+
.yaw(yaw)
2987+
.pitch(pitch)
2988+
.roll(roll)
2989+
.build()
2990+
};
2991+
2992+
let (w, i, j, k) = from_angles.to_quaternion();
2993+
let from_quat = unsafe { Rotation::<PlaneNed, PlaneFrd>::from_quaternion(w, i, j, k) };
2994+
2995+
let point = coordinate!(n = m(100.), e = m(50.), d = m(25.); in PlaneNed);
2996+
2997+
assert_relative_eq!(from_angles.transform(point), from_quat.transform(point),);
2998+
}
2999+
3000+
#[test]
3001+
fn manual_quaternion_construction_compound_rotate() {
3002+
// Create a rotation from the unit quaternion (1/2, 1/2, 1/2, 1/2), which cyclically maps
3003+
// the (1,0,0), (0,1,0), (0,0,1) vectors to (0,1,0), (0,0,1), and (1,0,0), respectively.
3004+
let rotation = unsafe { Rotation::<Ecef, Ecef>::from_quaternion(0.5, 0.5, 0.5, 0.5) };
3005+
3006+
let a = coordinate!(x = m(1.0), y = m(0.0), z = m(0.0); in Ecef);
3007+
let b = coordinate!(x = m(0.0), y = m(1.0), z = m(0.0); in Ecef);
3008+
let c = coordinate!(x = m(0.0), y = m(0.0), z = m(1.0); in Ecef);
3009+
3010+
let a_rot = rotation.transform(a);
3011+
let b_rot = rotation.transform(b);
3012+
let c_rot = rotation.transform(c);
3013+
3014+
assert_relative_eq!(a_rot, b);
3015+
assert_relative_eq!(b_rot, c);
3016+
assert_relative_eq!(c_rot, a);
3017+
}
3018+
3019+
#[rstest]
3020+
#[case((1.0, 0.0, 0.0, 0.0), (d(0.0), d(0.0), d(0.0)))]
3021+
#[case((0.0, 1.0, 0.0, 0.0), (d(0.0), d(0.0), d(180.0)))]
3022+
#[case((0.0, 0.0, 1.0, 0.0), (d(0.0), d(180.0), d(0.0)))]
3023+
#[case((0.0, 0.0, 0.0, 1.0), (d(180.0), d(0.0), d(0.0)))]
3024+
#[case((std::f64::consts::FRAC_1_SQRT_2, std::f64::consts::FRAC_1_SQRT_2, 0.0, 0.0), (d(0.0), d(0.0), d(90.0)))]
3025+
#[case((std::f64::consts::FRAC_1_SQRT_2, 0.0, std::f64::consts::FRAC_1_SQRT_2, 0.0), (d(0.0), d(90.0), d(0.0)))]
3026+
#[case((std::f64::consts::FRAC_1_SQRT_2, 0.0, 0.0, std::f64::consts::FRAC_1_SQRT_2), (d(90.0), d(0.0), d(0.0)))]
3027+
#[case((-std::f64::consts::FRAC_1_SQRT_2, std::f64::consts::FRAC_1_SQRT_2, 0.0, 0.0), (d(0.0), d(0.0), d(-90.0)))]
3028+
#[case((-std::f64::consts::FRAC_1_SQRT_2, 0.0, std::f64::consts::FRAC_1_SQRT_2, 0.0), (d(0.0), d(-90.0), d(0.0)))]
3029+
#[case((-std::f64::consts::FRAC_1_SQRT_2, 0.0, 0.0, std::f64::consts::FRAC_1_SQRT_2), (d(-90.0), d(0.0), d(0.0)))]
3030+
/// Checks that the parameters are handled in the correct order.
3031+
///
3032+
/// We're rotating in a right-handed cartesian coordinate system.
3033+
/// Tests 2-4 rotate by 180° around the x, y, and z axes.
3034+
/// Tests 5-7 rotate by 90° around these axes, tests 8-10 by -90° around these axes.
3035+
///
3036+
/// We provide the quaternions and the expected rotations as Tait-Bryan angles (in
3037+
/// yaw-pitch-roll order).
3038+
/// To verify that the rotations are indeed correct we map the three basis vectors (1, 0, 0),
3039+
/// (0, 1, 0), and (0, 0, 1) and having them mapped by the quaternion and the equivalent
3040+
/// rotation given by Tait-Bryan angles.
3041+
fn manual_quaternion_construction(
3042+
#[case] q: (f64, f64, f64, f64),
3043+
#[case] ypr: (Angle, Angle, Angle),
3044+
) {
3045+
let q_rotation = unsafe { Rotation::<Ecef, Ecef>::from_quaternion(q.0, q.1, q.2, q.3) };
3046+
// Negate the angles, as the rotation is a rotation _of_ the transformed vector _in_ the
3047+
// rotated system, see the `from_tait_bryan_angles` documentation for details.
3048+
let ypr_rotation = unsafe {
3049+
Rotation::<Ecef, Ecef>::tait_bryan_builder()
3050+
.yaw(-ypr.0)
3051+
.pitch(-ypr.1)
3052+
.roll(-ypr.2)
3053+
.build()
3054+
};
3055+
3056+
let x = coordinate!(x = m(1.0), y = m(0.0), z = m(0.0); in Ecef);
3057+
let y = coordinate!(x = m(0.0), y = m(1.0), z = m(0.0); in Ecef);
3058+
let z = coordinate!(x = m(0.0), y = m(0.0), z = m(1.0); in Ecef);
3059+
3060+
assert_relative_eq!(q_rotation.transform(x), ypr_rotation.transform(x));
3061+
assert_relative_eq!(q_rotation.transform(y), ypr_rotation.transform(y));
3062+
assert_relative_eq!(q_rotation.transform(z), ypr_rotation.transform(z));
3063+
}
28743064
}

0 commit comments

Comments
 (0)