use std::collections::HashMap;
use bevy::ecs::system::SystemParam;
use bevy::prelude::*;
use astrodyn::{
recompute_composites_via_storage, MassNodeView, MassPointState, MassProperties, MassStorage,
};
use crate::components::{MassChildOf, MassPropertiesC};
use astrodyn::typed_bridge::{mass_raw_to_self_ref, mass_typed_to_raw};
#[inline]
fn mp_c_from_raw(mp: MassProperties) -> MassPropertiesC {
MassPropertiesC(mass_raw_to_self_ref(&mp))
}
#[doc(hidden)] #[derive(Component, Debug, Clone, Copy)]
pub struct CoreMassPropertiesC(pub MassProperties);
pub struct MassTreeView {
parent_by_child: HashMap<Entity, Entity>,
nodes: Vec<MassNodeRecord>,
index: HashMap<Entity, usize>,
entities_in_order: Vec<Entity>,
children_by_parent: HashMap<Entity, Vec<Entity>>,
roots: Vec<Entity>,
}
struct MassNodeRecord {
core: astrodyn::MassProperties,
structure_point: MassPointState,
name: String,
}
impl MassTreeView {
pub fn from_queries<M, P>(
mass_q: &Query<(Entity, &MassPropertiesC), M>,
parents_q: &Query<(Entity, &MassChildOf), P>,
names_q: &Query<&Name>,
) -> Self
where
M: bevy::ecs::query::QueryFilter,
P: bevy::ecs::query::QueryFilter,
{
let mass_set: HashMap<Entity, ()> = mass_q.iter().map(|(e, _)| (e, ())).collect();
let mut edge_data: HashMap<Entity, MassChildOf> = HashMap::new();
let mut parent_by_child: HashMap<Entity, Entity> = HashMap::new();
let mut children_by_parent: HashMap<Entity, Vec<Entity>> = HashMap::new();
for (child, edge) in parents_q.iter() {
assert!(
mass_set.contains_key(&child),
"entity {child:?} carries MassChildOf({parent:?}) but has no \
MassPropertiesC: every body in the mass tree must declare its \
core mass properties — add MassPropertiesC or remove the \
MassChildOf relation",
parent = edge.parent
);
assert!(
mass_set.contains_key(&edge.parent),
"MassChildOf edge {child:?} -> {parent:?}: parent has no MassPropertiesC. \
Either add MassPropertiesC to the parent or remove the MassChildOf \
component from the child.",
parent = edge.parent
);
edge_data.insert(child, *edge);
parent_by_child.insert(child, edge.parent);
children_by_parent
.entry(edge.parent)
.or_default()
.push(child);
}
let mut nodes: Vec<MassNodeRecord> = Vec::new();
let mut index: HashMap<Entity, usize> = HashMap::new();
let mut entities_in_order: Vec<Entity> = Vec::new();
let mut roots: Vec<Entity> = Vec::new();
for (entity, mass) in mass_q.iter() {
let untyped = mass_typed_to_raw(&mass.0);
let structure_point = match edge_data.get(&entity) {
Some(edge) => MassPointState {
position: edge.offset,
t_parent_this: edge.t_parent_child,
},
None => MassPointState::default(),
};
let name = match names_q.get(entity) {
Ok(n) => n.as_str().to_owned(),
Err(_) => format!("{entity:?}"),
};
let idx = nodes.len();
nodes.push(MassNodeRecord {
core: untyped,
structure_point,
name,
});
index.insert(entity, idx);
entities_in_order.push(entity);
if !edge_data.contains_key(&entity) {
roots.push(entity);
}
}
Self {
parent_by_child,
nodes,
index,
entities_in_order,
children_by_parent,
roots,
}
}
pub fn len(&self) -> usize {
self.nodes.len()
}
pub fn is_empty(&self) -> bool {
self.nodes.is_empty()
}
pub fn contains(&self, entity: Entity) -> bool {
self.index.contains_key(&entity)
}
pub fn iter_entities(&self) -> impl Iterator<Item = Entity> + '_ {
self.entities_in_order.iter().copied()
}
pub fn iter_roots(&self) -> impl Iterator<Item = Entity> + '_ {
self.roots.iter().copied()
}
}
impl MassStorage for MassTreeView {
type Id = Entity;
fn parent(&self, id: Self::Id) -> Option<Self::Id> {
self.parent_by_child.get(&id).copied()
}
fn node(&self, id: Self::Id) -> MassNodeView<'_> {
let idx = *self.index.get(&id).unwrap_or_else(|| {
panic!(
"MassTreeView::node({id:?}) — entity has no MassPropertiesC. \
Add MassPropertiesC before participating in the mass tree."
)
});
let rec = &self.nodes[idx];
MassNodeView {
core: rec.core,
structure_point: rec.structure_point,
name: rec.name.as_str(),
}
}
fn children(&self, id: Self::Id) -> &[Self::Id] {
self.children_by_parent
.get(&id)
.map(Vec::as_slice)
.unwrap_or(&[])
}
fn roots(&self) -> Vec<Self::Id> {
self.roots.clone()
}
fn node_count(&self) -> usize {
self.nodes.len()
}
}
#[derive(SystemParam)]
pub struct MassTreeQueries<'w, 's> {
pub mass: Query<'w, 's, (Entity, Ref<'static, MassPropertiesC>)>,
pub parents: Query<'w, 's, (Entity, &'static MassChildOf)>,
pub names: Query<'w, 's, &'static Name>,
pub(crate) cores: Query<'w, 's, Ref<'static, CoreMassPropertiesC>>,
pub(crate) ticks: bevy::ecs::system::SystemChangeTick,
}
impl MassTreeQueries<'_, '_> {
pub fn core_mass(&self, entity: Entity) -> Option<astrodyn::MassProperties> {
if let Ok(core) = self.cores.get(entity) {
let live_newer = self.mass.get(entity).ok().is_some_and(|(_, m)| {
m.last_changed()
.is_newer_than(core.last_changed(), self.ticks.this_run())
});
if !live_newer {
return Some(core.0);
}
}
self.mass
.get(entity)
.ok()
.map(|(_, m)| mass_typed_to_raw(&m.0))
}
pub fn composite_mass(&self, entity: Entity) -> Option<astrodyn::MassProperties> {
self.mass
.get(entity)
.ok()
.map(|(_, m)| mass_typed_to_raw(&m.0))
}
pub fn build_view(&self) -> MassTreeView {
let mut cores: HashMap<Entity, MassProperties> = HashMap::new();
let this_run = self.ticks.this_run();
for (entity, mass) in self.mass.iter() {
let core = match self.cores.get(entity) {
Ok(c) => {
if mass
.last_changed()
.is_newer_than(c.last_changed(), this_run)
{
mass_typed_to_raw(&mass.0)
} else {
c.0
}
}
Err(_) => mass_typed_to_raw(&mass.0),
};
cores.insert(entity, core);
}
build_view_from_cores(&cores, &self.parents, &self.names)
}
pub fn recompute_composites(&self) -> Vec<(Entity, astrodyn::MassNodeOutputs)> {
let view = self.build_view();
astrodyn::recompute_composites_via_storage(&view)
}
}
#[allow(clippy::type_complexity)]
pub fn composite_mass_system(
mut commands: Commands,
parents: Query<(Entity, &MassChildOf)>,
names: Query<&Name>,
cores_q: Query<(Entity, &CoreMassPropertiesC)>,
changed_parents: Query<(), Changed<MassChildOf>>,
mut removed_parents: RemovedComponents<MassChildOf>,
mut props: ParamSet<(
// p0: entities whose MassPropertiesC was changed this tick
// (or just spawned). Used to refresh CoreMassPropertiesC
// so mid-sim mass edits (fuel burn, staging) are picked
// up. The system's own composite write-back uses
// `bypass_change_detection` so it does not re-trigger
// this filter on the next tick.
Query<(Entity, &'static MassPropertiesC), Changed<MassPropertiesC>>,
// p1: write-back of the composite results.
Query<&'static mut MassPropertiesC>,
)>,
) {
let any_topology_changed = !changed_parents.is_empty() || removed_parents.read().count() > 0;
let any_mass_changed = !props.p0().is_empty();
if parents.is_empty() {
let mut just_edited: std::collections::HashSet<Entity> = std::collections::HashSet::new();
{
let changed = props.p0();
for (entity, props_ref) in &changed {
commands
.entity(entity)
.insert(CoreMassPropertiesC(mass_typed_to_raw(&props_ref.0)));
just_edited.insert(entity);
}
}
let mut writes = props.p1();
for (entity, core) in &cores_q {
if just_edited.contains(&entity) {
continue;
}
if let Ok(mut live) = writes.get_mut(entity) {
let live_untyped = mass_typed_to_raw(&live.0);
if live_untyped.mass != core.0.mass
|| live_untyped.position != core.0.position
|| live_untyped.inertia != core.0.inertia
{
*live.bypass_change_detection() = mp_c_from_raw(core.0);
}
}
}
return;
}
if !any_topology_changed && !any_mass_changed {
return;
}
let mut cores: HashMap<Entity, MassProperties> = HashMap::new();
for (entity, core) in &cores_q {
cores.insert(entity, core.0);
}
let mut to_seed: Vec<(Entity, MassProperties)> = Vec::new();
{
let changed = props.p0();
for (entity, props_ref) in &changed {
let core = mass_typed_to_raw(&props_ref.0);
cores.insert(entity, core);
to_seed.push((entity, core));
}
}
let view = build_view_from_cores(&cores, &parents, &names);
let outputs = recompute_composites_via_storage(&view);
for (entity, core) in to_seed {
commands.entity(entity).insert(CoreMassPropertiesC(core));
}
let mut writes = props.p1();
for (entity, out) in outputs {
if let Ok(mut p) = writes.get_mut(entity) {
*p.bypass_change_detection() = mp_c_from_raw(out.composite);
}
}
}
fn build_view_from_cores(
cores: &HashMap<Entity, MassProperties>,
parents_q: &Query<(Entity, &MassChildOf)>,
names_q: &Query<&Name>,
) -> MassTreeView {
let mut edge_data: HashMap<Entity, MassChildOf> = HashMap::new();
let mut parent_by_child: HashMap<Entity, Entity> = HashMap::new();
let mut children_by_parent: HashMap<Entity, Vec<Entity>> = HashMap::new();
for (child, edge) in parents_q.iter() {
assert!(
cores.contains_key(&child),
"entity {child:?} carries MassChildOf({parent:?}) but has no \
MassPropertiesC: every body in the mass tree must declare its \
core mass properties — add MassPropertiesC or remove the \
MassChildOf relation",
parent = edge.parent
);
assert!(
cores.contains_key(&edge.parent),
"MassChildOf edge {child:?} -> {parent:?}: parent has no MassPropertiesC. \
Either add MassPropertiesC to the parent or remove the MassChildOf \
component from the child.",
parent = edge.parent
);
edge_data.insert(child, *edge);
parent_by_child.insert(child, edge.parent);
children_by_parent
.entry(edge.parent)
.or_default()
.push(child);
}
let mut nodes: Vec<MassNodeRecord> = Vec::new();
let mut index: HashMap<Entity, usize> = HashMap::new();
let mut entities_in_order: Vec<Entity> = Vec::new();
let mut roots: Vec<Entity> = Vec::new();
let mut sorted_entities: Vec<Entity> = cores.keys().copied().collect();
sorted_entities.sort_by_key(|e| e.to_bits());
for entity in sorted_entities {
let core = cores[&entity];
let structure_point = match edge_data.get(&entity) {
Some(edge) => MassPointState {
position: edge.offset,
t_parent_this: edge.t_parent_child,
},
None => MassPointState::default(),
};
let name = match names_q.get(entity) {
Ok(n) => n.as_str().to_owned(),
Err(_) => format!("{entity:?}"),
};
let idx = nodes.len();
nodes.push(MassNodeRecord {
core,
structure_point,
name,
});
index.insert(entity, idx);
entities_in_order.push(entity);
if !edge_data.contains_key(&entity) {
roots.push(entity);
}
}
MassTreeView {
parent_by_child,
nodes,
index,
entities_in_order,
children_by_parent,
roots,
}
}
#[cfg(test)]
mod tests {
use super::*;
use astrodyn::MassProperties;
use glam::{DMat3, DVec3};
fn add_test_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app
}
#[test]
fn single_root_leaves_props_unchanged() {
let mut app = add_test_app();
app.add_systems(Update, composite_mass_system);
let core = MassProperties::new(10.0);
let e = app.world_mut().spawn(mp_c_from_raw(core)).id();
app.update();
let world = app.world();
let stored = mass_typed_to_raw(&world.get::<MassPropertiesC>(e).unwrap().0);
assert!((stored.mass - core.mass).abs() < 1e-12);
assert_eq!(stored.position, core.position);
}
#[test]
fn parent_composite_matches_arena_after_attach() {
let mut app = add_test_app();
app.add_systems(Update, composite_mass_system);
let parent_core = MassProperties::with_inertia(
10.0,
DMat3::from_diagonal(DVec3::new(50.0, 60.0, 70.0)),
DVec3::ZERO,
);
let child_core = MassProperties::new(5.0);
let offset = DVec3::new(3.0, 0.0, 0.0);
let parent = app.world_mut().spawn(mp_c_from_raw(parent_core)).id();
app.world_mut()
.spawn((mp_c_from_raw(child_core), MassChildOf::new(parent, offset)));
app.update();
let stored = app.world().get::<MassPropertiesC>(parent).unwrap().0;
let stored = mass_typed_to_raw(&stored);
let mut tree = astrodyn::MassTree::new();
let parent_id = tree.add_root("parent".into(), parent_core);
let child_id = tree.add_body("child".into(), child_core);
tree.attach(child_id, parent_id, offset, DMat3::IDENTITY);
let arena = tree.get(parent_id).composite_properties;
assert!(
(stored.mass - arena.mass).abs() < 1e-12,
"Bevy {} vs arena {}",
stored.mass,
arena.mass
);
let dpos = (stored.position - arena.position).length();
assert!(dpos < 1e-12, "position diff {dpos:.3e}");
for (col_a, col_b) in [
(stored.inertia.x_axis, arena.inertia.x_axis),
(stored.inertia.y_axis, arena.inertia.y_axis),
(stored.inertia.z_axis, arena.inertia.z_axis),
] {
let d = (col_a - col_b).length();
assert!(d < 1e-10, "inertia col diff {d:.3e}");
}
}
#[test]
fn no_mass_children_fast_path_no_panic() {
let mut app = add_test_app();
app.add_systems(Update, composite_mass_system);
app.update();
}
#[test]
fn mid_sim_core_mass_edit_picked_up_on_next_tick() {
let mut app = add_test_app();
app.add_systems(Update, composite_mass_system);
let parent_core = MassProperties::new(10.0);
let child_core = MassProperties::new(5.0);
let offset = DVec3::new(2.0, 0.0, 0.0);
let parent = app.world_mut().spawn(mp_c_from_raw(parent_core)).id();
let child = app
.world_mut()
.spawn((mp_c_from_raw(child_core), MassChildOf::new(parent, offset)))
.id();
app.update();
let m1 = mass_typed_to_raw(&app.world().get::<MassPropertiesC>(parent).unwrap().0).mass;
assert!((m1 - 15.0).abs() < 1e-12, "tick1 parent mass {m1}");
{
let mut props = app.world_mut().get_mut::<MassPropertiesC>(child).unwrap();
*props = mp_c_from_raw(MassProperties::new(8.0));
}
app.update();
let m2 = mass_typed_to_raw(&app.world().get::<MassPropertiesC>(parent).unwrap().0).mass;
assert!(
(m2 - 18.0).abs() < 1e-12,
"mid-sim edit not picked up: parent mass {m2} (expected 18)"
);
}
#[test]
#[should_panic(expected = "MassChildOf edge")]
fn missing_parent_fails_loudly() {
let mut app = add_test_app();
app.add_systems(Update, composite_mass_system);
let bad_parent = app.world_mut().spawn_empty().id();
app.world_mut().spawn((
mp_c_from_raw(MassProperties::new(5.0)),
MassChildOf::new(bad_parent, DVec3::ZERO),
));
app.update();
}
#[test]
fn fast_path_preserves_mid_tick_edit_on_standalone_body() {
let mut app = add_test_app();
app.add_systems(Update, composite_mass_system);
let core = MassProperties::new(10.0);
let e = app.world_mut().spawn(mp_c_from_raw(core)).id();
app.update();
{
let mut props = app.world_mut().get_mut::<MassPropertiesC>(e).unwrap();
*props = mp_c_from_raw(MassProperties::new(42.0));
}
app.update();
let after_typed = app.world().get::<MassPropertiesC>(e).unwrap().0;
let after = mass_typed_to_raw(&after_typed);
assert!(
(after.mass - 42.0).abs() < 1e-12,
"fast-path rolled back mid-tick edit: mass {} (expected 42)",
after.mass
);
let cache = app
.world()
.get::<CoreMassPropertiesC>(e)
.expect("core cache present after tick");
assert!(
(cache.0.mass - 42.0).abs() < 1e-12,
"core cache not refreshed: mass {} (expected 42)",
cache.0.mass
);
}
#[test]
fn fast_path_reverts_just_detached_parent() {
use bevy::ecs::system::RunSystemOnce;
let mut app = add_test_app();
app.add_systems(Update, composite_mass_system);
let parent_core = MassProperties::new(10.0);
let child_core = MassProperties::new(5.0);
let offset = DVec3::new(2.0, 0.0, 0.0);
let parent = app.world_mut().spawn(mp_c_from_raw(parent_core)).id();
let child = app
.world_mut()
.spawn((mp_c_from_raw(child_core), MassChildOf::new(parent, offset)))
.id();
app.update();
let composite_mass =
mass_typed_to_raw(&app.world().get::<MassPropertiesC>(parent).unwrap().0).mass;
assert!(
(composite_mass - 15.0).abs() < 1e-12,
"tick1 parent composite mass {composite_mass}"
);
app.world_mut().entity_mut(child).remove::<MassChildOf>();
app.world_mut()
.run_system_once(|| ())
.expect("noop system runs");
app.update();
let reverted_typed = app.world().get::<MassPropertiesC>(parent).unwrap().0;
let reverted = mass_typed_to_raw(&reverted_typed);
assert!(
(reverted.mass - 10.0).abs() < 1e-12,
"just-detached parent not reverted: mass {} (expected 10)",
reverted.mass
);
}
#[test]
fn fast_path_skips_kernel_when_no_edges() {
let mut app = add_test_app();
app.add_systems(Update, composite_mass_system);
let core = MassProperties::new(10.0);
let e = app.world_mut().spawn(mp_c_from_raw(core)).id();
app.update();
let before_tick = app
.world()
.entity(e)
.get_change_ticks::<MassPropertiesC>()
.unwrap()
.changed;
app.update();
app.update();
let after_tick = app
.world()
.entity(e)
.get_change_ticks::<MassPropertiesC>()
.unwrap()
.changed;
assert_eq!(
before_tick, after_tick,
"fast path should not touch MassPropertiesC: change tick advanced from {before_tick:?} to {after_tick:?}"
);
}
#[test]
fn core_cache_stable_across_ticks_with_mass_update_system_present() {
let mut app = add_test_app();
app.add_systems(
Update,
(
crate::systems::mass_update_system,
composite_mass_system.after(crate::systems::mass_update_system),
),
);
let grandparent_core = MassProperties::new(10.0);
let parent_core = MassProperties::new(5.0);
let child_core = MassProperties::new(2.0);
let grandparent = app.world_mut().spawn(mp_c_from_raw(grandparent_core)).id();
let parent = app
.world_mut()
.spawn((
mp_c_from_raw(parent_core),
MassChildOf::new(grandparent, DVec3::new(2.0, 0.0, 0.0)),
))
.id();
let child = app
.world_mut()
.spawn((
mp_c_from_raw(child_core),
MassChildOf::new(parent, DVec3::new(1.0, 0.0, 0.0)),
))
.id();
app.update();
let gp_core_t1 = app
.world()
.get::<CoreMassPropertiesC>(grandparent)
.expect("grandparent cache seeded after tick 1")
.0;
let p_core_t1 = app
.world()
.get::<CoreMassPropertiesC>(parent)
.expect("parent cache seeded after tick 1")
.0;
let c_core_t1 = app
.world()
.get::<CoreMassPropertiesC>(child)
.expect("child cache seeded after tick 1")
.0;
let gp_composite_t1 =
mass_typed_to_raw(&app.world().get::<MassPropertiesC>(grandparent).unwrap().0).mass;
let p_composite_t1 =
mass_typed_to_raw(&app.world().get::<MassPropertiesC>(parent).unwrap().0).mass;
assert!(
(gp_composite_t1 - 17.0).abs() < 1e-12,
"tick1 grandparent composite mass {gp_composite_t1} (expected 17)"
);
assert!(
(p_composite_t1 - 7.0).abs() < 1e-12,
"tick1 parent composite mass {p_composite_t1} (expected 7)"
);
assert!(
(gp_core_t1.mass - 10.0).abs() < 1e-12,
"tick1 grandparent core cache {} (expected 10)",
gp_core_t1.mass
);
assert!(
(p_core_t1.mass - 5.0).abs() < 1e-12,
"tick1 parent core cache {} (expected 5)",
p_core_t1.mass
);
app.update();
let gp_core_t2 = app
.world()
.get::<CoreMassPropertiesC>(grandparent)
.unwrap()
.0;
let p_core_t2 = app.world().get::<CoreMassPropertiesC>(parent).unwrap().0;
let c_core_t2 = app.world().get::<CoreMassPropertiesC>(child).unwrap().0;
let gp_composite_t2 =
mass_typed_to_raw(&app.world().get::<MassPropertiesC>(grandparent).unwrap().0).mass;
let p_composite_t2 =
mass_typed_to_raw(&app.world().get::<MassPropertiesC>(parent).unwrap().0).mass;
assert!(
(gp_core_t2.mass - gp_core_t1.mass).abs() < 1e-12,
"grandparent core cache drifted across ticks: {} -> {}",
gp_core_t1.mass,
gp_core_t2.mass
);
assert!(
(p_core_t2.mass - p_core_t1.mass).abs() < 1e-12,
"parent core cache drifted across ticks: {} -> {}",
p_core_t1.mass,
p_core_t2.mass
);
assert!(
(c_core_t2.mass - c_core_t1.mass).abs() < 1e-12,
"child core cache drifted across ticks: {} -> {}",
c_core_t1.mass,
c_core_t2.mass
);
assert!(
(gp_composite_t2 - gp_composite_t1).abs() < 1e-12,
"grandparent composite mass drifted across ticks: {gp_composite_t1} -> {gp_composite_t2}"
);
assert!(
(p_composite_t2 - p_composite_t1).abs() < 1e-12,
"parent composite mass drifted across ticks: {p_composite_t1} -> {p_composite_t2}"
);
}
#[test]
fn mass_tree_view_parent_lookup_matches_storage() {
use bevy::ecs::system::RunSystemOnce;
let mut app = add_test_app();
let parent = app
.world_mut()
.spawn(mp_c_from_raw(MassProperties::new(10.0)))
.id();
let child_a = app
.world_mut()
.spawn((
mp_c_from_raw(MassProperties::new(2.0)),
MassChildOf::new(parent, DVec3::new(1.0, 0.0, 0.0)),
))
.id();
let child_b = app
.world_mut()
.spawn((
mp_c_from_raw(MassProperties::new(3.0)),
MassChildOf::new(parent, DVec3::new(0.0, 1.0, 0.0)),
))
.id();
let lone_root = app
.world_mut()
.spawn(mp_c_from_raw(MassProperties::new(7.0)))
.id();
let probe = move |mass_q: Query<(Entity, &MassPropertiesC)>,
parents_q: Query<(Entity, &MassChildOf)>,
names_q: Query<&Name>| {
let view = MassTreeView::from_queries(&mass_q, &parents_q, &names_q);
assert_eq!(view.parent(child_a), Some(parent));
assert_eq!(view.parent(child_b), Some(parent));
assert_eq!(view.parent(parent), None);
assert_eq!(view.parent(lone_root), None);
};
app.world_mut()
.run_system_once(probe)
.expect("probe system runs");
}
#[test]
fn mass_tree_queries_core_vs_composite_split() {
let mut app = add_test_app();
app.add_systems(Update, composite_mass_system);
let parent_core = MassProperties::with_inertia(
10.0,
DMat3::from_diagonal(DVec3::new(50.0, 60.0, 70.0)),
DVec3::ZERO,
);
let child_core = MassProperties::new(5.0);
let offset = DVec3::new(3.0, 0.0, 0.0);
let parent = app.world_mut().spawn(mp_c_from_raw(parent_core)).id();
let child = app
.world_mut()
.spawn((mp_c_from_raw(child_core), MassChildOf::new(parent, offset)))
.id();
app.update();
#[derive(Resource, Default)]
struct Probe {
parent_core_mass: f64,
parent_composite_mass: f64,
child_core_mass: f64,
child_composite_mass: f64,
}
app.insert_resource(Probe::default());
#[derive(Resource)]
struct Targets {
parent: Entity,
child: Entity,
}
app.insert_resource(Targets { parent, child });
fn probe(queries: MassTreeQueries, mut out: ResMut<Probe>, targets: Res<Targets>) {
out.parent_core_mass = queries
.core_mass(targets.parent)
.expect("parent has core mass")
.mass;
out.parent_composite_mass = queries
.composite_mass(targets.parent)
.expect("parent has composite mass")
.mass;
out.child_core_mass = queries
.core_mass(targets.child)
.expect("child has core mass")
.mass;
out.child_composite_mass = queries
.composite_mass(targets.child)
.expect("child has composite mass")
.mass;
}
let probe_id = app.world_mut().register_system(probe);
app.world_mut()
.run_system(probe_id)
.expect("probe system runs");
let p = app.world().resource::<Probe>();
assert!(
(p.parent_core_mass - 10.0).abs() < 1e-12,
"parent core mass: {} (expected 10)",
p.parent_core_mass
);
assert!(
(p.parent_composite_mass - 15.0).abs() < 1e-12,
"parent composite mass: {} (expected 15)",
p.parent_composite_mass
);
assert!(
(p.child_core_mass - 5.0).abs() < 1e-12,
"child core mass: {} (expected 5)",
p.child_core_mass
);
assert!(
(p.child_composite_mass - 5.0).abs() < 1e-12,
"child composite mass: {} (expected 5)",
p.child_composite_mass
);
}
#[test]
fn gate_skips_walk_on_static_chain() {
let mut app = add_test_app();
app.add_systems(Update, composite_mass_system);
let a = app
.world_mut()
.spawn(mp_c_from_raw(MassProperties::new(1.0)))
.id();
let b = app
.world_mut()
.spawn((
mp_c_from_raw(MassProperties::new(1.0)),
MassChildOf::new(a, DVec3::new(1.0, 0.0, 0.0)),
))
.id();
let _c = app
.world_mut()
.spawn((
mp_c_from_raw(MassProperties::new(1.0)),
MassChildOf::new(b, DVec3::new(1.0, 0.0, 0.0)),
))
.id();
app.update();
let composite_after_tick1 =
mass_typed_to_raw(&app.world().get::<MassPropertiesC>(a).unwrap().0).mass;
assert!(
(composite_after_tick1 - 3.0).abs() < 1e-12,
"tick1 composite mass {composite_after_tick1}"
);
let sentinel = MassProperties::new(999.0);
{
let mut e = app.world_mut().entity_mut(a);
let mut props = e
.get_mut::<MassPropertiesC>()
.expect("entity has MassPropertiesC");
*props.bypass_change_detection() = mp_c_from_raw(sentinel);
}
app.update();
let after_tick2 = mass_typed_to_raw(&app.world().get::<MassPropertiesC>(a).unwrap().0).mass;
assert!(
(after_tick2 - 999.0).abs() < 1e-12,
"gate did not skip walk: sentinel was overwritten ({after_tick2}, expected 999)"
);
let c_entity = {
let mut q = app
.world_mut()
.query_filtered::<Entity, With<MassChildOf>>();
let mut found: Option<Entity> = None;
let candidates: Vec<Entity> = q.iter(app.world()).collect();
for cand in candidates {
let edge = app.world().get::<MassChildOf>(cand).unwrap();
if edge.parent == b {
found = Some(cand);
break;
}
}
found.expect("found c")
};
{
let mut props = app
.world_mut()
.get_mut::<MassPropertiesC>(c_entity)
.unwrap();
*props = mp_c_from_raw(MassProperties::new(10.0));
}
{
let mut e = app.world_mut().entity_mut(a);
let mut props = e.get_mut::<MassPropertiesC>().unwrap();
*props.bypass_change_detection() = mp_c_from_raw(MassProperties::new(3.0));
}
app.update();
let after_tick3 = mass_typed_to_raw(&app.world().get::<MassPropertiesC>(a).unwrap().0).mass;
assert!(
(after_tick3 - 12.0).abs() < 1e-12,
"post-edit composite mass: {} (expected 12)",
after_tick3
);
}
#[test]
fn gate_does_not_skip_on_detach() {
let mut app = add_test_app();
app.add_systems(Update, composite_mass_system);
let parent = app
.world_mut()
.spawn(mp_c_from_raw(MassProperties::new(10.0)))
.id();
let child_a = app
.world_mut()
.spawn((
mp_c_from_raw(MassProperties::new(2.0)),
MassChildOf::new(parent, DVec3::new(1.0, 0.0, 0.0)),
))
.id();
let _child_b = app
.world_mut()
.spawn((
mp_c_from_raw(MassProperties::new(3.0)),
MassChildOf::new(parent, DVec3::new(0.0, 1.0, 0.0)),
))
.id();
app.update();
let composite_tick1 =
mass_typed_to_raw(&app.world().get::<MassPropertiesC>(parent).unwrap().0).mass;
assert!(
(composite_tick1 - 15.0).abs() < 1e-12,
"tick1 composite mass {composite_tick1}"
);
app.world_mut().entity_mut(child_a).remove::<MassChildOf>();
app.update();
let composite_tick2 =
mass_typed_to_raw(&app.world().get::<MassPropertiesC>(parent).unwrap().0).mass;
assert!(
(composite_tick2 - 13.0).abs() < 1e-12,
"gate skipped a real detach: parent mass {} (expected 13)",
composite_tick2
);
}
#[test]
fn build_view_uses_core_cache_to_avoid_double_count() {
let mut app = add_test_app();
app.add_systems(Update, composite_mass_system);
let parent_core = MassProperties::new(10.0);
let child_core = MassProperties::new(5.0);
let offset = DVec3::new(2.0, 0.0, 0.0);
let parent = app.world_mut().spawn(mp_c_from_raw(parent_core)).id();
let _child = app
.world_mut()
.spawn((mp_c_from_raw(child_core), MassChildOf::new(parent, offset)))
.id();
app.update();
let composite_after_tick1 =
mass_typed_to_raw(&app.world().get::<MassPropertiesC>(parent).unwrap().0).mass;
assert!(
(composite_after_tick1 - 15.0).abs() < 1e-12,
"tick1 composite mass {composite_after_tick1}"
);
#[derive(Resource, Default)]
struct Probe {
parent_recomposed_mass: f64,
}
app.insert_resource(Probe::default());
#[derive(Resource)]
struct Target(Entity);
app.insert_resource(Target(parent));
fn external_recompute(
queries: MassTreeQueries,
mut out: ResMut<Probe>,
target: Res<Target>,
) {
let outputs = queries.recompute_composites();
let parent_out = outputs
.iter()
.find(|(e, _)| *e == target.0)
.expect("parent in kernel outputs")
.1;
out.parent_recomposed_mass = parent_out.composite.mass;
}
let id = app.world_mut().register_system(external_recompute);
app.world_mut()
.run_system(id)
.expect("external recompute runs");
let probe = app.world().resource::<Probe>();
assert!(
(probe.parent_recomposed_mass - 15.0).abs() < 1e-12,
"external recompute mass: {} (expected 15, double-count would give 20)",
probe.parent_recomposed_mass
);
}
#[test]
#[should_panic(expected = "carries MassChildOf")]
fn child_without_mass_properties_panics_in_system() {
let mut app = add_test_app();
app.add_systems(Update, composite_mass_system);
let parent = app
.world_mut()
.spawn(mp_c_from_raw(MassProperties::new(10.0)))
.id();
let _child = app.world_mut().spawn(MassChildOf::at_origin(parent)).id();
app.update();
}
#[test]
#[should_panic(expected = "carries MassChildOf")]
fn child_without_mass_properties_panics_in_from_queries() {
let mut app = add_test_app();
let parent = app
.world_mut()
.spawn(mp_c_from_raw(MassProperties::new(10.0)))
.id();
let _child = app.world_mut().spawn(MassChildOf::at_origin(parent)).id();
fn build(
mass_q: Query<(Entity, &MassPropertiesC)>,
parents_q: Query<(Entity, &MassChildOf)>,
names_q: Query<&Name>,
) {
let _ = MassTreeView::from_queries(&mass_q, &parents_q, &names_q);
}
let id = app.world_mut().register_system(build);
app.world_mut().run_system(id).expect("system runs");
}
#[test]
fn core_mass_reflects_midtick_edit_on_leaf() {
let mut app = add_test_app();
app.add_systems(Update, composite_mass_system);
let leaf = app
.world_mut()
.spawn(mp_c_from_raw(MassProperties::new(10.0)))
.id();
app.update();
{
let mut p = app.world_mut().get_mut::<MassPropertiesC>(leaf).unwrap();
*p = mp_c_from_raw(MassProperties::new(7.0));
}
#[derive(Resource, Default)]
struct Probe {
core_mass: f64,
}
app.insert_resource(Probe::default());
#[derive(Resource)]
struct Target(Entity);
app.insert_resource(Target(leaf));
fn read_core(queries: MassTreeQueries, mut out: ResMut<Probe>, t: Res<Target>) {
out.core_mass = queries.core_mass(t.0).expect("leaf has core mass").mass;
}
let id = app.world_mut().register_system(read_core);
app.world_mut().run_system(id).expect("read_core runs");
let p = app.world().resource::<Probe>();
assert!(
(p.core_mass - 7.0).abs() < 1e-12,
"stale cache returned: {} (expected 7 after mid-tick edit)",
p.core_mass
);
}
#[test]
fn build_view_reflects_midtick_edit_on_leaf() {
let mut app = add_test_app();
app.add_systems(Update, composite_mass_system);
let parent = app
.world_mut()
.spawn(mp_c_from_raw(MassProperties::new(10.0)))
.id();
let _child = app
.world_mut()
.spawn((
mp_c_from_raw(MassProperties::new(5.0)),
MassChildOf::new(parent, DVec3::new(2.0, 0.0, 0.0)),
))
.id();
app.update();
{
let mut p = app.world_mut().get_mut::<MassPropertiesC>(parent).unwrap();
*p = mp_c_from_raw(MassProperties::new(8.0));
}
#[derive(Resource, Default)]
struct Probe {
parent_composite_mass: f64,
}
app.insert_resource(Probe::default());
#[derive(Resource)]
struct Target(Entity);
app.insert_resource(Target(parent));
fn external_recompute(queries: MassTreeQueries, mut out: ResMut<Probe>, t: Res<Target>) {
let outputs = queries.recompute_composites();
out.parent_composite_mass = outputs
.iter()
.find(|(e, _)| *e == t.0)
.expect("parent in outputs")
.1
.composite
.mass;
}
let id = app.world_mut().register_system(external_recompute);
app.world_mut()
.run_system(id)
.expect("external_recompute runs");
let p = app.world().resource::<Probe>();
assert!(
(p.parent_composite_mass - 13.0).abs() < 1e-12,
"build_view used stale cache: {} (expected 13 after parent core edit 10 -> 8)",
p.parent_composite_mass
);
}
#[test]
fn iter_entities_is_deterministic_across_views() {
let mut app = add_test_app();
let root = app
.world_mut()
.spawn(mp_c_from_raw(MassProperties::new(10.0)))
.id();
let mut children = Vec::new();
for i in 0..5 {
let c = app
.world_mut()
.spawn((
mp_c_from_raw(MassProperties::new(1.0 + i as f64)),
MassChildOf::new(root, DVec3::new(i as f64, 0.0, 0.0)),
))
.id();
children.push(c);
}
let probe = app.world_mut().register_system(
|mass_q: Query<(Entity, &MassPropertiesC)>,
parents_q: Query<(Entity, &MassChildOf)>,
names_q: Query<&Name>|
-> (Vec<Entity>, Vec<Entity>) {
let view = MassTreeView::from_queries(&mass_q, &parents_q, &names_q);
(
view.iter_entities().collect(),
view.iter_entities().collect(),
)
},
);
let (view_a_first, view_a_second) = app.world_mut().run_system(probe).expect("view_a");
assert_eq!(
view_a_first, view_a_second,
"iter_entities returned different orders on the same view: {:?} vs {:?}",
view_a_first, view_a_second,
);
let (view_b_first, _) = app.world_mut().run_system(probe).expect("view_b");
assert_eq!(
view_a_first, view_b_first,
"iter_entities order differed between two views built against the same world: \
{view_a_first:?} vs {view_b_first:?}"
);
let mut sorted_a = view_a_first.clone();
sorted_a.sort_by_key(|e| e.to_bits());
let mut expected: Vec<Entity> = std::iter::once(root).chain(children).collect();
expected.sort_by_key(|e| e.to_bits());
assert_eq!(
sorted_a, expected,
"view missing entities: {view_a_first:?}"
);
}
}