use bevy::prelude::*;
use glam::DVec3;
use astrodyn::{
Abm4State, FrameTransform, GaussJacksonState, IntegratorType, MassBodyId, MassTree,
MassTreeAttachment, Planet, PlanetFixed, RootInertial, RotationModel, SimulationBuilder,
ValidationError, VehicleConfig,
};
use crate::components::{
Abm4StateC, EphemerisBodyC, GaussJacksonStateC, GravitySourceC, MassBodyIdC, MassChildOf,
MoonMarker, PlanetFixedRotationC, PlanetOmegaC, RotationModelC, SourceInertialPositionC,
SourceInertialVelocityC, SunMarker, TidalConfigC, TranslationalStateC,
};
use crate::{
AstrodynPlugin, AtmosphereModelR, EphemerisR, IntegrationDtR, MassTreeR, PolarMotionR,
SimulationTimeR, VehicleConfigBevyExt,
};
#[derive(Debug, Clone)]
pub struct ScenarioHandles {
pub source_entities: Vec<Entity>,
pub body_entities: Vec<Entity>,
}
pub trait SimulationBuilderBevyExt: Sized {
fn populate_app<P: Planet>(
self,
app: &mut App,
) -> Result<ScenarioHandles, Vec<ValidationError>>;
}
impl SimulationBuilderBevyExt for SimulationBuilder {
fn populate_app<P: Planet>(
self,
app: &mut App,
) -> Result<ScenarioHandles, Vec<ValidationError>> {
app.insert_resource(Time::<Fixed>::from_seconds(self.dt));
app.insert_resource(IntegrationDtR(self.dt));
app.insert_resource(SimulationTimeR(self.time));
if let Some(eph) = self.ephemeris {
app.insert_resource(EphemerisR(eph));
}
if let Some((xp, yp)) = self.polar_motion {
app.insert_resource(PolarMotionR { xp, yp });
}
if !app.is_plugin_added::<AstrodynPlugin>() {
app.add_plugins(AstrodynPlugin);
}
if !app
.world()
.resource::<crate::RegisteredPlanetsR>()
.contains::<P>()
{
crate::register_planet_systems::<P>(app);
}
let sources_len = self.sources.len();
let mut source_entities = Vec::with_capacity(sources_len);
let SimulationBuilder {
atmosphere,
atmosphere_planet_source,
sun_source,
moon_source,
sources,
source_ephem_bodies,
bodies,
mass_tree_names,
mass_tree_attachments,
..
} = self;
assert!(
source_ephem_bodies.len() == sources_len,
"populate_app: source_ephem_bodies length {} does not match sources length {}",
source_ephem_bodies.len(),
sources_len
);
for (idx, (name, entry)) in sources.into_iter().enumerate() {
let ephem = source_ephem_bodies.get(idx).copied().flatten();
let entity = spawn_source::<P>(app, idx, &name, entry, sun_source, moon_source, ephem);
source_entities.push(entity);
}
if let Some(config) = atmosphere {
let planet_idx = atmosphere_planet_source.expect(
"populate_app: SimulationBuilder.atmosphere is Some but \
atmosphere_planet_source is None. Atmosphere computation \
requires a planet source whose `PlanetFixedRotationC` the \
atmosphere system queries every tick. Call \
`SimulationBuilder::atmosphere(config, planet_source)` (not \
a direct `sb.atmosphere = Some(_)` field write) and ensure \
the source has a `rotation_model` so `populate_app` inserts \
`PlanetFixedRotationC` on it. The runner side accepts the \
split fields as-is and the in-source-list index validation \
catches a stale index, but a `None` planet source is a \
misconfiguration the bridge surfaces here.",
);
let planet_entity = *source_entities.get(planet_idx).unwrap_or_else(|| {
panic!(
"populate_app: atmosphere_planet_source index {planet_idx} out of \
range ({sources_len} sources)"
)
});
app.insert_resource(AtmosphereModelR::new(config, planet_entity));
}
let has_tree = mass_tree_names.iter().any(|n| n.is_some());
assert!(
has_tree || mass_tree_attachments.is_empty(),
"populate_app: SimulationBuilder has {} pending mass-tree \
attachment(s) but no body is registered with a mass-tree name. \
Call `register_in_mass_tree(idx, name)` on each participating \
body before `attach_bodies(...)`.",
mass_tree_attachments.len(),
);
let (mass_tree, mass_ids): (Option<MassTree>, Vec<Option<MassBodyId>>) = if has_tree {
let mut tree = MassTree::new();
let mut ids: Vec<Option<MassBodyId>> = Vec::with_capacity(bodies.len());
for (i, name) in mass_tree_names.iter().enumerate() {
if let Some(name) = name {
let mass = bodies[i].mass.unwrap_or_else(|| {
panic!(
"populate_app: mass-tree-registered body {i} has no mass properties; \
SimulationBuilder::register_in_mass_tree should have caught this."
)
});
ids.push(Some(tree.add_body(
name.clone(),
astrodyn::typed_bridge::mass_typed_to_raw(&mass),
)));
} else {
ids.push(None);
}
}
for att in &mass_tree_attachments {
let MassTreeAttachment {
child_idx,
parent_idx,
offset,
t_parent_child,
} = *att;
let child_id = ids[child_idx].expect(
"populate_app: attachment references a child not registered in the mass tree",
);
let parent_id = ids[parent_idx].expect(
"populate_app: attachment references a parent not registered in the mass tree",
);
tree.attach(child_id, parent_id, offset, t_parent_child);
}
(Some(tree), ids)
} else {
(None, Vec::new())
};
let attachments_for_mass_child_of = mass_tree_attachments;
let mut shadow_marker_inserts: Vec<(usize, f64)> = Vec::new();
let mut body_entities = Vec::with_capacity(bodies.len());
for (i, cfg) in bodies.into_iter().enumerate() {
if let Some(sb) = cfg.shadow_body {
shadow_marker_inserts.push((sb.source_idx, sb.radius));
}
let integrator = cfg.integrator;
let entity = spawn_vehicle::<P>(app, cfg, &source_entities);
match integrator {
IntegratorType::GaussJackson(config) => {
app.world_mut()
.entity_mut(entity)
.insert(GaussJacksonStateC(GaussJacksonState::new(config)));
}
IntegratorType::Abm4 => {
app.world_mut()
.entity_mut(entity)
.insert(Abm4StateC(Abm4State::new()));
}
_ => {}
}
if let Some(Some(id)) = mass_ids.get(i).copied() {
app.world_mut().entity_mut(entity).insert(MassBodyIdC(id));
}
body_entities.push(entity);
}
for (source_idx, radius) in shadow_marker_inserts {
let source_entity = *source_entities.get(source_idx).unwrap_or_else(|| {
panic!(
"populate_app: VehicleConfig.shadow_body.source_idx {source_idx} \
out of range ({sources_len} sources)"
)
});
let existing = app
.world()
.get::<crate::components::ShadowBodyC>(source_entity)
.map(|c| c.radius);
if let Some(prev) = existing {
assert!(
prev.to_bits() == radius.to_bits(),
"populate_app: source {source_idx} already has \
ShadowBodyC {{ radius: {prev} }} but a later body \
specifies radius {radius}; bodies sharing a shadow \
body must agree on its radius."
);
continue;
}
app.world_mut()
.entity_mut(source_entity)
.insert(crate::components::ShadowBodyC { radius });
}
if let Some(tree) = mass_tree {
app.insert_resource(MassTreeR(tree));
for att in attachments_for_mass_child_of {
let MassTreeAttachment {
child_idx,
parent_idx,
offset,
t_parent_child,
} = att;
let child_entity = body_entities[child_idx];
let parent_entity = body_entities[parent_idx];
app.world_mut()
.entity_mut(child_entity)
.insert(MassChildOf::with_rotation(
parent_entity,
offset,
t_parent_child,
));
}
}
Ok(ScenarioHandles {
source_entities,
body_entities,
})
}
}
fn spawn_source<P: Planet>(
app: &mut App,
idx: usize,
name: &str,
entry: astrodyn::GravitySourceEntry,
sun_source: Option<usize>,
moon_source: Option<usize>,
ephem: Option<(astrodyn::EphemerisBody, astrodyn::EphemerisBody)>,
) -> Entity {
let astrodyn::GravitySourceEntry {
source,
position,
velocity,
t_inertial_pfix,
rotation_model,
delta_c20: _,
tidal_config,
planet_omega,
central: _,
marker_only,
} = entry;
if marker_only {
let mut entity_cmds = app.world_mut().spawn((
Name::new(name.to_string()),
TranslationalStateC::<P>::from_untyped(astrodyn::TranslationalState {
position: position.raw_si(),
velocity: velocity.raw_si(),
}),
));
if Some(idx) == sun_source {
entity_cmds.insert(SunMarker);
}
if Some(idx) == moon_source {
entity_cmds.insert(MoonMarker);
}
return entity_cmds.id();
}
let mut entity_cmds = app.world_mut().spawn((
Name::new(name.to_string()),
GravitySourceC(source),
SourceInertialPositionC(position),
TranslationalStateC::<P>::from_untyped(astrodyn::TranslationalState {
position: position.raw_si(),
velocity: velocity.raw_si(),
}),
));
if velocity.raw_si() != DVec3::ZERO {
entity_cmds.insert(SourceInertialVelocityC(velocity));
}
if rotation_model != RotationModel::None || t_inertial_pfix.is_some() {
let t = t_inertial_pfix.unwrap_or(glam::DMat3::IDENTITY);
entity_cmds.insert(PlanetFixedRotationC::<P>(FrameTransform::<
RootInertial,
PlanetFixed<P>,
>::from_matrix(t)));
if rotation_model != RotationModel::None {
entity_cmds.insert(RotationModelC(rotation_model));
}
}
if planet_omega != 0.0 {
entity_cmds.insert(PlanetOmegaC(planet_omega));
}
if let Some(cfg) = tidal_config {
entity_cmds.insert(TidalConfigC::from_untyped(&cfg));
}
if Some(idx) == sun_source {
entity_cmds.insert(SunMarker);
}
if Some(idx) == moon_source {
entity_cmds.insert(MoonMarker);
}
if let Some((target, observer)) = ephem {
entity_cmds.insert(EphemerisBodyC { target, observer });
}
entity_cmds.id()
}
fn spawn_vehicle<P: Planet>(
app: &mut App,
cfg: VehicleConfig,
source_entities: &[Entity],
) -> Entity {
let entity = {
let mut commands = app.world_mut().commands();
cfg.spawn_bevy::<P>(&mut commands, source_entities)
};
app.world_mut().flush();
entity
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use astrodyn::{
GravityControl, GravityControls, GravityGradient, GravityModel, GravitySource,
GravitySourceEntry, Position, RootInertial, SimulationTime, TranslationalState,
VehicleConfig, Velocity,
};
use astrodyn_runner::SimulationBuilderExt;
use bevy::prelude::*;
use glam::DVec3;
use super::*;
use crate::TranslationalStateC;
const MU_EARTH: f64 = 3.986_004_418e14;
const DT: f64 = 10.0;
const NUM_STEPS: usize = 50;
fn iss_trans() -> TranslationalState {
TranslationalState {
position: DVec3::new(6_778_137.0, 0.0, 0.0),
velocity: DVec3::new(0.0, 7668.56, 0.0),
}
}
fn point_mass_iss_builder() -> SimulationBuilder {
let time = SimulationTime::at_j2000(astrodyn::default_leap_second_table());
let mut b = SimulationBuilder::new(time, DT);
let mut earth = GravitySourceEntry::new(
GravitySource {
mu: MU_EARTH,
model: GravityModel::PointMass,
},
Position::<RootInertial>::zero(),
None,
);
earth.central = true;
let earth_idx = b.add_source("Earth", earth);
b.add_body(VehicleConfig {
trans: astrodyn::typed_bridge::trans_raw_to_root(&iss_trans()),
gravity_controls: GravityControls {
controls: vec![GravityControl::new_spherical(
earth_idx,
GravityGradient::Skip,
)],
},
..Default::default()
});
b
}
fn step_bevy(app: &mut App, n: usize) {
for _ in 0..n {
app.world_mut()
.resource_mut::<Time<Fixed>>()
.advance_by(Duration::from_secs_f64(DT));
app.world_mut().run_schedule(FixedUpdate);
}
}
fn assert_bits_eq(component: &str, a: f64, b: f64) {
assert!(
a.to_bits() == b.to_bits(),
"{component}: not bit-identical: a={a} ({:#018x}) vs b={b} ({:#018x})",
a.to_bits(),
b.to_bits(),
);
}
#[test]
fn populate_app_point_mass_iss_matches_runner_bit_identical() {
let runner_sim = point_mass_iss_builder()
.build()
.expect("runner build must succeed");
let mut runner_sim = runner_sim;
runner_sim
.step_n(NUM_STEPS)
.expect("runner step_n must succeed");
let runner_state = astrodyn::typed_bridge::trans_typed_to_raw(&runner_sim.body(0).trans);
let mut app = App::new();
app.add_plugins(MinimalPlugins);
let handles = point_mass_iss_builder()
.populate_app::<astrodyn::Earth>(&mut app)
.expect("populate_app must succeed");
assert_eq!(handles.source_entities.len(), 1);
assert_eq!(handles.body_entities.len(), 1);
step_bevy(&mut app, NUM_STEPS);
let bevy_state = astrodyn::typed_bridge::trans_typed_to_raw(
&app.world()
.get::<TranslationalStateC<astrodyn::Earth>>(handles.body_entities[0])
.expect("vehicle entity must carry TranslationalStateC<Earth>")
.0,
);
for i in 0..3 {
assert_bits_eq(
&format!("position[{i}]"),
bevy_state.position[i],
runner_state.position[i],
);
assert_bits_eq(
&format!("velocity[{i}]"),
bevy_state.velocity[i],
runner_state.velocity[i],
);
}
}
#[test]
fn populate_app_returns_one_entity_per_source_and_body() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
let mut b = SimulationBuilder::new(
SimulationTime::at_j2000(astrodyn::default_leap_second_table()),
DT,
);
let mut earth = GravitySourceEntry::new(
GravitySource {
mu: MU_EARTH,
model: GravityModel::PointMass,
},
Position::<RootInertial>::zero(),
None,
);
earth.central = true;
let earth_idx = b.add_source("Earth", earth);
let _sun_idx = b.add_source(
"Sun",
GravitySourceEntry {
source: GravitySource {
mu: 1.327e20,
model: GravityModel::PointMass,
},
position: Position::<RootInertial>::from_raw_si(DVec3::new(1.5e11, 0.0, 0.0)),
velocity: Velocity::<RootInertial>::zero(),
t_inertial_pfix: None,
rotation_model: astrodyn::RotationModel::None,
delta_c20: 0.0,
tidal_config: None,
planet_omega: 0.0,
central: false,
marker_only: false,
},
);
b.add_body(VehicleConfig {
trans: astrodyn::typed_bridge::trans_raw_to_root(&iss_trans()),
gravity_controls: GravityControls {
controls: vec![GravityControl::new_spherical(
earth_idx,
GravityGradient::Skip,
)],
},
..Default::default()
});
let handles = b
.populate_app::<astrodyn::Earth>(&mut app)
.expect("populate_app");
assert_eq!(
handles.source_entities.len(),
2,
"two sources spawned, one entity per source"
);
assert_eq!(
handles.body_entities.len(),
1,
"one vehicle spawned, one entity"
);
}
}