use std::any::TypeId;
use std::collections::HashSet;
use std::marker::PhantomData;
use astrodyn::{BodyAction, Planet};
use bevy::ecs::message::MessageWriter;
use bevy::prelude::*;
use crate::components::{
Abm4StateC, DynamicsConfigC, GaussJacksonStateC, MassPropertiesC, RotationalStateC,
TranslationalStateC,
};
#[derive(Resource, Debug, Default)]
pub struct RegisteredPlanetsR {
pub(crate) planets: HashSet<TypeId>,
}
impl RegisteredPlanetsR {
#[inline]
pub fn register<P: Planet>(&mut self) {
self.planets.insert(TypeId::of::<P>());
}
#[inline]
pub fn contains<P: Planet>(&self) -> bool {
self.planets.contains(&TypeId::of::<P>())
}
#[inline]
pub fn contains_type_id(&self, type_id: TypeId) -> bool {
self.planets.contains(&type_id)
}
}
#[derive(Debug, Clone)]
pub(crate) struct PendingBodyAction {
pub(crate) entity: Entity,
pub(crate) action: BodyAction,
pub(crate) name: Option<String>,
}
#[allow(clippy::large_enum_variant)]
#[derive(Message, Debug, Clone)]
pub enum BodyActionEvent {
Add {
entity: Entity,
action: BodyAction,
name: Option<String>,
planet: TypeId,
},
Remove {
name: String,
},
}
impl BodyActionEvent {
#[inline]
pub fn add(entity: Entity, action: BodyAction, name: Option<&str>) -> Self {
Self::add_for::<astrodyn::Earth>(entity, action, name)
}
#[inline]
pub fn add_for<P: Planet>(entity: Entity, action: BodyAction, name: Option<&str>) -> Self {
BodyActionEvent::Add {
entity,
action,
name: name.map(|n| n.to_string()),
planet: TypeId::of::<P>(),
}
}
#[inline]
pub fn remove(name: &str) -> Self {
BodyActionEvent::Remove {
name: name.to_string(),
}
}
}
#[derive(Resource, Debug)]
pub struct BodyActionsR<P: Planet> {
pub(crate) pending: Vec<PendingBodyAction>,
_planet: PhantomData<fn() -> P>,
}
impl<P: Planet> Default for BodyActionsR<P> {
fn default() -> Self {
Self {
pending: Vec::new(),
_planet: PhantomData,
}
}
}
pub fn body_action_intake_system<P: Planet>(
mut messages: bevy::ecs::message::MessageReader<BodyActionEvent>,
mut queue: ResMut<BodyActionsR<P>>,
) {
let this_planet = TypeId::of::<P>();
for msg in messages.read() {
match msg {
BodyActionEvent::Add {
entity,
action,
name,
planet,
} => {
if *planet != this_planet {
continue;
}
queue.pending.push(PendingBodyAction {
entity: *entity,
action: action.clone(),
name: name.clone(),
});
}
BodyActionEvent::Remove { name } => {
if name.is_empty() {
continue;
}
queue
.pending
.retain(|act| act.name.as_deref() != Some(name.as_str()));
}
}
}
}
pub fn body_action_unregistered_planet_fence_system(
mut messages: bevy::ecs::message::MessageReader<BodyActionEvent>,
registered: Res<RegisteredPlanetsR>,
) {
for msg in messages.read() {
if let BodyActionEvent::Add {
entity,
name,
planet,
..
} = msg
{
assert!(
registered.contains_type_id(*planet),
"BodyActionEvent::Add for planet TypeId {planet:?} against entity \
{entity:?} (action_name={name:?}) but no per-planet body-action \
pipeline is registered for that planet — the unified \
`Messages<BodyActionEvent>` buffer holds the entry, but no \
`body_action_intake_system::<P>` will claim it (every existing \
intake will skip it on `TypeId` mismatch) and the message will \
age out of the double-buffer with no observable effect. \
Fix: call `astrodyn_bevy::register_planet_systems::<P>(&mut app)` \
during `App` setup for the planet whose body this action targets, \
before writing `BodyActionEvent::add_for::<P>` (or use \
`BodyActionCommandsExt::add_body_action_for::<P>`, which performs \
the same registration check at the call site). `AstrodynPlugin::build` \
pre-registers `astrodyn::Earth`; additional planets must be \
registered explicitly.",
);
}
}
}
#[allow(clippy::type_complexity)]
pub fn body_action_system<P: Planet>(
mut queue: ResMut<BodyActionsR<P>>,
mut bodies: Query<
(
Option<&mut TranslationalStateC<P>>,
Option<&mut RotationalStateC>,
Option<&mut MassPropertiesC>,
Option<&mut GaussJacksonStateC>,
Option<&mut Abm4StateC>,
),
With<DynamicsConfigC>,
>,
) {
let mut idx = 0;
while idx < queue.pending.len() {
let action_ref = &queue.pending[idx];
if !action_ref.action.is_ready() {
idx += 1;
continue;
}
let action = queue.pending.remove(idx);
let (mut trans, mut rot, mut mass, mut gj, mut abm) = bodies
.get_mut(action.entity)
.unwrap_or_else(|err| {
panic!(
"BodyAction subject entity {:?} (action_name={:?}) is not a recognised vehicle entity \
(despawned, never spawned, or missing DynamicsConfigC — every dynamic body carries DynamicsConfigC). \
Spawn the entity with the dynamic-body Components before queuing a BodyAction. (bevy query error: {err:?})",
action.entity, action.name,
)
});
let mut state_mutated = false;
let mut mass_mutated = false;
if let Some(state) = action.action.apply_translational() {
let comp = trans
.as_deref_mut()
.unwrap_or_else(|| {
panic!(
"BodyAction targets translational state on entity {entity:?} (action_name={name:?}) \
but the entity has no `TranslationalStateC<{planet}>` slot, and this apply pass \
only mutates `<{planet}>`-tagged storage (the action was routed to \
`BodyActionsR<{planet}>` by its planet tag in `BodyActionEvent::Add::planet`). \
Two fixes: \
(a) if the body's integration planet really is `{planet}`, spawn it with \
`TranslationalStateC::<{planet}>` (`VehicleConfig::spawn_bevy::<{planet}>` is \
the canonical entry point); \
(b) if the body integrates against another planet `Q`, queue the action via \
`BodyActionEvent::add_for::<Q>` (or `BodyActionCommandsExt::add_body_action_for::<Q>`) \
so it lands in `BodyActionsR<Q>` and the matching `body_action_system::<Q>` \
pass mutates `TranslationalStateC<Q>` instead. Adding a wrong-planet \
translational slot to the entity is not a valid workaround — it would \
silently land the action in the wrong planet's storage.",
entity = action.entity, name = action.name, planet = std::any::type_name::<P>(),
)
});
comp.0 = astrodyn::typed_bridge::trans_raw_to_planet::<P>(&state);
state_mutated = true;
}
if let Some(state) = action.action.apply_rotational() {
let comp = rot
.as_deref_mut()
.unwrap_or_else(|| {
panic!(
"BodyAction targets rotational state on entity {:?} (action_name={:?}) but the entity has no RotationalStateC. \
Add `RotationalStateC::default()` to the entity before queuing this action.",
action.entity, action.name,
)
});
comp.0 = astrodyn::typed_bridge::rot_raw_to_self_ref(&state);
state_mutated = true;
}
if let Some(props) = action.action.apply_mass() {
let comp = mass
.as_deref_mut()
.unwrap_or_else(|| {
panic!(
"BodyAction targets mass properties on entity {:?} (action_name={:?}) but the entity has no MassPropertiesC. \
Add `MassPropertiesC::from(MassPropertiesTyped::<SelfRef>::new(420_000.0.kg()))` (or `with_inertia(...)`) to the entity before queuing this action.",
action.entity, action.name,
)
});
comp.0 = astrodyn::typed_bridge::mass_raw_to_self_ref(&props);
comp.0.dirty = true;
mass_mutated = true;
}
if state_mutated || mass_mutated {
astrodyn::reset_integrators(
gj.as_deref_mut().map(|c| c.0.inner_mut()),
abm.as_deref_mut().map(|c| c.0.inner_mut()),
);
}
}
}
pub trait BodyActionCommandsExt {
fn add_body_action(&mut self, entity: Entity, action: BodyAction, name: Option<&str>);
fn add_body_action_for<P: Planet>(
&mut self,
entity: Entity,
action: BodyAction,
name: Option<&str>,
);
fn remove_body_action(&mut self, name: &str);
}
impl<'w, 's> BodyActionCommandsExt for Commands<'w, 's> {
fn add_body_action(&mut self, entity: Entity, action: BodyAction, name: Option<&str>) {
self.add_body_action_for::<astrodyn::Earth>(entity, action, name);
}
fn add_body_action_for<P: Planet>(
&mut self,
entity: Entity,
action: BodyAction,
name: Option<&str>,
) {
let name = name.map(|n| n.to_string());
self.queue(move |world: &mut World| {
if let Some(registered) = world.get_resource::<RegisteredPlanetsR>() {
assert!(
registered.contains::<P>(),
"BodyAction queued for planet `{planet}` against entity {entity:?} \
(action_name={name:?}) but no per-planet body-action pipeline is \
registered for that planet. The unified `Messages<BodyActionEvent>` \
buffer would never be drained for this `<P>` and the action would \
silently age out of the double-buffer. \
Fix: call `astrodyn_bevy::register_planet_systems::<{planet}>(&mut app)` \
during `App` setup (after `app.add_plugins(AstrodynPlugin)`), before \
queuing actions for `{planet}`-integrated bodies. \
`AstrodynPlugin::build` pre-registers `astrodyn::Earth`; additional \
planets must be registered explicitly.",
planet = std::any::type_name::<P>(),
);
}
let mut writer = world.resource_mut::<bevy::ecs::message::Messages<BodyActionEvent>>();
writer.write(BodyActionEvent::Add {
entity,
action,
name,
planet: TypeId::of::<P>(),
});
});
}
fn remove_body_action(&mut self, name: &str) {
let name = name.to_string();
self.queue(move |world: &mut World| {
let mut writer = world.resource_mut::<bevy::ecs::message::Messages<BodyActionEvent>>();
writer.write(BodyActionEvent::Remove { name });
});
}
}
#[inline]
pub fn add_body_action_via(
writer: &mut MessageWriter<BodyActionEvent>,
entity: Entity,
action: BodyAction,
name: Option<&str>,
) {
writer.write(BodyActionEvent::add(entity, action, name));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::components::{
DynamicsConfigC, MassPropertiesC, RotationalStateC, TranslationalStateC,
};
use astrodyn::{
DynamicsConfig, JeodQuat, MassProperties, OrbitalElementSet, OrbitalElements,
RotationalState,
};
use glam::DVec3;
fn build_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_message::<BodyActionEvent>();
app.init_resource::<BodyActionsR<astrodyn::Earth>>();
app.add_systems(
Update,
(
body_action_intake_system::<astrodyn::Earth>,
body_action_system::<astrodyn::Earth>,
)
.chain(),
);
app
}
fn spawn_vehicle(app: &mut App) -> Entity {
app.world_mut()
.spawn((
TranslationalStateC::<astrodyn::Earth>::default(),
RotationalStateC::default(),
MassPropertiesC::from(astrodyn::typed_bridge::mass_raw_to_self_ref(
&(MassProperties::new(400_000.0)),
)),
DynamicsConfigC(DynamicsConfig {
translational_dynamics: true,
rotational_dynamics: true,
three_dof: false,
}),
))
.id()
}
fn write_msg(app: &mut App, msg: BodyActionEvent) {
app.world_mut()
.resource_mut::<bevy::ecs::message::Messages<BodyActionEvent>>()
.write(msg);
}
#[test]
fn add_then_remove_before_apply_skips_action() {
let mut app = build_app();
let entity = spawn_vehicle(&mut app);
write_msg(
&mut app,
BodyActionEvent::add(
entity,
BodyAction::InitMass {
mass: MassProperties::new(400_000.0),
},
Some("vehicle.mass_init"),
),
);
write_msg(&mut app, BodyActionEvent::remove("vehicle.mass_init"));
write_msg(
&mut app,
BodyActionEvent::add(
entity,
BodyAction::InitMass {
mass: MassProperties::new(100_000.0),
},
Some("vehicle.mass_init"),
),
);
app.update();
let final_mass = astrodyn::typed_bridge::mass_typed_to_raw(
&app.world()
.entity(entity)
.get::<MassPropertiesC>()
.expect("mass props present")
.0,
)
.mass;
assert_eq!(final_mass, 100_000.0);
}
#[test]
fn rot_init_writes_rotational_state() {
let mut app = build_app();
let entity = spawn_vehicle(&mut app);
let q = JeodQuat::identity();
let omega = DVec3::new(0.0, 0.0, 0.01);
write_msg(
&mut app,
BodyActionEvent::add(
entity,
BodyAction::InitRot {
quaternion: q,
ang_vel_body: omega,
},
None,
),
);
app.update();
let state: RotationalState = astrodyn::typed_bridge::rot_typed_to_raw(
&app.world()
.entity(entity)
.get::<RotationalStateC>()
.expect("rot state present")
.0,
);
assert_eq!(state.quaternion, q);
assert_eq!(state.ang_vel_body, omega);
}
#[test]
fn trans_orbital_init_writes_translational_state() {
let mut app = build_app();
let entity = spawn_vehicle(&mut app);
const MU: f64 = 3.986_004_415e14;
let mut elements = OrbitalElements::default();
elements.semi_major_axis = 7.0e6;
elements.e_mag = 0.001;
elements.inclination = 51.6_f64.to_radians();
elements.long_asc_node = 0.1;
elements.arg_periapsis = 0.2;
elements.true_anom = 0.3;
write_msg(
&mut app,
BodyActionEvent::add(
entity,
BodyAction::InitTransOrbital {
set: OrbitalElementSet::SmaEccIncAscnodeArgperTanom,
elements,
time_periapsis: 0.0,
mu: MU,
},
None,
),
);
app.update();
let trans = astrodyn::typed_bridge::trans_typed_to_raw(
&app.world()
.entity(entity)
.get::<TranslationalStateC<astrodyn::Earth>>()
.expect("trans state present")
.0,
);
assert!(trans.position.length() > 1.0e6);
assert!(trans.velocity.length() > 1.0);
}
#[test]
fn commands_extension_add_then_remove() {
let mut app = build_app();
let entity = spawn_vehicle(&mut app);
fn queue_actions(In(entity): In<Entity>, mut commands: Commands) {
commands.add_body_action(
entity,
BodyAction::InitMass {
mass: MassProperties::new(400_000.0),
},
Some("vehicle.mass_init"),
);
commands.remove_body_action("vehicle.mass_init");
commands.add_body_action(
entity,
BodyAction::InitMass {
mass: MassProperties::new(100_000.0),
},
Some("vehicle.mass_init"),
);
}
app.world_mut()
.run_system_cached_with(queue_actions, entity)
.expect("run_system_cached_with");
app.update();
let final_mass = astrodyn::typed_bridge::mass_typed_to_raw(
&app.world()
.entity(entity)
.get::<MassPropertiesC>()
.expect("mass props present")
.0,
)
.mass;
assert_eq!(final_mass, 100_000.0);
}
#[test]
fn anonymous_action_cannot_be_removed_by_name() {
let mut app = build_app();
let entity = spawn_vehicle(&mut app);
write_msg(
&mut app,
BodyActionEvent::add(
entity,
BodyAction::InitMass {
mass: MassProperties::new(123.0),
},
None,
),
);
write_msg(&mut app, BodyActionEvent::remove("anything"));
app.update();
let final_mass = astrodyn::typed_bridge::mass_typed_to_raw(
&app.world()
.entity(entity)
.get::<MassPropertiesC>()
.expect("mass props present")
.0,
)
.mass;
assert_eq!(final_mass, 123.0);
}
#[test]
fn two_writes_with_same_name_apply_in_order() {
let mut app = build_app();
let entity = spawn_vehicle(&mut app);
write_msg(
&mut app,
BodyActionEvent::add(
entity,
BodyAction::InitMass {
mass: MassProperties::new(11.0),
},
Some("dup"),
),
);
write_msg(
&mut app,
BodyActionEvent::add(
entity,
BodyAction::InitMass {
mass: MassProperties::new(22.0),
},
Some("dup"),
),
);
app.update();
let final_mass = astrodyn::typed_bridge::mass_typed_to_raw(
&app.world()
.entity(entity)
.get::<MassPropertiesC>()
.expect("mass props present")
.0,
)
.mass;
assert_eq!(final_mass, 22.0);
}
#[test]
fn remove_drops_all_pending_with_matching_name() {
let mut app = build_app();
let entity = spawn_vehicle(&mut app);
write_msg(
&mut app,
BodyActionEvent::add(
entity,
BodyAction::InitMass {
mass: MassProperties::new(11.0),
},
Some("dup"),
),
);
write_msg(
&mut app,
BodyActionEvent::add(
entity,
BodyAction::InitMass {
mass: MassProperties::new(22.0),
},
Some("dup"),
),
);
write_msg(&mut app, BodyActionEvent::remove("dup"));
app.update();
let final_mass = astrodyn::typed_bridge::mass_typed_to_raw(
&app.world()
.entity(entity)
.get::<MassPropertiesC>()
.expect("mass props present")
.0,
)
.mass;
assert_eq!(final_mass, 400_000.0);
}
#[test]
fn empty_name_remove_is_noop() {
let mut app = build_app();
let entity = spawn_vehicle(&mut app);
write_msg(
&mut app,
BodyActionEvent::add(
entity,
BodyAction::InitMass {
mass: MassProperties::new(11.0),
},
Some(""),
),
);
write_msg(
&mut app,
BodyActionEvent::add(
entity,
BodyAction::InitMass {
mass: MassProperties::new(22.0),
},
Some(""),
),
);
write_msg(&mut app, BodyActionEvent::remove(""));
app.update();
let final_mass = astrodyn::typed_bridge::mass_typed_to_raw(
&app.world()
.entity(entity)
.get::<MassPropertiesC>()
.expect("mass props present")
.0,
)
.mass;
assert_eq!(final_mass, 22.0);
}
}