use bevy::ecs::system::ParamSet;
use bevy::prelude::*;
use glam::DVec3;
use std::collections::HashMap;
use astrodyn::{
propagate_state_via_storage, KinematicEdge, KinematicNodeState, MassStorage, Planet,
};
use crate::components::{
KinematicChildC, MassChildOf, MassPropertiesC, RotationalStateC, StructuralTransformC,
TranslationalStateC,
};
use crate::mass_tree::MassTreeView;
#[allow(clippy::type_complexity)]
pub fn propagate_state_from_root_system<P: Planet>(
mass_q: Query<(Entity, &MassPropertiesC)>,
parents_q: Query<(Entity, &MassChildOf)>,
kinematic_q: Query<Entity, With<KinematicChildC>>,
names_q: Query<&Name>,
struct_q: Query<&StructuralTransformC>,
mut state_qs: ParamSet<(
Query<(&RotationalStateC, &TranslationalStateC<P>)>,
Query<(&mut RotationalStateC, &mut TranslationalStateC<P>), With<KinematicChildC>>,
)>,
) {
if parents_q.is_empty() {
return;
}
let view = MassTreeView::from_queries(&mass_q, &parents_q, &names_q);
if view.is_empty() {
return;
}
let mut nodes: HashMap<Entity, KinematicNodeState> = HashMap::with_capacity(view.len());
{
let read_q = state_qs.p0();
for entity in view.iter_entities() {
let (rot_untyped, trans_typed) = match read_q.get(entity) {
Ok((r, t)) => (
astrodyn::typed_bridge::rot_typed_to_raw(&r.0),
t.0.relabel_to::<astrodyn::MassNode>(),
),
Err(_) => Default::default(),
};
let t_struct_body = struct_q
.get(entity)
.map_or(glam::DMat3::IDENTITY, |s| *s.0.matrix_ref());
let composite_in_struct = mass_q
.get(entity)
.map(|(_, m)| m.0.center_of_mass.raw_si())
.unwrap_or(DVec3::ZERO);
nodes.insert(
entity,
KinematicNodeState {
rot: rot_untyped,
trans: trans_typed,
t_struct_body,
composite_in_struct,
},
);
}
}
let mut edges: HashMap<Entity, KinematicEdge> = HashMap::with_capacity(view.len());
for (child, link) in parents_q.iter() {
edges.insert(
child,
KinematicEdge {
t_parent_child: link.t_parent_child,
offset_in_pstr: link.offset,
},
);
}
let derived = propagate_state_via_storage(&view, &nodes, &edges);
let kinematic_set: std::collections::HashSet<Entity> = kinematic_q.iter().collect();
let mut writeback_q = state_qs.p1();
for (entity, state) in &derived {
if MassStorage::parent(&view, *entity).is_none() {
continue;
}
if kinematic_set.contains(entity) {
if let Ok((mut rot_c, mut trans_c)) = writeback_q.get_mut(*entity) {
rot_c.0 = astrodyn::typed_bridge::rot_raw_to_self_ref(&state.rot);
trans_c.0 = state.trans.relabel_to::<astrodyn::PlanetInertial<P>>();
}
}
}
}
#[allow(clippy::type_complexity)]
pub fn propagate_state_from_root_post_integration_system<P: Planet>(
mass_q: Query<(Entity, &MassPropertiesC)>,
parents_q: Query<(Entity, &MassChildOf)>,
kinematic_q: Query<Entity, With<KinematicChildC>>,
names_q: Query<&Name>,
struct_q: Query<&StructuralTransformC>,
state_qs: ParamSet<(
Query<(&RotationalStateC, &TranslationalStateC<P>)>,
Query<(&mut RotationalStateC, &mut TranslationalStateC<P>), With<KinematicChildC>>,
)>,
) {
propagate_state_from_root_system::<P>(
mass_q,
parents_q,
kinematic_q,
names_q,
struct_q,
state_qs,
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::components::{
DynamicsConfigC, ExternalForceC, ExternalTorqueC, FrameDerivativesC, TotalForceC,
};
use crate::mass_tree::composite_mass_system;
use crate::wrench::wrench_aggregation_system;
use astrodyn::{MassProperties, RotationalState};
use glam::{DMat3, DVec3};
fn add_test_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app
}
fn run_pipeline(app: &mut App) {
app.add_systems(
Update,
(
composite_mass_system,
propagate_state_from_root_system::<astrodyn::Earth>.after(composite_mass_system),
wrench_aggregation_system
.after(propagate_state_from_root_system::<astrodyn::Earth>),
),
);
app.update();
}
fn jeod_rot_z(angle: f64) -> DMat3 {
astrodyn::JeodQuat::left_quat_from_eigen_rotation(angle, DVec3::Z)
.left_quat_to_transformation()
}
#[test]
fn rotated_attach_child_tracks_parent_through_link() {
let mut app = add_test_app();
let parent_q = astrodyn::JeodQuat::left_quat_from_eigen_rotation(
std::f64::consts::FRAC_PI_3,
DVec3::Z,
);
let parent_omega = DVec3::new(0.0, 0.0, 1e-3);
let parent_pos = DVec3::new(7e6, 0.0, 0.0);
let parent_vel = DVec3::new(0.0, 7500.0, 0.0);
let parent_rot = RotationalState {
quaternion: parent_q,
ang_vel_body: parent_omega,
};
let parent_trans = astrodyn::TranslationalState {
position: parent_pos,
velocity: parent_vel,
};
let parent = app
.world_mut()
.spawn((
Name::new("rotated_parent"),
MassPropertiesC::from(astrodyn::typed_bridge::mass_raw_to_self_ref(
&(MassProperties::new(10.0)),
)),
RotationalStateC::from(astrodyn::typed_bridge::rot_raw_to_self_ref(&(parent_rot))),
TranslationalStateC::<astrodyn::Earth>::from_untyped(parent_trans),
TotalForceC::default(),
FrameDerivativesC::default(),
DynamicsConfigC::default(),
ExternalForceC::default(),
ExternalTorqueC::default(),
))
.id();
let t_pc = jeod_rot_z(std::f64::consts::PI / 6.0);
let offset = DVec3::new(1.0, 0.0, 0.0);
let child = app
.world_mut()
.spawn((
Name::new("kinematic_child"),
MassPropertiesC::from(astrodyn::typed_bridge::mass_raw_to_self_ref(
&(MassProperties::new(5.0)),
)),
MassChildOf::with_rotation(parent, offset, t_pc),
RotationalStateC::default(),
TranslationalStateC::<astrodyn::Earth>::default(),
TotalForceC::default(),
FrameDerivativesC::default(),
DynamicsConfigC::default(),
ExternalForceC::default(),
ExternalTorqueC::default(),
))
.id();
run_pipeline(&mut app);
app.update();
let child_rot = app.world().get::<RotationalStateC>(child).unwrap();
let child_trans = app
.world()
.get::<TranslationalStateC<astrodyn::Earth>>(child)
.unwrap();
let parent_t_ib = parent_q.left_quat_to_transformation();
let expected_child_t = t_pc * parent_t_ib;
let actual_child_t = child_rot
.0
.q_inertial_body
.as_witness()
.left_quat_to_transformation();
let mat_diff = (actual_child_t.x_axis - expected_child_t.x_axis).length()
+ (actual_child_t.y_axis - expected_child_t.y_axis).length()
+ (actual_child_t.z_axis - expected_child_t.z_axis).length();
assert!(
mat_diff < 1e-10,
"child T_inertial_body mismatch: expected {expected_child_t:?}, got {actual_child_t:?}"
);
let parent_composite_in_pstr = DVec3::new(1.0 / 3.0, 0.0, 0.0);
let pcm_to_ccm = offset - parent_composite_in_pstr;
let expected_offset_inertial = parent_t_ib.transpose() * pcm_to_ccm;
let expected_child_pos = parent_pos + expected_offset_inertial;
let pos_err = (child_trans.0.position.raw_si() - expected_child_pos).length();
assert!(
pos_err < 1e-9,
"child position mismatch: expected {expected_child_pos:?}, got {:?}",
child_trans.0.position.raw_si()
);
let omega_inertial = parent_t_ib.transpose() * parent_omega;
let expected_child_vel = parent_vel + omega_inertial.cross(expected_offset_inertial);
let vel_err = (child_trans.0.velocity.raw_si() - expected_child_vel).length();
assert!(
vel_err < 1e-9,
"child velocity mismatch: expected {expected_child_vel:?}, got {:?}",
child_trans.0.velocity.raw_si()
);
}
#[test]
fn three_body_chain_propagates_through_middle_to_leaf() {
let mut app = add_test_app();
let parent_pos = DVec3::new(7e6, 0.0, 0.0);
let parent_vel = DVec3::ZERO;
let parent_trans = astrodyn::TranslationalState {
position: parent_pos,
velocity: parent_vel,
};
let root = app
.world_mut()
.spawn((
Name::new("root"),
MassPropertiesC::from(astrodyn::typed_bridge::mass_raw_to_self_ref(
&(MassProperties::new(10.0)),
)),
RotationalStateC::default(),
TranslationalStateC::<astrodyn::Earth>::from_untyped(parent_trans),
TotalForceC::default(),
FrameDerivativesC::default(),
DynamicsConfigC::default(),
ExternalForceC::default(),
ExternalTorqueC::default(),
))
.id();
let t_pc = jeod_rot_z(std::f64::consts::PI / 6.0);
let zero = DVec3::ZERO;
let mid = app
.world_mut()
.spawn((
Name::new("mid"),
MassPropertiesC::from(astrodyn::typed_bridge::mass_raw_to_self_ref(
&(MassProperties::new(5.0)),
)),
MassChildOf::with_rotation(root, zero, t_pc),
RotationalStateC::default(),
TranslationalStateC::<astrodyn::Earth>::default(),
TotalForceC::default(),
FrameDerivativesC::default(),
DynamicsConfigC::default(),
ExternalForceC::default(),
ExternalTorqueC::default(),
))
.id();
let leaf = app
.world_mut()
.spawn((
Name::new("leaf"),
MassPropertiesC::from(astrodyn::typed_bridge::mass_raw_to_self_ref(
&(MassProperties::new(2.0)),
)),
MassChildOf::with_rotation(mid, zero, t_pc),
RotationalStateC::default(),
TranslationalStateC::<astrodyn::Earth>::default(),
TotalForceC::default(),
FrameDerivativesC::default(),
DynamicsConfigC::default(),
ExternalForceC::default(),
ExternalTorqueC::default(),
))
.id();
run_pipeline(&mut app);
app.update();
let leaf_rot = app.world().get::<RotationalStateC>(leaf).unwrap();
let leaf_trans = app
.world()
.get::<TranslationalStateC<astrodyn::Earth>>(leaf)
.unwrap();
let expected_leaf_t = t_pc * t_pc;
let actual_leaf_t = leaf_rot
.0
.q_inertial_body
.as_witness()
.left_quat_to_transformation();
let mat_diff = (actual_leaf_t.x_axis - expected_leaf_t.x_axis).length()
+ (actual_leaf_t.y_axis - expected_leaf_t.y_axis).length()
+ (actual_leaf_t.z_axis - expected_leaf_t.z_axis).length();
assert!(
mat_diff < 1e-10,
"leaf T_inertial_body mismatch: expected {expected_leaf_t:?}, got {actual_leaf_t:?}"
);
let pos_err = (leaf_trans.0.position.raw_si() - parent_pos).length();
assert!(
pos_err < 1e-9,
"leaf position should equal root position with zero offsets: \
expected {parent_pos:?}, got {:?}",
leaf_trans.0.position.raw_si()
);
let mid_pos = app
.world()
.get::<TranslationalStateC<astrodyn::Earth>>(mid)
.unwrap()
.0
.position
.raw_si();
let mid_pos_err = (mid_pos - parent_pos).length();
assert!(
mid_pos_err < 1e-9,
"mid position should equal root position with zero offsets: got {mid_pos:?}"
);
}
#[test]
fn rotated_chain_with_propagation_succeeds() {
let mut app = add_test_app();
let parent_q = astrodyn::JeodQuat::left_quat_from_eigen_rotation(
std::f64::consts::FRAC_PI_4,
DVec3::Z,
);
let parent_rot = RotationalState {
quaternion: parent_q,
ang_vel_body: DVec3::ZERO,
};
let parent = app
.world_mut()
.spawn((
Name::new("rotated_parent"),
MassPropertiesC::from(astrodyn::typed_bridge::mass_raw_to_self_ref(
&(MassProperties::new(10.0)),
)),
RotationalStateC::from(astrodyn::typed_bridge::rot_raw_to_self_ref(&(parent_rot))),
TranslationalStateC::<astrodyn::Earth>::default(),
TotalForceC::default(),
FrameDerivativesC::default(),
DynamicsConfigC::default(),
ExternalForceC::default(),
ExternalTorqueC::default(),
))
.id();
let t_pc = DMat3::from_cols(
DVec3::new(0.0, 1.0, 0.0),
DVec3::new(-1.0, 0.0, 0.0),
DVec3::new(0.0, 0.0, 1.0),
);
let child = app
.world_mut()
.spawn((
Name::new("kinematic_child"),
MassPropertiesC::from(astrodyn::typed_bridge::mass_raw_to_self_ref(
&(MassProperties::new(5.0)),
)),
MassChildOf::with_rotation(parent, DVec3::new(1.0, 0.0, 0.0), t_pc),
RotationalStateC::default(),
TranslationalStateC::<astrodyn::Earth>::default(),
TotalForceC::default(),
FrameDerivativesC::default(),
DynamicsConfigC::default(),
ExternalForceC::default(),
ExternalTorqueC::default(),
))
.id();
run_pipeline(&mut app);
app.update();
let parent_t_ib = parent_q.left_quat_to_transformation();
let expected = t_pc * parent_t_ib;
let child_t = app
.world()
.get::<RotationalStateC>(child)
.unwrap()
.0
.q_inertial_body
.as_witness()
.left_quat_to_transformation();
let diff = (child_t.x_axis - expected.x_axis).length()
+ (child_t.y_axis - expected.y_axis).length()
+ (child_t.z_axis - expected.z_axis).length();
assert!(
diff < 1e-10,
"propagated child attitude must equal t_pc · parent_T; got diff {diff}"
);
}
}