use crate::quaternion::JeodQuat;
use crate::types::DMat3;
use astrodyn_quantities::quat::{LeftTransform, NormalizedQuat, ScalarFirst};
use std::f64::consts::{FRAC_PI_2, PI};
use uom::si::angle::radian;
use uom::si::f64::Angle;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(usize)]
pub enum EulerSequence {
XYZ = 0,
XZY = 1,
YZX = 2,
YXZ = 3,
ZXY = 4,
ZYX = 5,
XYX = 6,
XZX = 7,
YZY = 8,
YXY = 9,
ZXZ = 10,
ZYZ = 11,
}
pub const ALL_SEQUENCES: [EulerSequence; 12] = [
EulerSequence::XYZ,
EulerSequence::XZY,
EulerSequence::YZX,
EulerSequence::YXZ,
EulerSequence::ZXY,
EulerSequence::ZYX,
EulerSequence::XYX,
EulerSequence::XZX,
EulerSequence::YZY,
EulerSequence::YXY,
EulerSequence::ZXZ,
EulerSequence::ZYZ,
];
struct EulerInfo {
indices: [usize; 3],
alternate_x: usize,
alternate_z: usize,
is_even_permutation: bool,
is_aerodynamics_sequence: bool,
}
static EULER_INFO: [EulerInfo; 12] = [
EulerInfo {
indices: [0, 1, 2],
alternate_x: 0,
alternate_z: 2,
is_even_permutation: true,
is_aerodynamics_sequence: true,
}, EulerInfo {
indices: [0, 2, 1],
alternate_x: 0,
alternate_z: 1,
is_even_permutation: false,
is_aerodynamics_sequence: true,
}, EulerInfo {
indices: [1, 2, 0],
alternate_x: 1,
alternate_z: 0,
is_even_permutation: true,
is_aerodynamics_sequence: true,
}, EulerInfo {
indices: [1, 0, 2],
alternate_x: 1,
alternate_z: 2,
is_even_permutation: false,
is_aerodynamics_sequence: true,
}, EulerInfo {
indices: [2, 0, 1],
alternate_x: 2,
alternate_z: 1,
is_even_permutation: true,
is_aerodynamics_sequence: true,
}, EulerInfo {
indices: [2, 1, 0],
alternate_x: 2,
alternate_z: 0,
is_even_permutation: false,
is_aerodynamics_sequence: true,
}, EulerInfo {
indices: [0, 1, 0],
alternate_x: 2,
alternate_z: 2,
is_even_permutation: true,
is_aerodynamics_sequence: false,
}, EulerInfo {
indices: [0, 2, 0],
alternate_x: 1,
alternate_z: 1,
is_even_permutation: false,
is_aerodynamics_sequence: false,
}, EulerInfo {
indices: [1, 2, 1],
alternate_x: 0,
alternate_z: 0,
is_even_permutation: true,
is_aerodynamics_sequence: false,
}, EulerInfo {
indices: [1, 0, 1],
alternate_x: 2,
alternate_z: 2,
is_even_permutation: false,
is_aerodynamics_sequence: false,
}, EulerInfo {
indices: [2, 0, 2],
alternate_x: 1,
alternate_z: 1,
is_even_permutation: true,
is_aerodynamics_sequence: false,
}, EulerInfo {
indices: [2, 1, 2],
alternate_x: 0,
alternate_z: 0,
is_even_permutation: false,
is_aerodynamics_sequence: false,
}, ];
const GIMBAL_LOCK_THRESHOLD: f64 = 1e-13;
#[inline]
fn t(trans: &DMat3, row: usize, col: usize) -> f64 {
trans.col(col)[row]
}
fn compute_quaternion_from_euler_angles_impl(
angles: [f64; 3],
sequence: EulerSequence,
) -> JeodQuat {
let info = &EULER_INFO[sequence as usize];
let axes = &info.indices;
let mut q = [JeodQuat::identity(); 3];
for ii in 0..3 {
let htheta = 0.5 * angles[ii];
let cosht = htheta.cos();
let sinht = htheta.sin();
let mut data = [0.0_f64; 4];
data[0] = cosht;
data[1 + axes[ii]] = -sinht; q[ii] = JeodQuat::from_array(data);
}
let q21 = q[2].multiply(&q[1]);
let mut result = q21.multiply(&q[0]);
result.normalize();
result
}
fn compute_matrix_from_euler_angles_impl(angles: [f64; 3], sequence: EulerSequence) -> DMat3 {
let info = &EULER_INFO[sequence as usize];
let axes = &info.indices;
let mut m = [[[0.0_f64; 3]; 3]; 3];
for ii in 0..3 {
let sin_theta = angles[ii].sin();
let cos_theta = angles[ii].cos();
match axes[ii] {
0 => {
m[ii][0][0] = 1.0;
m[ii][1][1] = cos_theta;
m[ii][1][2] = sin_theta;
m[ii][2][1] = -sin_theta;
m[ii][2][2] = cos_theta;
}
1 => {
m[ii][1][1] = 1.0;
m[ii][0][0] = cos_theta;
m[ii][0][2] = -sin_theta;
m[ii][2][0] = sin_theta;
m[ii][2][2] = cos_theta;
}
2 => {
m[ii][2][2] = 1.0;
m[ii][0][0] = cos_theta;
m[ii][0][1] = sin_theta;
m[ii][1][0] = -sin_theta;
m[ii][1][1] = cos_theta;
}
_ => unreachable!(),
}
}
let to_dmat3 = |arr: &[[f64; 3]; 3]| -> DMat3 {
crate::types::mat3_from_rows(
glam::DVec3::new(arr[0][0], arr[0][1], arr[0][2]),
glam::DVec3::new(arr[1][0], arr[1][1], arr[1][2]),
glam::DVec3::new(arr[2][0], arr[2][1], arr[2][2]),
)
};
let m0 = to_dmat3(&m[0]);
let m1 = to_dmat3(&m[1]);
let m2 = to_dmat3(&m[2]);
let m21 = m2 * m1;
m21 * m0
}
fn compute_euler_angles_from_matrix_impl(trans: &DMat3, sequence: EulerSequence) -> [f64; 3] {
let info = &EULER_INFO[sequence as usize];
let mut theta_val = t(trans, info.indices[2], info.indices[0]);
let mut sin_phi = t(trans, info.indices[2], info.indices[1]);
let mut cos_phi = t(trans, info.indices[2], info.alternate_z);
let mut sin_psi = t(trans, info.indices[1], info.indices[0]);
let mut cos_psi = t(trans, info.alternate_x, info.indices[0]);
let alt_theta_val1 = (sin_phi * sin_phi + cos_phi * cos_phi).sqrt();
let alt_theta_val2 = (sin_psi * sin_psi + cos_psi * cos_psi).sqrt();
let alt_theta_val = 0.5 * (alt_theta_val1 + alt_theta_val2);
if info.is_aerodynamics_sequence && !info.is_even_permutation {
theta_val = -theta_val;
}
let theta;
if alt_theta_val < theta_val.abs() {
let alt_theta = alt_theta_val.asin();
if info.is_aerodynamics_sequence {
if theta_val < 0.0 {
theta = -FRAC_PI_2 + alt_theta;
} else {
theta = FRAC_PI_2 - alt_theta;
}
} else if theta_val < 0.0 {
theta = PI - alt_theta;
} else {
theta = alt_theta;
}
} else if info.is_aerodynamics_sequence {
theta = theta_val.asin();
} else {
theta = theta_val.acos();
}
let phi;
let psi;
if alt_theta_val > GIMBAL_LOCK_THRESHOLD {
if info.is_aerodynamics_sequence {
if info.is_even_permutation {
sin_phi = -sin_phi;
sin_psi = -sin_psi;
}
} else {
if info.is_even_permutation {
cos_phi = -cos_phi;
} else {
cos_psi = -cos_psi;
}
}
phi = sin_phi.atan2(cos_phi);
psi = sin_psi.atan2(cos_psi);
} else {
let gl_sin_phi = t(trans, info.indices[1], info.alternate_z);
let gl_cos_phi = t(trans, info.indices[1], info.indices[1]);
let corrected_sin_phi = if !info.is_even_permutation {
-gl_sin_phi
} else {
gl_sin_phi
};
phi = corrected_sin_phi.atan2(gl_cos_phi);
psi = 0.0;
}
[phi, theta, psi]
}
pub fn compute_quaternion_from_euler_angles_typed(
angles: [Angle; 3],
sequence: EulerSequence,
) -> NormalizedQuat<ScalarFirst, LeftTransform> {
let radians = [
angles[0].get::<radian>(),
angles[1].get::<radian>(),
angles[2].get::<radian>(),
];
let q = compute_quaternion_from_euler_angles_impl(radians, sequence);
NormalizedQuat::new(q).unwrap_or_else(|err| {
panic!("compute_quaternion_from_euler_angles_impl returns a normalized quaternion: {err}")
})
}
pub fn compute_matrix_from_euler_angles_typed(
angles: [Angle; 3],
sequence: EulerSequence,
) -> DMat3 {
let radians = [
angles[0].get::<radian>(),
angles[1].get::<radian>(),
angles[2].get::<radian>(),
];
compute_matrix_from_euler_angles_impl(radians, sequence)
}
pub fn compute_euler_angles_from_matrix_typed(
trans: &DMat3,
sequence: EulerSequence,
) -> [Angle; 3] {
let [phi, theta, psi] = compute_euler_angles_from_matrix_impl(trans, sequence);
[
Angle::new::<radian>(phi),
Angle::new::<radian>(theta),
Angle::new::<radian>(psi),
]
}
pub fn quaternion_to_matrix_normalized(q: NormalizedQuat<ScalarFirst, LeftTransform>) -> DMat3 {
q.left_quat_to_transformation()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::{approx_eq_f64, approx_eq_mat3};
use glam::DVec3;
use uom::si::angle::radian;
use compute_euler_angles_from_matrix_impl as compute_euler_angles_from_matrix;
use compute_matrix_from_euler_angles_impl as compute_matrix_from_euler_angles;
use compute_quaternion_from_euler_angles_impl as compute_quaternion_from_euler_angles;
const TOL: f64 = 1e-14;
fn approx_eq_angles(a: &[f64; 3], b: &[f64; 3], tol: f64) -> bool {
(a[0] - b[0]).abs() < tol && (a[1] - b[1]).abs() < tol && (a[2] - b[2]).abs() < tol
}
#[test]
fn round_trip_all_sequences() {
let test_angles_deg = [30.0_f64, 45.0, 60.0];
let test_angles_rad = [
test_angles_deg[0].to_radians(),
test_angles_deg[1].to_radians(),
test_angles_deg[2].to_radians(),
];
for &seq in &ALL_SEQUENCES {
let mat = compute_matrix_from_euler_angles(test_angles_rad, seq);
let extracted = compute_euler_angles_from_matrix(&mat, seq);
let mat2 = compute_matrix_from_euler_angles(extracted, seq);
assert!(
approx_eq_mat3(&mat, &mat2, TOL),
"Round-trip matrix mismatch for {:?}:\n original angles = {:?}\n extracted = {:?}\n max element diff = {:e}",
seq,
test_angles_rad,
extracted,
max_mat_diff(&mat, &mat2),
);
if EULER_INFO[seq as usize].is_aerodynamics_sequence {
assert!(
approx_eq_angles(&test_angles_rad, &extracted, TOL),
"Round-trip angle mismatch for {:?}:\n input = {:?}\n output = {:?}",
seq,
test_angles_rad,
extracted,
);
}
}
}
#[test]
fn known_angles_xyz() {
let angles = [
30.0_f64.to_radians(),
45.0_f64.to_radians(),
60.0_f64.to_radians(),
];
let mat = compute_matrix_from_euler_angles(angles, EulerSequence::XYZ);
let extracted = compute_euler_angles_from_matrix(&mat, EulerSequence::XYZ);
assert!(
approx_eq_angles(&angles, &extracted, TOL),
"XYZ known angles mismatch:\n input = {:?}\n output = {:?}",
angles,
extracted,
);
}
#[test]
fn gimbal_lock_xyz() {
let angles = [0.3, FRAC_PI_2, 0.0]; let mat = compute_matrix_from_euler_angles(angles, EulerSequence::XYZ);
let extracted = compute_euler_angles_from_matrix(&mat, EulerSequence::XYZ);
for (i, val) in extracted.iter().enumerate() {
assert!(!val.is_nan(), "Gimbal lock XYZ: angle[{}] is NaN", i);
}
let mat2 = compute_matrix_from_euler_angles(extracted, EulerSequence::XYZ);
assert!(
approx_eq_mat3(&mat, &mat2, 1e-12),
"Gimbal lock XYZ: matrix mismatch\n extracted = {:?}\n max diff = {:e}",
extracted,
max_mat_diff(&mat, &mat2),
);
}
#[test]
fn gimbal_lock_negative_theta() {
let angles = [0.7, -FRAC_PI_2, 0.0];
let mat = compute_matrix_from_euler_angles(angles, EulerSequence::XYZ);
let extracted = compute_euler_angles_from_matrix(&mat, EulerSequence::XYZ);
for (i, val) in extracted.iter().enumerate() {
assert!(!val.is_nan(), "Gimbal lock -90 XYZ: angle[{}] is NaN", i);
}
let mat2 = compute_matrix_from_euler_angles(extracted, EulerSequence::XYZ);
assert!(
approx_eq_mat3(&mat, &mat2, 1e-12),
"Gimbal lock -90 XYZ: matrix mismatch\n max diff = {:e}",
max_mat_diff(&mat, &mat2),
);
}
#[test]
fn gimbal_lock_astro_zxz() {
let angles = [0.5, 0.0, 0.0];
let mat = compute_matrix_from_euler_angles(angles, EulerSequence::ZXZ);
let extracted = compute_euler_angles_from_matrix(&mat, EulerSequence::ZXZ);
for (i, val) in extracted.iter().enumerate() {
assert!(!val.is_nan(), "Gimbal lock ZXZ: angle[{}] is NaN", i);
}
let mat2 = compute_matrix_from_euler_angles(extracted, EulerSequence::ZXZ);
assert!(
approx_eq_mat3(&mat, &mat2, 1e-12),
"Gimbal lock ZXZ: matrix mismatch\n max diff = {:e}",
max_mat_diff(&mat, &mat2),
);
}
#[test]
fn identity_matrix_all_sequences() {
let identity = DMat3::IDENTITY;
for &seq in &ALL_SEQUENCES {
let angles = compute_euler_angles_from_matrix(&identity, seq);
for (i, val) in angles.iter().enumerate() {
assert!(
approx_eq_f64(*val, 0.0, 1e-13),
"Identity -> {:?}: angle[{}] = {:e}, expected 0.0",
seq,
i,
val,
);
}
}
}
#[test]
fn matrix_quaternion_consistency() {
let test_angles = [
[0.3, 0.5, 0.7],
[1.0, -0.4, 2.1],
[0.1, 0.2, 0.3],
[-0.5, 0.8, -1.2],
];
for &seq in &ALL_SEQUENCES {
for angles in &test_angles {
let mat_direct = compute_matrix_from_euler_angles(*angles, seq);
let quat = compute_quaternion_from_euler_angles(*angles, seq);
let mat_from_quat = quat.left_quat_to_transformation();
assert!(
approx_eq_mat3(&mat_direct, &mat_from_quat, TOL),
"Matrix/quat mismatch for {:?}, angles={:?}\n max diff = {:e}",
seq,
angles,
max_mat_diff(&mat_direct, &mat_from_quat),
);
}
}
}
#[test]
fn round_trip_varied_angles() {
let angle_sets: [[f64; 3]; 5] = [
[0.1, 0.2, 0.3],
[-0.5, 0.8, -1.2],
[1.0, -0.3, 2.5],
[0.01, 0.01, 0.01], [2.0, -1.0, 3.0], ];
for &seq in &ALL_SEQUENCES {
for angles in &angle_sets {
let mat = compute_matrix_from_euler_angles(*angles, seq);
let extracted = compute_euler_angles_from_matrix(&mat, seq);
let mat2 = compute_matrix_from_euler_angles(extracted, seq);
assert!(
approx_eq_mat3(&mat, &mat2, 1e-13),
"Varied round-trip failed for {:?}, angles={:?}\n extracted={:?}\n max diff = {:e}",
seq,
angles,
extracted,
max_mat_diff(&mat, &mat2),
);
}
}
}
fn max_mat_diff(a: &DMat3, b: &DMat3) -> f64 {
let mut max_d = 0.0_f64;
for c in 0..3 {
for r in 0..3 {
let d = (a.col(c)[r] - b.col(c)[r]).abs();
if d > max_d {
max_d = d;
}
}
}
max_d
}
#[test]
fn jeod_euler_test_vector_1e12() {
let t = DMat3::from_cols(
DVec3::new(
0.353_553_390_593_273_8,
-0.612_372_435_695_794_6,
0.707_106_781_186_547_5,
),
DVec3::new(
0.926_776_695_296_636_9,
0.126_826_484_044_322_3,
-0.353_553_390_593_273_7,
),
DVec3::new(
0.126_826_484_044_322,
0.780_330_085_889_910_6,
0.612_372_435_695_794_6,
),
);
let expected_rad = [
30.0_f64.to_radians(),
45.0_f64.to_radians(),
60.0_f64.to_radians(),
];
let extracted = compute_euler_angles_from_matrix(&t, EulerSequence::XYZ);
for i in 0..3 {
let err = (extracted[i] - expected_rad[i]).abs();
assert!(
err < 1e-12,
"JEOD XYZ angle[{i}] error {err:.4e} rad exceeds 1e-12\n \
expected: {:.15}, got: {:.15}",
expected_rad[i],
extracted[i],
);
}
let t_transpose = t.transpose();
let body_ref_expected_deg: [f64; 3] = [
-51.876_568_255_402_19,
7.286_245_187_115_636,
-69.118_790_319_646_11,
];
let body_ref_expected_rad = [
body_ref_expected_deg[0].to_radians(),
body_ref_expected_deg[1].to_radians(),
body_ref_expected_deg[2].to_radians(),
];
let body_ref_extracted = compute_euler_angles_from_matrix(&t_transpose, EulerSequence::XYZ);
for i in 0..3 {
let err = (body_ref_extracted[i] - body_ref_expected_rad[i]).abs();
assert!(
err < 1e-12,
"JEOD XYZ body_ref angle[{i}] error {err:.4e} rad exceeds 1e-12\n \
expected: {:.15}, got: {:.15}",
body_ref_expected_rad[i],
body_ref_extracted[i],
);
}
for &seq in &[
EulerSequence::XYZ,
EulerSequence::XZY,
EulerSequence::YZX,
EulerSequence::YXZ,
EulerSequence::ZXY,
EulerSequence::ZYX,
] {
let angles = compute_euler_angles_from_matrix(&t, seq);
let reconstructed = compute_matrix_from_euler_angles(angles, seq);
assert!(
approx_eq_mat3(&t, &reconstructed, 1e-14),
"JEOD matrix round-trip for {seq:?} exceeds 1e-14: diff = {:.4e}",
max_mat_diff(&t, &reconstructed),
);
}
}
fn angles_rad(phi: f64, theta: f64, psi: f64) -> [Angle; 3] {
[
Angle::new::<radian>(phi),
Angle::new::<radian>(theta),
Angle::new::<radian>(psi),
]
}
fn non_trivial_angles_for(seq: EulerSequence) -> [f64; 3] {
if EULER_INFO[seq as usize].is_aerodynamics_sequence {
[0.3_f64, 0.4, 0.5]
} else {
[0.3_f64, 0.9, 0.5]
}
}
#[test]
fn typed_roundtrip_all_sequences() {
for &seq in &ALL_SEQUENCES {
let raw = non_trivial_angles_for(seq);
let typed_in = angles_rad(raw[0], raw[1], raw[2]);
let mat = compute_matrix_from_euler_angles_typed(typed_in, seq);
let extracted = compute_euler_angles_from_matrix_typed(&mat, seq);
if EULER_INFO[seq as usize].is_aerodynamics_sequence {
for i in 0..3 {
let err = (extracted[i] - typed_in[i]).get::<radian>().abs();
assert!(
err < 1e-13,
"typed roundtrip {seq:?}: angle[{i}] err = {err:.4e} rad"
);
}
}
let mat2 = compute_matrix_from_euler_angles_typed(extracted, seq);
assert!(
approx_eq_mat3(&mat, &mat2, 1e-13),
"typed roundtrip matrix mismatch for {seq:?}: diff = {:.4e}",
{
let mut d = 0.0_f64;
for c in 0..3 {
for r in 0..3 {
d = d.max((mat.col(c)[r] - mat2.col(c)[r]).abs());
}
}
d
}
);
}
}
#[test]
fn typed_matrix_bit_identical_to_f64() {
for &seq in &ALL_SEQUENCES {
let raw = non_trivial_angles_for(seq);
let typed = angles_rad(raw[0], raw[1], raw[2]);
let mat_typed = compute_matrix_from_euler_angles_typed(typed, seq);
let mat_f64 = compute_matrix_from_euler_angles(
[
typed[0].get::<radian>(),
typed[1].get::<radian>(),
typed[2].get::<radian>(),
],
seq,
);
for c in 0..3 {
for r in 0..3 {
assert_eq!(
mat_typed.col(c)[r].to_bits(),
mat_f64.col(c)[r].to_bits(),
"{seq:?}: matrix element [{r}][{c}] differs bit-wise",
);
}
}
}
}
#[test]
fn typed_angles_bit_identical_to_f64() {
for &seq in &ALL_SEQUENCES {
let raw = non_trivial_angles_for(seq);
let mat = compute_matrix_from_euler_angles(raw, seq);
let from_typed = compute_euler_angles_from_matrix_typed(&mat, seq);
let from_f64 = compute_euler_angles_from_matrix(&mat, seq);
for i in 0..3 {
assert_eq!(
from_typed[i].get::<radian>().to_bits(),
from_f64[i].to_bits(),
"{seq:?}: angle[{i}] differs bit-wise",
);
}
}
}
#[test]
fn typed_quaternion_bit_identical_to_f64() {
for &seq in &ALL_SEQUENCES {
let raw = non_trivial_angles_for(seq);
let typed = angles_rad(raw[0], raw[1], raw[2]);
let q_typed = compute_quaternion_from_euler_angles_typed(typed, seq);
let q_f64 = compute_quaternion_from_euler_angles(raw, seq);
let inner = q_typed.inner();
for i in 0..4 {
assert_eq!(
inner.data[i].to_bits(),
q_f64.data[i].to_bits(),
"{seq:?}: quaternion data[{i}] differs bit-wise",
);
}
assert!((inner.norm() - 1.0).abs() < 1e-14);
}
}
#[test]
fn quaternion_to_matrix_normalized_matches_f64_matrix() {
for &seq in &ALL_SEQUENCES {
let raw = non_trivial_angles_for(seq);
let typed = angles_rad(raw[0], raw[1], raw[2]);
let q = compute_quaternion_from_euler_angles_typed(typed, seq);
let mat_from_quat = quaternion_to_matrix_normalized(q);
let mat_direct = compute_matrix_from_euler_angles_typed(typed, seq);
assert!(
approx_eq_mat3(&mat_from_quat, &mat_direct, 1e-14),
"{seq:?}: NormalizedQuat-gated matrix disagrees with typed Euler->matrix",
);
}
}
}