use core::fmt::Debug;
use core::hash::Hash;
use std::collections::{HashMap, HashSet};
use glam::{DMat3, DVec3};
use crate::mass::MassProperties;
use crate::mass_body::{point_mass_inertia, MassBodyId, MassPointState, MassTree};
#[derive(Debug, Clone, Copy)]
pub struct MassNodeView<'a> {
pub core: MassProperties,
pub structure_point: MassPointState,
pub name: &'a str,
}
#[derive(Debug, Clone, Copy)]
pub struct MassNodeOutputs {
pub composite: MassProperties,
pub core_wrt_composite: MassPointState,
pub composite_wrt_pstr: MassPointState,
}
impl Default for MassNodeOutputs {
fn default() -> Self {
Self {
composite: MassProperties::new(1.0),
core_wrt_composite: MassPointState::default(),
composite_wrt_pstr: MassPointState::default(),
}
}
}
pub trait MassStorage {
type Id: Copy + Eq + Hash + Debug;
fn parent(&self, id: Self::Id) -> Option<Self::Id>;
fn node(&self, id: Self::Id) -> MassNodeView<'_>;
fn children(&self, id: Self::Id) -> &[Self::Id];
fn roots(&self) -> Vec<Self::Id>;
fn node_count(&self) -> usize;
}
pub fn compute_node_composite(
node: MassNodeView<'_>,
children: &[MassNodeOutputs],
_is_root: bool,
) -> MassNodeOutputs {
if children.is_empty() {
let mut composite = node.core;
if composite.mass > 0.0 {
let det = composite.inertia.determinant();
assert!(
det.abs() > 1e-30,
"Body '{}' has singular core inertia (det={det:.2e}); \
check the mid-tick mass edit that produced this leaf core.",
node.name
);
composite.inverse_mass = 1.0 / composite.mass;
composite.inverse_inertia = composite.inertia.inverse();
} else {
composite.inverse_mass = 0.0;
composite.inverse_inertia = DMat3::ZERO;
}
return MassNodeOutputs {
composite,
core_wrt_composite: MassPointState::default(),
composite_wrt_pstr: MassPointState::default(),
};
}
let mut total_mass = node.core.mass;
let mut weighted_pos = node.core.position * node.core.mass;
for child in children {
total_mass += child.composite.mass;
weighted_pos += child.composite_wrt_pstr.position * child.composite.mass;
}
let cm = if total_mass > 0.0 {
weighted_pos / total_mass
} else {
DVec3::ZERO
};
let core_offset = node.core.position - cm;
let mut composite_inertia = node.core.inertia + point_mass_inertia(node.core.mass, core_offset);
for child in children {
let child_offset = child.composite_wrt_pstr.position - cm;
let t = child.composite_wrt_pstr.t_parent_this;
let rotated_inertia = t.transpose() * child.composite.inertia * t;
composite_inertia +=
rotated_inertia + point_mass_inertia(child.composite.mass, child_offset);
}
let inverse_mass = if total_mass > 0.0 {
1.0 / total_mass
} else {
0.0
};
let inverse_inertia = if total_mass > 0.0 {
let det = composite_inertia.determinant();
assert!(
det.abs() > 1e-30,
"Body '{}' has singular composite inertia (det={det:.2e}); \
check mass-tree attach offsets and child inertias.",
node.name
);
composite_inertia.inverse()
} else {
DMat3::ZERO
};
let composite = MassProperties {
mass: total_mass,
inverse_mass,
inertia: composite_inertia,
inverse_inertia,
position: cm,
t_parent_this: node.core.t_parent_this,
dirty: false,
};
let core_wrt_composite = MassPointState {
position: node.core.position - cm,
t_parent_this: DMat3::IDENTITY,
};
MassNodeOutputs {
composite,
core_wrt_composite,
composite_wrt_pstr: MassPointState::default(),
}
}
pub fn finalize_child_in_parent_frame(
child: &mut MassNodeOutputs,
structure_point: &MassPointState,
) {
let t = structure_point.t_parent_this;
let comp_pos = child.composite.position;
child.composite_wrt_pstr.position = t.transpose() * comp_pos + structure_point.position;
child.composite_wrt_pstr.t_parent_this = structure_point.t_parent_this;
}
pub fn recompute_composites_via_storage<S: MassStorage>(
storage: &S,
) -> Vec<(S::Id, MassNodeOutputs)> {
let roots = storage.roots();
let expected = storage.node_count();
let mut out: Vec<(S::Id, MassNodeOutputs)> = Vec::with_capacity(expected);
let mut index: HashMap<S::Id, usize> = HashMap::with_capacity(expected);
let mut seen: HashSet<S::Id> = HashSet::with_capacity(expected);
for root in roots {
walk(storage, root, true, &mut out, &mut index, &mut seen);
}
assert!(
seen.len() == expected,
"MassStorage topology has a cycle or orphan: {} of {} nodes unreachable from roots(). \
Check MassChildOf edges for cycles, missing parents, or detached subtrees.",
expected.saturating_sub(seen.len()),
expected
);
out
}
fn walk<S: MassStorage>(
storage: &S,
id: S::Id,
is_root: bool,
out: &mut Vec<(S::Id, MassNodeOutputs)>,
index: &mut HashMap<S::Id, usize>,
seen: &mut HashSet<S::Id>,
) {
if !seen.insert(id) {
return;
}
let children = storage.children(id);
let mut child_outputs: Vec<MassNodeOutputs> = Vec::with_capacity(children.len());
for &child_id in children {
walk(storage, child_id, false, out, index, seen);
let slot = *index.get(&child_id).expect(
"child not pushed by post-order walk: kernel invariant — \
walk(child) above must always push exactly one (id, outputs) entry",
);
let child_view = storage.node(child_id);
let mut child_out = out[slot].1;
finalize_child_in_parent_frame(&mut child_out, &child_view.structure_point);
child_outputs.push(child_out);
out[slot].1 = child_out;
}
let view = storage.node(id);
let outputs = compute_node_composite(view, &child_outputs, is_root);
let slot = out.len();
out.push((id, outputs));
index.insert(id, slot);
}
impl MassStorage for MassTree {
type Id = MassBodyId;
fn parent(&self, id: Self::Id) -> Option<Self::Id> {
MassTree::parent(self, id)
}
fn node(&self, id: Self::Id) -> MassNodeView<'_> {
let body = MassTree::get(self, id);
MassNodeView {
core: body.core_properties,
structure_point: body.structure_point,
name: &body.name,
}
}
fn children(&self, id: Self::Id) -> &[Self::Id] {
MassTree::children(self, id)
}
fn roots(&self) -> Vec<Self::Id> {
let mut roots = Vec::new();
for id in 0..self.len() {
if MassTree::parent(self, id).is_none() {
roots.push(id);
}
}
roots
}
fn node_count(&self) -> usize {
MassTree::len(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mass::MassProperties;
fn assert_props_close(a: &MassProperties, b: &MassProperties, tol: f64, label: &str) {
assert!(
(a.mass - b.mass).abs() < tol,
"{label}: mass {} vs {}",
a.mass,
b.mass
);
let dpos = (a.position - b.position).length();
assert!(dpos < tol, "{label}: position diff {dpos:.3e}");
for (col_a, col_b) in [
(a.inertia.x_axis, b.inertia.x_axis),
(a.inertia.y_axis, b.inertia.y_axis),
(a.inertia.z_axis, b.inertia.z_axis),
] {
let d = (col_a - col_b).length();
assert!(d < tol, "{label}: inertia col diff {d:.3e}");
}
}
#[test]
fn storage_kernel_matches_arena_single_attach() {
let mut tree = MassTree::new();
let parent = tree.add_root(
"parent".into(),
MassProperties::with_inertia(
10.0,
DMat3::from_diagonal(DVec3::new(50.0, 60.0, 70.0)),
DVec3::new(0.1, -0.2, 0.0),
),
);
let child = tree.add_body(
"child".into(),
MassProperties::with_inertia(
4.0,
DMat3::from_diagonal(DVec3::new(8.0, 9.0, 10.0)),
DVec3::new(-0.05, 0.0, 0.0),
),
);
tree.attach(child, parent, DVec3::new(2.0, 0.5, -0.3), DMat3::IDENTITY);
let arena_parent_composite = tree.get(parent).composite_properties;
let arena_child_composite = tree.get(child).composite_properties;
let outs = recompute_composites_via_storage(&tree);
let kernel_parent = outs.iter().find(|(id, _)| *id == parent).unwrap().1;
let kernel_child = outs.iter().find(|(id, _)| *id == child).unwrap().1;
assert_props_close(
&arena_parent_composite,
&kernel_parent.composite,
1e-12,
"parent composite",
);
assert!((kernel_child.composite.mass - arena_child_composite.mass).abs() < 1e-12);
assert!(
(kernel_child.composite.position - arena_child_composite.position).length() < 1e-12
);
if kernel_child.composite.mass > 0.0 {
let identity = kernel_child.composite.inertia * kernel_child.composite.inverse_inertia;
for r in 0..3 {
for c in 0..3 {
let expected = if r == c { 1.0 } else { 0.0 };
assert!(
(identity.col(r)[c] - expected).abs() < 1e-9,
"child I·I^-1 not identity at [{r},{c}]: got {}",
identity.col(r)[c]
);
}
}
}
}
#[test]
fn storage_kernel_matches_arena_three_body_chain() {
let mut tree = MassTree::new();
let a = tree.add_root("A".into(), MassProperties::new(10.0));
let b = tree.add_body("B".into(), MassProperties::new(5.0));
let c = tree.add_body(
"C".into(),
MassProperties::with_inertia(
3.0,
DMat3::from_diagonal(DVec3::new(1.0, 4.0, 9.0)),
DVec3::ZERO,
),
);
tree.attach(b, a, DVec3::new(2.0, 0.0, 0.0), DMat3::IDENTITY);
let rot_z90 = 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),
);
tree.attach(c, b, DVec3::new(1.0, 0.0, 0.0), rot_z90);
let arena_a = tree.get(a).composite_properties;
let arena_b = tree.get(b).composite_properties;
let outs = recompute_composites_via_storage(&tree);
let ka = outs.iter().find(|(id, _)| *id == a).unwrap().1;
let kb = outs.iter().find(|(id, _)| *id == b).unwrap().1;
assert_props_close(&arena_a, &ka.composite, 1e-10, "A composite");
assert!((arena_b.mass - kb.composite.mass).abs() < 1e-12);
assert!((arena_b.position - kb.composite.position).length() < 1e-12);
for (ca, cb) in [
(arena_b.inertia.x_axis, kb.composite.inertia.x_axis),
(arena_b.inertia.y_axis, kb.composite.inertia.y_axis),
(arena_b.inertia.z_axis, kb.composite.inertia.z_axis),
] {
let d = (ca - cb).length();
assert!(d < 1e-10, "B inertia diff {d:.3e}");
}
for (ca, cb) in [
(
arena_b.inverse_inertia.x_axis,
kb.composite.inverse_inertia.x_axis,
),
(
arena_b.inverse_inertia.y_axis,
kb.composite.inverse_inertia.y_axis,
),
(
arena_b.inverse_inertia.z_axis,
kb.composite.inverse_inertia.z_axis,
),
] {
let d = (ca - cb).length();
assert!(d < 1e-10, "B inverse_inertia diff {d:.3e}");
}
}
#[test]
fn storage_children_returns_borrowed_slice() {
let mut tree = MassTree::new();
let parent = tree.add_root("parent".into(), MassProperties::new(10.0));
let child_a = tree.add_body("child_a".into(), MassProperties::new(2.0));
let child_b = tree.add_body("child_b".into(), MassProperties::new(3.0));
tree.attach(child_a, parent, DVec3::new(1.0, 0.0, 0.0), DMat3::IDENTITY);
tree.attach(child_b, parent, DVec3::new(0.0, 1.0, 0.0), DMat3::IDENTITY);
let slice_a: &[MassBodyId] = <MassTree as MassStorage>::children(&tree, parent);
let slice_b: &[MassBodyId] = <MassTree as MassStorage>::children(&tree, parent);
assert_eq!(slice_a.len(), 2);
assert!(
std::ptr::eq(slice_a.as_ptr(), slice_b.as_ptr()),
"MassStorage::children re-allocates between calls — \
trait must return a borrow into the backend, not an owned Vec"
);
}
#[test]
fn storage_kernel_chain_of_100_recomposes() {
let mut tree = MassTree::new();
let mut prev = tree.add_root("body_0".into(), MassProperties::new(1.0));
let mut bodies = vec![prev];
for i in 1..100 {
let body = tree.add_body(format!("body_{i}"), MassProperties::new(1.0));
tree.attach(body, prev, DVec3::new(0.1, 0.0, 0.0), DMat3::IDENTITY);
bodies.push(body);
prev = body;
}
let outs = recompute_composites_via_storage(&tree);
assert_eq!(outs.len(), 100, "kernel must visit every body");
let root_idx = outs.iter().position(|(id, _)| *id == bodies[0]).unwrap();
let root_composite_mass = outs[root_idx].1.composite.mass;
assert!(
(root_composite_mass - 100.0).abs() < 1e-9,
"root composite mass: expected 100.0, got {root_composite_mass}"
);
}
#[test]
fn storage_kernel_atomic_leaf_refreshes_stale_inverses() {
let live_mass = 12.0_f64;
let live_inertia = DMat3::from_diagonal(DVec3::new(40.0, 50.0, 60.0));
let stale_inverse_mass = 1.0 / 20.0;
let stale_inertia = DMat3::from_diagonal(DVec3::new(80.0, 100.0, 120.0));
let stale_inverse_inertia = stale_inertia.inverse();
let leaf_core = MassProperties {
mass: live_mass,
inverse_mass: stale_inverse_mass,
inertia: live_inertia,
inverse_inertia: stale_inverse_inertia,
position: DVec3::ZERO,
t_parent_this: DMat3::IDENTITY,
dirty: true,
};
let view = MassNodeView {
core: leaf_core,
structure_point: MassPointState::default(),
name: "midtick_leaf",
};
let out = compute_node_composite(view, &[], true);
let composite = out.composite;
assert!(
(composite.mass - live_mass).abs() < 1e-12,
"leaf composite mass: expected {live_mass}, got {}",
composite.mass
);
assert!(
(composite.mass * composite.inverse_mass - 1.0).abs() < 1e-12,
"leaf inverse_mass not refreshed: {} * {} = {} (expected 1.0)",
composite.mass,
composite.inverse_mass,
composite.mass * composite.inverse_mass
);
let identity = composite.inertia * composite.inverse_inertia;
for r in 0..3 {
for c in 0..3 {
let expected = if r == c { 1.0 } else { 0.0 };
assert!(
(identity.col(r)[c] - expected).abs() < 1e-9,
"leaf I·I^-1 not identity at [{r},{c}]: got {} \
(kernel returned stale inverse_inertia from `node.core`)",
identity.col(r)[c]
);
}
}
let stale_product = composite.inertia * stale_inverse_inertia;
assert!(
(stale_product - DMat3::IDENTITY).x_axis.length() > 0.1,
"test setup invalid: stale inverse happens to satisfy \
I·I^-1 ≈ identity against the live inertia"
);
}
#[test]
fn storage_kernel_atomic_root_round_trip() {
let mut tree = MassTree::new();
let r = tree.add_root(
"alone".into(),
MassProperties::with_inertia(
7.0,
DMat3::from_diagonal(DVec3::new(20.0, 30.0, 40.0)),
DVec3::new(0.0, 0.1, 0.0),
),
);
let outs = recompute_composites_via_storage(&tree);
let k = outs.iter().find(|(id, _)| *id == r).unwrap().1;
let arena = tree.get(r).composite_properties;
assert_props_close(&arena, &k.composite, 1e-12, "atomic root");
}
}