use astrodyn::Planet;
use bevy::prelude::*;
use crate::components::{
CannonballSrpC, DragConfigC, DynamicsConfigC, EarthLightingConfigC, EulerAnglesConfigC,
FlatPlateConfigC, FrameAngVelC, FrameEntityC, FrameRotC, FrameSwitchesC, FrameTransC,
FrameUidC, GeodeticConfigC, GravityAccelerationC, GravityControlsC, GravitySourceC, LvlhFrameC,
MassPropertiesC, MoonMarker, OrbitalElementsConfigC, PlanetFixedRotationC, RotationalStateC,
SolarBetaC, SunMarker, TidalConfigC, TidalDeltaC20C, TranslationalStateC,
};
use crate::RootFrameEntityR;
pub(crate) fn is_root_equivalent_entity(
frame_entity: Entity,
root_entity: Entity,
parents: &Query<&ChildOf>,
frame_states: &Query<(&FrameTransC, &FrameRotC, &FrameAngVelC)>,
) -> bool {
if frame_entity == root_entity {
return true;
}
let Ok(child_of) = parents.get(frame_entity) else {
return false;
};
if child_of.parent() != root_entity {
return false;
}
let Ok((trans, rot, ang_vel)) = frame_states.get(frame_entity) else {
return false;
};
trans.position == glam::DVec3::ZERO
&& trans.velocity == glam::DVec3::ZERO
&& rot.t_parent_this == glam::DMat3::IDENTITY
&& ang_vel.0 == glam::DVec3::ZERO
}
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
pub fn validate_jeod_invariants<P: Planet>(
bodies: Query<
(
Entity,
&DynamicsConfigC,
&GravityControlsC,
Option<&GravityAccelerationC>,
Option<&MassPropertiesC>,
Option<&RotationalStateC>,
Option<&TranslationalStateC<P>>,
Option<&FlatPlateConfigC>,
),
Added<GravityControlsC>,
>,
sources: Query<(Entity, &FrameUidC, &GravitySourceC)>,
tidal_sources: Query<(
Entity,
&TidalConfigC,
Option<&TidalDeltaC20C>,
Option<&PlanetFixedRotationC<P>>,
)>,
srp_exclusion: Query<Entity, With<CannonballSrpC>>,
derived_state_markers: Query<(
Entity,
Option<&SolarBetaC>,
Option<&EarthLightingConfigC>,
Option<&SunMarker>,
Option<&MoonMarker>,
Option<&TranslationalStateC<P>>,
)>,
body_frame_state: Query<(Option<&FrameEntityC>, Option<&FrameSwitchesC>)>,
source_frames: Query<(&FrameUidC, &FrameEntityC), With<GravitySourceC>>,
parents: Query<&ChildOf>,
frame_states: Query<(&FrameTransC, &FrameRotC, &FrameAngVelC)>,
root_frame_entity: Option<Res<RootFrameEntityR>>,
#[allow(clippy::type_complexity)] root_dependent_features: Query<(
Has<DragConfigC>,
Has<FlatPlateConfigC>,
Has<CannonballSrpC>,
Has<OrbitalElementsConfigC>,
Has<EulerAnglesConfigC>,
Has<GeodeticConfigC>,
Has<LvlhFrameC>,
Has<SolarBetaC>,
Has<EarthLightingConfigC>,
)>,
) {
if bodies.is_empty() {
return;
}
let source_by_uid: std::collections::HashMap<&astrodyn::FrameUid, Entity> = sources
.iter()
.map(|(source_entity, uid, _)| (&uid.0, source_entity))
.collect();
let source_frame_by_uid: std::collections::HashMap<&astrodyn::FrameUid, Entity> = source_frames
.iter()
.map(|(uid, fe)| (&uid.0, fe.0))
.collect();
let mut sun_count = 0;
let mut moon_count = 0;
for (_entity, _, _, sun, moon, trans) in &derived_state_markers {
if trans.is_none() {
continue;
}
if sun.is_some() {
sun_count += 1;
}
if moon.is_some() {
moon_count += 1;
}
}
assert!(
sun_count <= 1,
"Multiple SunMarker entities found with TranslationalStateC for this validator's planet. \
JEOD assumes exactly one Sun body per integration frame."
);
assert!(
moon_count <= 1,
"Multiple MoonMarker entities found with TranslationalStateC for this validator's planet. \
JEOD assumes exactly one Moon body per integration frame."
);
for (entity, solar_beta, earth_lighting, _, _, body_trans) in &derived_state_markers {
if body_trans.is_none() {
continue;
}
if solar_beta.is_some() && sun_count == 0 {
panic!(
"Entity {entity:?}: SolarBetaC present but no SunMarker entity exists. \
Solar beta computation requires exactly one SunMarker entity."
);
}
if earth_lighting.is_some() {
if sun_count == 0 {
panic!(
"Entity {entity:?}: EarthLightingConfigC present but no SunMarker entity. \
Earth lighting requires both SunMarker and MoonMarker entities."
);
}
if moon_count == 0 {
panic!(
"Entity {entity:?}: EarthLightingConfigC present but no MoonMarker entity. \
Earth lighting requires both SunMarker and MoonMarker entities."
);
}
}
}
for (entity, _config, delta, rotation) in &tidal_sources {
assert!(
delta.is_some(),
"Entity {entity:?}: TidalConfigC is present but TidalDeltaC20C is missing. \
Add TidalDeltaC20C::default() to the entity so tidal_update_system can write ΔC20."
);
assert!(
rotation.is_some(),
"Entity {entity:?}: TidalConfigC is present but PlanetFixedRotationC is missing. \
tidal_update_system requires PlanetFixedRotationC to transform tidal body \
positions into the planet-fixed frame."
);
}
for (entity, _, _, _, _, _, _, flat_plates) in &bodies {
if flat_plates.is_some() && srp_exclusion.get(entity).is_ok() {
panic!(
"Entity {entity:?}: both FlatPlateConfigC and CannonballSrpC are present. \
These are mutually exclusive — use one SRP model per entity."
);
}
}
for (entity, config, controls, grav_accel, mass, rot_state, trans_state, flat_plates) in &bodies
{
let plate_counts = flat_plates.map(|fp| {
(
fp.plates.len(),
fp.temperatures.len(),
fp.t_pow4_cached.len(),
)
});
let mass_untyped = mass.map(|m| astrodyn::typed_bridge::mass_typed_to_raw(&m.0));
let trans_untyped = trans_state.map(|t| astrodyn::typed_bridge::trans_typed_to_raw(&t.0));
let errors = astrodyn::validate_body(
config,
&controls.0,
grav_accel.is_some(),
mass_untyped.as_ref(),
rot_state.is_some(),
trans_untyped.as_ref(),
|uid: &astrodyn::FrameUid| {
source_by_uid
.get(uid)
.and_then(|&e| sources.get(e).ok())
.map(|(_, _, source)| &source.0)
},
plate_counts,
);
if !errors.is_empty() {
let mut warn_detail = String::new();
let mut fatal_detail = String::new();
for error in &errors {
if error.is_warning() {
warn_detail.push_str("\n - ");
warn_detail.push_str(&error.to_string());
} else {
fatal_detail.push_str("\n - ");
fatal_detail.push_str(&error.to_string());
}
}
if !warn_detail.is_empty() {
bevy::log::warn!("Entity {entity:?} component validation warnings:{warn_detail}");
}
if !fatal_detail.is_empty() {
let remediation = "Validation is a hard gate — fix the misconfiguration named above and respawn the entity before adding it to the simulation.";
panic!(
"Entity {entity:?} fails component validation:{fatal_detail}\n{remediation}"
);
}
}
let (body_frame_handle, switches) = body_frame_state.get(entity).unwrap_or((None, None));
let root_entity_value = root_frame_entity.as_ref().map(|r| r.0);
let body_integ_frame_entity = body_frame_handle
.and_then(|fe| parents.get(fe.0).ok().map(|child_of| child_of.parent()));
let non_root_integ = match (body_integ_frame_entity, root_entity_value) {
(Some(integ_e), Some(root_e)) => {
!is_root_equivalent_entity(integ_e, root_e, &parents, &frame_states)
}
_ => false,
};
let mut non_root_switch = false;
if let Some(switches) = switches {
for sw in &switches.0 {
if !sw.active {
continue;
}
let target_frame_entity = match source_frame_by_uid.get(&sw.target) {
Some(&fe) => Some(fe),
None => {
panic!(
"Entity {entity:?}: FrameSwitchConfig.target = `{target}` \
does not resolve to a registered gravity source — no source \
entity carries that FrameUidC (or its frame was never \
registered). Spawn it with PlanetBundle before adding the \
body, or fix the target identity.",
target = sw.target,
);
}
};
if !controls.0.controls.iter().any(|c| c.source == sw.target) {
panic!(
"Entity {entity:?}: FrameSwitchConfig.target = `{target}` \
is not in the body's GravityControlsC. The post-switch gravity \
reclassification needs the target source to have a GravityControl \
entry (it becomes the non-differential central body). Add \
GravityControl::new_spherical(<that identity>, ...) to the body's \
controls before configuring the switch.",
target = sw.target,
);
}
if let (Some(tfe), Some(root_e)) = (target_frame_entity, root_entity_value) {
if !is_root_equivalent_entity(tfe, root_e, &parents, &frame_states) {
non_root_switch = true;
}
}
}
}
if non_root_integ || non_root_switch {
let (
has_drag,
has_flat,
has_cannonball,
has_orbital,
has_euler,
has_geodetic,
has_lvlh,
has_solar_beta,
has_earth_lighting,
) = root_dependent_features.get(entity).unwrap_or_default();
let has_root_dependent = has_drag
|| has_flat
|| has_cannonball
|| has_orbital
|| has_euler
|| has_geodetic
|| has_lvlh
|| has_solar_beta
|| has_earth_lighting;
if has_root_dependent {
bevy::log::warn!(
"Entity {entity:?}: non-root integration frame (or active \
frame switch into a non-root frame) paired with features. \
Per RF.10, these fall into two groups: SHIFT SITES (need \
root-inertial conversion before mixing with root-inertial \
source positions — flat_plate_srp={has_flat}, \
cannonball_srp={has_cannonball}, \
solar_beta={has_solar_beta}, \
earth_lighting={has_earth_lighting}) and NON-SHIFT SITES \
(consume the body's typed `Position<PlanetInertial<P>>` \
directly; shifting would break them — drag={has_drag}, \
orbital_elements={has_orbital}, euler={has_euler}, \
geodetic={has_geodetic}, lvlh={has_lvlh}). If the \
configuration applies the shift at every shift site (and \
leaves the non-shift sites alone) this warning is \
informational.",
);
}
}
for ctrl in &controls.0.controls {
if let Some((_, _, source)) = source_by_uid
.get(&ctrl.source)
.and_then(|&e| sources.get(e).ok())
{
ctrl.check_validity(&source.0);
}
}
}
}