use core::marker::PhantomData;
use glam::DVec3;
use crate::aliases::AngularVelocity;
use crate::frame::{BodyFrame, Vehicle};
use crate::frame_transform::FrameTransform;
use crate::quat::{JeodQuat, LeftTransform, NormalizedQuat, ScalarFirst, NORM_LIMIT};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BodyAttitude<V: Vehicle> {
q: NormalizedQuat<ScalarFirst, LeftTransform>,
_v: PhantomData<V>,
}
impl<V: Vehicle> BodyAttitude<V> {
#[inline]
pub fn identity() -> Self {
Self {
q: NormalizedQuat::new(JeodQuat::identity()).expect("identity quaternion is unit-norm"),
_v: PhantomData,
}
}
#[inline]
pub fn from_witness(q: NormalizedQuat<ScalarFirst, LeftTransform>) -> Self {
Self { q, _v: PhantomData }
}
#[inline]
pub fn from_jeod_quat(q: JeodQuat) -> Self {
let witnessed = NormalizedQuat::new(q).unwrap_or_else(|err| {
panic!("BodyAttitude::from_jeod_quat: quaternion is not unit-norm: {err}")
});
Self::from_witness(witnessed)
}
#[inline]
pub const fn as_witness(&self) -> &NormalizedQuat<ScalarFirst, LeftTransform> {
&self.q
}
#[inline]
pub fn to_jeod_quat(&self) -> JeodQuat {
self.q.inner()
}
pub fn advance_under_body_rate(self, ang_vel: AngularVelocity<BodyFrame<V>>, dt: f64) -> Self {
let omega: DVec3 = ang_vel.raw_si();
let omega_norm = omega.length();
if omega_norm == 0.0 {
return self;
}
let half_angle = omega_norm * dt * 0.5;
let s = half_angle.sin() / omega_norm;
let c = half_angle.cos();
let dq = JeodQuat::new(c, -omega.x * s, -omega.y * s, -omega.z * s);
let mut q_new = dq.multiply(&self.q.inner());
normalize_integ(&mut q_new);
let witnessed = NormalizedQuat::new(q_new)
.expect("normalize_integ leaves quaternion within unit-norm tolerance");
Self {
q: witnessed,
_v: PhantomData,
}
}
pub fn compose_with<W: Vehicle>(
self,
rel: FrameTransform<BodyFrame<V>, BodyFrame<W>>,
) -> BodyAttitude<W> {
let composed = rel.quat().inner().multiply(&self.q.inner());
let witnessed = NormalizedQuat::renormalize(composed)
.expect("composition of two unit quaternions is finite and non-zero");
BodyAttitude {
q: witnessed,
_v: PhantomData,
}
}
}
impl<V: Vehicle> Default for BodyAttitude<V> {
#[inline]
fn default() -> Self {
Self::identity()
}
}
#[inline]
fn normalize_integ(q: &mut JeodQuat) {
let qmagsq = q.norm_sq();
assert!(
qmagsq > 0.0,
"BodyAttitude::advance_under_body_rate produced zero-magnitude quaternion"
);
let diff1 = 1.0 - qmagsq;
let fact = if diff1 > -NORM_LIMIT && diff1 < NORM_LIMIT {
2.0 / (1.0 + qmagsq)
} else {
1.0 / qmagsq.sqrt()
};
q.data[0] *= fact;
q.data[1] *= fact;
q.data[2] *= fact;
q.data[3] *= fact;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::frame::SelfRef;
use crate::qty3::Qty3;
const TOL: f64 = 1e-14;
#[test]
fn identity_is_unit_quat() {
let a: BodyAttitude<SelfRef> = BodyAttitude::identity();
let q = a.to_jeod_quat();
assert!((q.norm() - 1.0).abs() < TOL);
assert_eq!(q.scalar(), 1.0);
assert_eq!(q.vector(), DVec3::ZERO);
}
#[test]
fn zero_omega_is_no_op() {
let q_init = JeodQuat::left_quat_from_eigen_rotation(0.5, DVec3::Z);
let a: BodyAttitude<SelfRef> =
BodyAttitude::from_witness(NormalizedQuat::new(q_init).unwrap());
let omega: AngularVelocity<BodyFrame<SelfRef>> = Qty3::zero();
let advanced = a.advance_under_body_rate(omega, 1.0);
assert_eq!(advanced.to_jeod_quat().data, q_init.data);
}
#[test]
fn advance_uses_left_multiply_not_right() {
let q_init = JeodQuat::left_quat_from_eigen_rotation(0.5, DVec3::Z);
let omega_vec = DVec3::new(0.0, 0.001_138_5, 0.0);
let dt = 0.02_f64;
let attitude: BodyAttitude<SelfRef> =
BodyAttitude::from_witness(NormalizedQuat::new(q_init).unwrap());
let omega: AngularVelocity<BodyFrame<SelfRef>> = Qty3::from_raw_si(omega_vec);
let q_out = attitude.advance_under_body_rate(omega, dt).to_jeod_quat();
let omega_norm = omega_vec.length();
let half = omega_norm * dt * 0.5;
let s = half.sin() / omega_norm;
let c = half.cos();
let dq = JeodQuat::new(c, -omega_vec.x * s, -omega_vec.y * s, -omega_vec.z * s);
let mut expected_left = dq.multiply(&q_init);
normalize_integ(&mut expected_left);
let mut expected_right = q_init.multiply(&dq);
normalize_integ(&mut expected_right);
for i in 0..4 {
assert!(
(q_out.data[i] - expected_left.data[i]).abs() < TOL,
"advance_under_body_rate must match LEFT-multiply convention at component {i}"
);
}
let mut diverged = false;
for i in 0..4 {
if (expected_left.data[i] - expected_right.data[i]).abs() > 1e-9 {
diverged = true;
break;
}
}
assert!(
diverged,
"test setup is degenerate: left and right multiply produce equal results"
);
}
#[test]
fn small_steps_match_one_shot() {
let q_init = JeodQuat::left_quat_from_eigen_rotation(0.3, DVec3::Y);
let omega_vec = DVec3::new(0.001, -0.0005, 0.0008);
let total_dt = 1.0_f64;
let n = 100_usize;
let small_dt = total_dt / n as f64;
let mut q_iter: BodyAttitude<SelfRef> =
BodyAttitude::from_witness(NormalizedQuat::new(q_init).unwrap());
let omega: AngularVelocity<BodyFrame<SelfRef>> = Qty3::from_raw_si(omega_vec);
for _ in 0..n {
q_iter = q_iter.advance_under_body_rate(omega, small_dt);
}
let q_one_shot: BodyAttitude<SelfRef> =
BodyAttitude::from_witness(NormalizedQuat::new(q_init).unwrap())
.advance_under_body_rate(omega, total_dt);
let a = q_iter.to_jeod_quat();
let b = q_one_shot.to_jeod_quat();
for i in 0..4 {
assert!(
(a.data[i] - b.data[i]).abs() < 1e-6,
"small-step vs one-shot diverge at component {i}: {} vs {}",
a.data[i],
b.data[i]
);
}
}
}