-
Notifications
You must be signed in to change notification settings - Fork 0
Frame Tree ECS Native
Status (May 2026): the migration described in Section 13 is complete. PRs 1–4 of the migration sequencing landed, removing
FrameTreeR, the five*FrameIdCcomponents, and the twosync_*_to_frame_systemarena-mirror systems. Frames are Bevy entities; cross-frame state goes through theRelativeFrameState/FrameOriginSystemParams; the sharedastrodyn::FrameStoragetrait factors the read-only graph walks for both the Bevy adapter andastrodyn_runner's arena. PR 5 (theFrame<P>typed-state work in #263) landed via the typedTranslationalStateC<P: Planet>end-to-end migration in #263. The articulated- bodies extension (Section 15) has also progressed substantially: the mass-tree ECS migration withMassChildOf+composite_mass_systemshipped via #308; frame-attached integration via #198 / #206; cross-integ-frame attach via #299/#312/#314/#319/#350; per-body kernels via #358/#363; staging-system simplification via #357 and the runner-side cross-integ-frame attach via #381.This page is preserved as the historical design document. Section headings and the "Before / After" examples remain useful for contributors trying to understand why the codebase looks the way it does, and Section 14 / 15 still capture the open articulated-bodies work that hadn't landed at design time. Workstreams that now ship follow the design described here.
PR #260 closed #71
by lifting astrodyn_runner's arena-based FrameTree into Bevy as a
Resource (FrameTreeR) plus five per-body *FrameIdC components
that map ECS entities to arena FrameIds, with two
sync_*_to_frame_system systems copying ECS state into the arena
each tick. (Historical state at the time this design was written;
all of this has since been removed — see status banner above.)
The shape worked but walked past CLAUDE.md's guidance:
RefFrame tree → entities with Parent/Children hierarchy
This doc captured the design for the long-term ECS-native replacement. It is not the migration; it is the target shape, the migration sequencing, and the tie-in to the typed-state work tracked in #263.
A working prototype branch —
study/268-frame-tree-ecs-native
— validates the shape end-to-end:
- 4 prototype tests assert bit-identity between
FrameTreeR.compute_relative_state(arena) and the newRelativeFrameStateSystemParam (ECS). - All 115 existing
bevy_parity_*tests stay green under the prototype's dual-write infrastructure. - A compile-only
frame_switch_system_ecs_native_sketchshows the full Sketch 2 below is real Rust.
The target is frames participating in ordinary Bevy idioms, not
"abstract over arena and ECS so the runner and Bevy share code"
(the latter would just preserve arena-style methods on a Bevy
resource — exactly what FrameTreeR already is, with extra
plumbing).
Concretely:
-
Frames are entities. Hierarchy lives on Bevy 0.18's
ChildOf/Childrenrelationship (Bevy ≥ 0.16 renamedParenttoChildOf). Reparenting on a frame switch iscommands.entity(frame).insert(ChildOf(new_parent)). -
Frame state lives as components on those entities:
FrameTransC(position + velocity vs parent),FrameRotC(q_parent_this,t_parent_this),FrameAngVelC(ang_vel_this). Change detection fires per-component. -
Frame kind is expressed as marker components
(
InertialFrameMarker,PlanetFixedFrameMarker,BodyFrameMarker,IntegrationFrameMarker) — the Bevy idiom for query keying viaWith<…>. Replaces the runtimeRefFrameKindenum on the arena side. -
Cross-frame operations expose as Bevy
SystemParams —RelativeFrameState,FrameOrigin. Mission code passesEntityhandles and never sees aFrameId, the arena, or any trait method. -
FrameTreeRand the five*FrameIdCcomponents disappear from the user-facing surface.astrodyn_runnerkeeps its arena (it has noWorld); the Bevy adapter does not mirror it.
The acid test for any proposed shape: does mission code asking "give me body X's position in frame Y" look like a normal Bevy SystemParam call, or like an arena lookup? If the latter, the design has not gone far enough. The prototype demonstrates the former concretely (Sketch 1 below).
All physics lives in
astrodyn_*crates (pure Rust, zero Bevy dependency). Orchestration lives inastrodyn(composesastrodyn_*functions into pipeline stages, re-exports all types; zero Bevy dependency). Bevy wiring lives in theastrodynroot package (src/— thin glue: component derives, systems that delegate toastrodynfunctions, plugin registration).The root package depends only on
astrodyn+bevy— never onastrodyn_*crates directly.
Implication: any frame-tree work must keep the arena (used by
astrodyn_runner) functioning unchanged, and put new ECS-side state
in astrodyn only.
Integration-frame state must be shifted to root-inertial via the integration-origin offset before passing to consumers that mix it with root-inertial source positions (gravity sources, Sun, Moon). Affected sites: gravity, relativistic, SRP (
sun_to_vehicle, shadow), solar beta, earth lighting. Sites that operate within a single planet's inertial frame (atmosphere, drag velocity, LVLH, geodetic, orbital elements) are NOT shift sites — the body's integration frame IS that planet's inertial frame in realistic configs, and shifting would break them.Status: partial — structural for shift sites (the
Position<IntegrationFrame> + Position<RootInertial>mismatch makesbody - sun_posrefuse to compile, forcingto_inertial(&o)); convention for non-shift sites (consumer takes rawDVec3, frame correctness depends on runtime invariantbody.integ_source == consumer_planet_source).
The new shape must preserve the structural shift-site guard. The non-shift-site convention is what #263 is about (Section 11 below).
Four named tests must remain green: tier3_apollo8_frame_switch
(frame switching), tier3_sim_mars_orbit (pfix rotation),
tier3_sim_polar_motion (RNP), tier3_sim_shadow_2a (eclipse +
pfix).
No "good enough" approximation of the existing helpers. Bit- identical numerics or the design isn't done. The prototype's 4 parity tests demonstrate this is achievable.
┌──────────────────────────────────────────────────┐
│ astrodyn_frames::FrameTree │
│ Vec<FrameNode { name, kind, RefFrameState }> │
│ Vec<Option<FrameId>> (parent links) │
│ Vec<Vec<FrameId>> (children lists) │
│ FrameId = usize │
└──────────┬─────────────────────────────┬─────────┘
│ │
astrodyn::frame_orchestration │
─ sync_pfix_rotation │
─ frame_origin / frame_origin_typed │
─ compute_relative_state_typed │
─ evaluate_and_apply_frame_switch │
│ │
astrodyn::source_state │
─ source_position / set_source_position │
─ source_pfix_rotation / set_source_state │
│ │
┌────────┴───────┐ ┌───────────┴───────────┐
│ astrodyn_runner │ │ astrodyn │
│ Simulation │ │ FrameTreeR (Resource) │
│ owns FrameTree │ │ wraps FrameTree │
└────────────────┘ │ │
│ Per-body components: │
│ SourceFrameIdC │
│ SourcePfixFrameIdC │
│ RetiredPfixFrameIdC │
│ BodyFrameIdC │
│ IntegFrameIdC │
│ │
│ Sync systems: │
│ sync_source_to_frame │
│ sync_body_to_frame │
│ (copy ECS → arena) │
└───────────────────────┘
The two storage backends (ECS components on bodies/sources +
arena FrameTree) hold overlapping state and require sync each
tick. Mission code reaching for cross-frame state must go through
Res<FrameTreeR> and *FrameIdC lookups.
For each piece of state the current shape carries, where it lives in the ECS-native target:
| Current (PR #260) | Target (this design) |
|---|---|
FrameTreeR Resource |
disappears. The Bevy adapter no longer mirrors an arena. |
RootFrameIdR Resource |
becomes RootFrameEntityR(Entity) — the root frame entity ID. |
SourceFrameIdC(FrameId) on source |
becomes FrameEntityC(Entity) on source, pointing at the source's frame entity. |
SourcePfixFrameIdC(FrameId) on source |
becomes PfixFrameEntityC(Entity) on source, pointing at the pfix frame entity. |
BodyFrameIdC(FrameId) on body |
becomes FrameEntityC(Entity) on body. (Same component name as for sources.) |
IntegFrameIdC(FrameId) on body |
disappears. The body's integ frame is its frame entity's ChildOf parent. |
RetiredPfixFrameIdC(FrameId) on source |
becomes a stashed Entity (or just rely on Bevy entity reuse / despawn). |
RefFrameState { trans, rot } on node |
splits across FrameTransC + FrameRotC + FrameAngVelC on the frame entity. |
FrameNode.kind: RefFrameKind enum |
becomes marker components (InertialFrameMarker, PlanetFixedFrameMarker, |
BodyFrameMarker, IntegrationFrameMarker). |
|
| Arena parent/children vectors |
become Bevy 0.18's ChildOf / Children relationship. |
Arena FrameTree::find_by_name
|
becomes Query<Entity, With<Name>> filtered by the Name component. |
Decision rationale:
-
Component split (Q2): the three pieces of
RefFrameStateare independently mutated.FrameTransCis rewritten by integration / source-state updates;FrameRotCby pfix rotation;FrameAngVelCalongside pfix rotation but stays at zero for inertial / body frames. Splitting buys finer change detection. The prototype validates the split with bit-identical numerics. -
Body Entity ≠ body-frame Entity (Q3): kept separate. A body
carries domain components (mass, dynamics config, integrator
state) and its frame entity carries frame state. Collapsing them
would mix two roles on one entity and complicate query filters.
Bodies link to their frame entity via
FrameEntityC. -
Marker components (Q4): standard Bevy idiom, lets queries
key off
With<PlanetFixedFrameMarker>instead of carrying a runtime kind enum. The marker suffix avoids colliding withastrodyn's phantom-frame types (BodyFrame,PlanetFixed,IntegrationFrame).
Three Bevy SystemParams replace the entire mission-code reach
into the frame tree.
Replaces FrameTreeR.compute_relative_state(from_id, to_id) and
frame_origin(tree, root, frame_id). Walks the ECS hierarchy via
Query<&ChildOf> plus Query<(&FrameTransC, &FrameRotC, &FrameAngVelC)>, composes states using the pure-state math
re-exported from astrodyn_frames via astrodyn.
pub struct RelativeFrameState<'w, 's> {
parents: Query<'w, 's, &'static ChildOf>,
states: Query<'w, 's, (&'static FrameTransC, &'static FrameRotC, &'static FrameAngVelC)>,
}
impl RelativeFrameState<'_, '_> {
pub fn position(&self, from: Entity, to: Entity) -> DVec3 { … }
pub fn position_velocity(&self, from: Entity, to: Entity) -> (DVec3, DVec3) { … }
pub fn relative_state(&self, from: Entity, to: Entity) -> RefFrameState { … }
}Before — arena lookup through FrameTreeR:
fn read_via_arena(
frame_tree: Res<FrameTreeR>,
bodies: Query<&BodyFrameIdC, With<MyBody>>,
planets: Query<&SourcePfixFrameIdC, With<MyPlanet>>,
) -> DVec3 {
let body_fid = bodies.single().unwrap().0;
let pfix_fid = planets.single().unwrap().0;
frame_tree.0.compute_relative_state(pfix_fid, body_fid).trans.position
}After — RelativeFrameState SystemParam wrapping ECS queries:
fn read_via_systemparam(
rel: RelativeFrameState,
bodies: Query<&FrameEntityC, With<MyBody>>,
planets: Query<&PfixFrameEntityC, With<MyPlanet>>,
) -> DVec3 {
let body_e = bodies.single().unwrap().0;
let pfix_e = planets.single().unwrap().0;
rel.position(pfix_e, body_e)
}The "After" never names a FrameId, never holds
Res<FrameTreeR>, never reaches into arena indices. It reads
like any other Bevy SystemParam call. The prototype test
relative_frame_state_matches_arena
asserts the two paths are bit-identical.
Specialized RelativeFrameState variant for the common
"frame-vs-root" query that gravity and integration code needs
today. Returns (Position<RootInertial>, Velocity<RootInertial>)
typed at the root-inertial phantom (per the inertial-frame
phantoms in CLAUDE.md). Sugar over rel.position_velocity(root, frame); landed as a separate SystemParam to make the typed
return value's frame phantom explicit.
The existing SourceMutator (src/source_mutator.rs) wraps
set_source_position and set_source_state. Phase A surfaced
astrodyn::source_state as the second arena consumer beyond
frame_orchestration (5 functions: source_position,
source_pfix_rotation, set_source_position, set_source_state,
source_frame_id). The migration extends SourceMutator to
expose all 5 over the new ECS components + FrameTransC /
FrameRotC writes. Same SystemParam shape as today; new methods.
These three SystemParams are the complete user-facing surface
the design commits to. Additional helpers (e.g., a
FrameSwitchHandler SystemParam) can be added on demand if
mission code asks for them. The design pressure-tested: every
current consumer of FrameTreeR / compute_relative_state /
frame_origin (inventoried in Phase A) maps to one of these
three, plus internal-only registration / sync systems that don't
need a stable mission-facing surface.
The factoring split has three layers:
-
Pure state math stays in
astrodyn_framesunchanged (RefFrameState::incr_left,incr_right,negate, plus the underlyingRefFrameTrans/RefFrameRotops). Pure functions onRefFrameStatepairs with no tree storage involved. Both consumers reuse them. -
Tree-walking algorithms (
compose_to_ancestor,common_ancestor,compute_relative_state) are factored over a thin internalFrameStoragetrait inastrodyn_frames::frame_storage, generic over the storage'sIdtype. One implementation; each backend implements the trait:-
astrodyn_runner's arena:impl FrameStorage for FrameTree { type Id = FrameId; … }(unchanged signatures elsewhere). -
astrodyn'sRelativeFrameStateSystemParam:impl FrameStorage for RelativeFrameState<'_, '_> { type Id = Entity; … }. Internally walksQuery<&ChildOf>+ the new state-component queries; from the trait's perspective, "give me the parent of this id" is the same operation either way.
-
-
Mutation (
reparent) stays native per consumer. The arena does it directly; the Bevy adapter does it viacommands.entity(child).insert(ChildOf(p)). These don't share an abstraction cleanly (deferred Commands vs direct mutation), and the prototype's experiment confirmed the read-only trait is the right scope.
The prototype first reimplemented compose_to_ancestor /
common_ancestor / compute_relative_state natively in
astrodyn's SystemParam (~80 lines). A follow-on experiment
(commit
2b0a8ab)
factored those three algorithms behind FrameStorage and verified
the outcome:
-
astrodyn_bevy/src/frame_param.rs:RelativeFrameState::relative_statecollapses to a one-line delegation (astrodyn::frame_compute_relative_state_via_storage(self, from, to)); the ~80 lines of inline tree-walking helpers (compose_to_ancestor,common_ancestor,parent_of,state_of,depth) become ~25 lines ofimpl FrameStoragetrait methods. -
astrodyn_frames/src/frame_storage.rs: ~140 lines hosting the trait, the three algorithms, and the arena impl (with two unit tests asserting bit-identity vs.FrameTree's inherent methods). - All 4 prototype parity tests stay green: the SystemParam reading
through the shared algorithm is bit-identical to the arena's
direct read. All 115
bevy_parity_*tests stay green.
The trait is internal scaffolding. Mission code never sees it:
the SystemParam surface is unchanged (rel.position(from, to),
rel.relative_state(from, to), etc.). The trait lives in the
implementation behind those methods. The earlier guidance
forbidding "abstract over arena and ECS so the runner and Bevy
share code" applies to the user-facing surface — making mission
code call arena-shaped trait methods would defeat the point of
using Bevy. A trait that lives entirely below the SystemParam
boundary doesn't have that problem.
reparent (mutation) is per-consumer. Backend-specific
registration (allocating an arena node vs. spawning an entity),
despawn cleanup (arena retirement vs. Bevy hierarchy plugin), and
all Commands-mediated operations are native to each backend.
The trait is only the read-only graph-traversal contract.
The runner's arena code is unchanged in signature. The runner does not depend on Bevy. Both consumers share the pure-state math and the tree-walking algorithms via the trait. This satisfies the three-layer rule and keeps each consumer's user-facing API surface idiomatic to its own world.
Decision: astrodyn_runner keeps its arena unchanged.
- The
Simulationstruct incrates/astrodyn_runner/src/simulation/mod.rsis a monolithic owner (frame_tree: FrameTree,bodies: Vec<SimBody>, etc.). Moving it onto a BevyWorldwould be a runner rewrite, not a frame- tree migration. - The runner's primary purpose — Tier 3 cross-validation tests
and offline studies that don't need ECS scheduling — is well-
served by the arena. Adopting
bevy_ecs::Worldwould add a heavy dependency that contradicts the "lightweight harness" role CLAUDE.md describes. - All existing orchestration helpers
(
frame_orchestration::evaluate_and_apply_frame_switch,source_state::set_source_position, etc.) keep their arena- shaped signatures. Bevy reimplements equivalent logic over ECS hierarchy + queries, sharing only the pure-state math (Section 7). No changes toastrodyn_runnerare required by this design.
This means the migration is entirely contained in astrodyn
plus (optionally, late) some tagging cleanup in astrodyn to
clarify which helpers stay arena-shaped vs which are absorbed
into per-consumer native code.
The pre-migration astrodyn schedule (PR #260 baseline) included
registration systems, sync systems, and despawn observers. After the
migration (now LANDED — see status banner at the top of this page):
| System (today) | Fate under the new shape |
|---|---|
register_source_frames_system |
Stays. Spawns the source's frame entity instead of arena node. |
register_pfix_frames_system |
Stays. Spawns the pfix frame entity ChildOf the source frame. |
register_body_frames_system |
Stays. Spawns the body's frame entity ChildOf the integ frame. |
sync_source_to_frame_system |
Disappears. Source state is written directly to FrameTransC. |
sync_body_to_frame_system |
Disappears. Integration writes TranslationalStateC and
|
the body frame entity's FrameTransC (or systems read directly |
|
from TranslationalStateC / its successor — see Section 11). |
|
planet_fixed_rotation_system |
Stays, simplified. Writes only to the pfix frame entity's |
FrameRotC + FrameAngVelC. The arena pfix-node sync via |
|
sync_pfix_rotation disappears. |
|
frame_switch_system |
Stays, rewritten. Uses commands.entity(frame).insert(ChildOf(p))
|
+ RelativeFrameState (see Section 10 and the prototype's |
|
frame_switch_system_ecs_native_sketch). |
|
gravity_computation_system |
Stays, simplified. Uses FrameOrigin SystemParam to query |
non-root integ-frame origins. Drops Res<FrameTreeR> + |
|
IntegFrameIdC lookups. |
|
integration_system |
Stays, simplified. Same pattern as gravity system; per-stage |
origin interpolation reads through FrameOrigin. |
|
Despawn observers (on_*_despawn) |
Disappear. Bevy's hierarchy plugin handles Children cleanup |
| when frame entities despawn; no arena retirement bookkeeping needed. |
Net effect: 2 sync systems removed, 4 despawn observers removed,
gravity_computation_system and integration_system
significantly cleaner (no arena reach). Registration systems stay
but spawn entities instead of arena nodes.
The prototype's compile-only sketch
(frame_switch_system_ecs_native_sketch in src/frame_param.rs)
shows the full flow:
fn frame_switch_system_ecs_native(
mut commands: Commands,
rel: RelativeFrameState,
sources: Query<&FrameEntityC, Without<BodyFrameMarker>>,
mut bodies: Query<(
Entity,
&mut TranslationalStateC<…>, // typed per #263
&FrameEntityC,
&mut FrameSwitchesC,
&mut GravityControlsC,
)>,
parents: Query<&ChildOf>,
) {
for (body_entity, mut trans, body_frame, mut switches, mut gravity_controls) in &mut bodies {
if switches.0.is_empty() { continue; }
// Current integ frame is the body frame's parent — no
// IntegFrameIdC lookup needed.
let current_integ_frame = parents.get(body_frame.0).unwrap().parent();
// Walk active switches; evaluate distance via SystemParam.
let triggered = switches.0.iter().enumerate().find_map(|(idx, sw)| {
if !sw.active { return None; }
let target_frame = sources.get(sw.target_source).ok()?.0;
let body_pos_in_target = rel.position(target_frame, body_frame.0).length_squared();
let body_pos_in_current = rel.position(current_integ_frame, body_frame.0).length_squared();
let threshold_sq = sw.switch_distance * sw.switch_distance;
let fire = match sw.switch_sense {
SwitchSense::OnApproach => body_pos_in_target < threshold_sq,
SwitchSense::OnDeparture => body_pos_in_current > threshold_sq,
};
fire.then_some((idx, target_frame))
});
let Some((idx, new_parent_frame)) = triggered else { continue; };
// Reproject body's translational state into the new integ
// frame using the SystemParam.
let new_state = rel.relative_state(new_parent_frame, body_frame.0);
trans.position = new_state.trans.position.into(); // typed wrap
trans.velocity = new_state.trans.velocity.into();
// Reparent — the load-bearing ECS-native operation.
commands.entity(body_frame.0).insert(ChildOf(new_parent_frame));
// Flip gravity controls.
switches.0[idx].active = false;
let target_source = switches.0[idx].target_source;
for ctrl in &mut gravity_controls.0.controls {
ctrl.differential = ctrl.source_name != target_source;
}
}
}The load-bearing change is commands.entity(body_frame).insert(ChildOf(new_parent_frame)),
replacing frame_tree.reparent(body_frame_id, new_integ_fid) and
the surrounding update IntegFrameIdC bookkeeping. Bevy's
hierarchy plugin handles the rest (updating Children on both
old and new parents). The IntegFrameIdC component disappears
entirely — the body frame entity's parent is the integration
frame.
#263 Section A
catalogs the TranslationalStateC<RootInertial> type-lie for
non-root bodies and three other untyped-frame leaks. The PR #260
review thread flagged this repeatedly — derived-state systems
(geodetic, solar-beta, SRP-vs-Sun, atmosphere) read
TranslationalStateC as if it were absolute root-inertial, but
for IntegSourceC(Some(planet)) bodies it's actually integ-frame-
relative.
The ECS-native shape makes typed TranslationalStateC<P> the
natural typing, because the body's ChildOf parent is the
integration-frame entity, and that entity carries a planet
phantom marker. Concretely:
- A planet's frame entity carries
Frame<P: Planet>marker (e.g.,Frame<Earth>). Currently this is implicit in thePlanetC<P>component on the source entity; it can be added to the source's frame entity at registration. - A body's
TranslationalStateC<P>reads its phantom from its parent frame's planet marker. For root-integrated bodies, the parent is the root frame entity carrying the singletonFrame<RootInertial>marker; the body's state types asTranslationalStateC<RootInertial>. - For
IntegSourceC(Some(earth))bodies, the parent is Earth's frame entity; the body's state types asTranslationalStateC<PlanetInertial<Earth>>.
Sequencing — the typed state lands after the frame-tree migration, in three steps:
-
Migrate frame tree to ECS-native (steps in Section 13
below).
TranslationalStateCstays<RootInertial>-tagged with the same documented caveat as today. -
Add
Frame<P>markers to source frame entities at registration. AddFrame<RootInertial>to the root frame entity. Updatebevy_parity_*tests if any fail by reaching into a now-typed marker. (Minimal change; just adds info.) -
Type
TranslationalStateCover its parent frame's planet —TranslationalStateC<P: Frame>parameterized by parent. Compile errors guide the migration of every shift-vs-non-shift site. The shift-site / non-shift-site table from RF.10 becomes the type-error punch list:
| Consumer category | Reads | Phantom needed (post-#263) |
|---|---|---|
| Gravity (shift) | body_pos + integ_origin |
Position<RootInertial> after to_inertial
|
| Relativistic | same | same |
SRP sun_to_vehicle
|
same | same |
| Solar beta | same | same |
| Earth lighting | same | same |
| Atmosphere (non-shift) | body's pos in planet's pfix |
Position<PlanetInertial<P>> directly |
| Drag velocity | same | same |
| LVLH | body's pos in planet's inertial | same |
| Geodetic | same | same |
| Orbital elements | same | same |
The typed-state PR is a separate landing — but it does nothing until the frame-tree ECS migration finishes the parent-as-frame-entity work, because that's where the typed phantom comes from.
For each named Tier 3 test, what could regress under the migration and what guards it:
| Test | Risk | Guard |
|---|---|---|
tier3_apollo8_frame_switch |
New commands.entity(...).insert(ChildOf(...)) doesn't preserve absolute body state on reparent (unlike FrameTree::reparent, which recomputes relative state). |
Step 4 (Section 13): rewrite frame_switch_system reads body state via RelativeFrameState, computes new relative state explicitly via rel.relative_state(new_parent, body), writes that to TranslationalStateC before reparenting. Same math as the arena's reparent. |
tier3_sim_mars_orbit |
FrameRotC / FrameAngVelC writes from planet_fixed_rotation_system are not bit-identical to the arena pfix-node writes. |
Prototype validates bit-identity for Earth (test pfix_rot_arena_matches_ecs_after_step). Mars / Moon use the same code path; risk is identical and small. Add a parallel parity test for Mars during step 3 (Section 13). |
tier3_sim_polar_motion |
RNP's polar-motion contribution is folded into the matrix produced by compute_t_parent_this_from_tjt_with_polar. The new write path takes the same matrix; the ECS-side write must preserve all components. |
Same as above — the matrix is opaque; bit-identity carries through. |
tier3_sim_shadow_2a |
Cross-frame compute_relative_state precision through pfix is lost when re-routed through RelativeFrameState's ECS hierarchy walk. |
Prototype validates this through relative_frame_state_matches_arena_through_pfix (bit-identical via pfix intermediary). The composition math is the same in both paths. |
The prototype's bit-identity tests are the structural protection. A stricter check — running the existing Tier 3 tests with gravity / integration / frame-switch systems rewritten to read through the SystemParams — lands as part of step 4 in Section 13.
Five PRs, each landable independently with a clear exit criterion. Steps 1-3 are the frame-tree migration; step 4 is the final cutover; step 5 closes the #263 tie-in.
All five PRs have landed. PR 4 in particular shipped as
#280 (rewrite
frame_switch_system, remove FrameTreeR Resource and *FrameIdC
components, extend SourceMutator); PR 5 shipped as
#263 (type
TranslationalStateC<P: Planet> end-to-end).
Scope:
- Components
FrameTransC,FrameRotC,FrameAngVelC,*FrameMarker,FrameEntityC,PfixFrameEntityC. -
RootFrameEntityRresource. -
FrameStoragetrait + sharedcompose_to_ancestor/common_ancestor/compute_relative_statealgorithms inastrodyn_frames::frame_storage.impl FrameStorage for FrameTreein the same module. -
RelativeFrameStateSystemParam,impl FrameStoragefor it, delegating itsrelative_stateto the shared algorithm. - Dual-write extensions to
register_*_frames_system,sync_*_to_frame_system,planet_fixed_rotation_system. - Prototype tests asserting bit-identity (carried over from
the study branch), plus the
frame_storageunit tests asserting the shared algorithm matchesFrameTree's inherent methods.
Exit: All existing tests pass. New components and SystemParam
exposed in astrodyn_bevy::prelude so mission code can adopt them
incrementally. FrameTreeR and the five *FrameIdC components
remain.
Depends on #263: No.
Scope:
- Identify any current mission-code consumers of
Res<FrameTreeR>(most are internal). For each, rewrite to useRelativeFrameState/FrameOrigin. - Add
FrameOriginSystemParam. - Mark
FrameTreeRas#[deprecated]for mission-code use (with a doc note pointing atRelativeFrameState).
Exit: No mission-code-shaped consumer reads from
FrameTreeR. Internal systems still do.
Depends on #263: No.
Scope:
- Rewrite
gravity_computation_system,integration_system(per-stage origin interpolation), and any derived-state system that currently callsframe_origin/compute_relative_stateto use the SystemParams. - Drop the
IntegFrameIdCcomponent — bodies' integration frame comes fromQuery<&ChildOf>on the body frame entity. - Drop the
Res<FrameTreeR>parameter from those systems.
Exit: All bevy_parity_* and Tier 3 tests pass.
FrameTreeR is no longer read by gravity / integration.
Depends on #263: No (still using <RootInertial>-typed
state).
PR 4 — Rewrite frame_switch_system and remove FrameTreeR (LANDED — #280)
Scope:
- Rewrite
frame_switch_systemto usecommands.entity(frame).insert(ChildOf(...))-
RelativeFrameState(as in Section 10).
-
- Extend
SourceMutatorto cover all 5astrodyn::source_statefunctions. - Remove
FrameTreeRResource,RootFrameIdRResource, all*FrameIdCcomponents, sync systems, despawn observers. - Remove
RetiredPfixFrameIdC(Bevy entity reuse handles the case).
Exit: FrameTreeR is gone from astrodyn. All tests pass.
astrodyn_runner is unchanged.
Depends on #263: No.
Scope:
- Add
Frame<P>marker to source frame entities at registration. - Add
Frame<RootInertial>to the root frame entity. - Type
TranslationalStateC<P: Frame>over its parent frame's planet phantom. - Migrate every shift-site / non-shift-site consumer per the table in Section 11.
- Update
RF.10invariant catalog to reflect the now-structural guarantee for non-shift sites.
Exit: TranslationalStateC<RootInertial> no longer mis-types
non-root bodies. RF.10 upgrades from partial to structural
for the consumer table. #263 closes.
Depends on #263: This PR is #263's resolution for the Bevy side.
These are decisions intentionally left to the implementing PRs; the design doesn't preempt them.
-
Reflect derives: Whether
FrameTransCetc. need non-opaqueReflect(today they're#[reflect(opaque)]). If a Bevy inspector workflow needs field-level reflection, switch per-component as needed. -
Diagnostic messages: The exact wording of panic messages
in
RelativeFrameState, frame-switch reparent failures, and missing-FrameEntityC errors. Should follow CLAUDE.md "Fail Loudly" — name the broken assumption, name the input that triggered it, name the fix. -
Naming:
FrameEntityCvsFrameEntityRefvsBodyFrameRef. Pick during PR 1. -
SourceMutatorextension shape: whether the 5astrodyn::source_statefunctions become methods or stay as free functions over the mutator's components. Pick during PR 4. -
Frame<RootInertial>marker: whether to introduce aRootInertialmarker component, or to use aFrame<RootInertial>generic over the existing phantom. Pick during PR 5. -
Mass-tree integration: the existing
MassTree(crates/astrodyn_dynamics) lives in the runner'sSimulationand isn't touched by this design. See Section 15 for the articulated-bodies extension covering mass composition, force/torque routing, and attach/detach in detail. -
Stretch SystemParams:
FrameSwitchHandler,RelativeRotationalState, etc. Not introduced now; add when mission code asks.
Issue #268's primary scope is the simulator's coordinate-frame hierarchy. Mission code that builds vehicles with articulated sub-assemblies — a robotic arm, a deployable boom, a docking adapter, a separating booster — needs four things to work end-to-end:
- Body-fixed sub-trees (kinematic).
- Mass / inertia composition through the chain.
- Force / torque routing through the chain.
- Attach / detach at runtime, including momentum conservation.
Only #1 is part of #268's migration. The other three are dynamics work that lands separately. This section sketches how each lands under the ECS-native shape so reviewers can confirm the design doesn't paint future work into a corner. Where the existing codebase has working pieces, this section cites them; where it doesn't, it flags the gap as future work.
Each member of an articulated assembly becomes a frame entity,
parented through ChildOf:
root.frame (InertialFrameMarker)
└─ vehicle.frame.body (BodyFrameMarker) ← TranslationalStateC integrates here
└─ vehicle.frame.structural ← rigid offset to geometric origin
└─ arm.frame.base ← rigid mount point on vehicle
└─ arm.frame.joint_1 ← varies with joint 1 angle
└─ arm.frame.link_1 ← rigid link length
└─ arm.frame.joint_2
└─ ...
└─ arm.frame.end_effector
-
Fixed offsets (vehicle→structural, structural→arm-base,
joint→link):
FrameTransCandFrameRotCwritten once at registration, never touched. No per-tick system reads or writes. -
Variable joints: a per-joint state component (e.g.,
JointStateC { angle: f64, axis: DVec3, rate: f64 }) plus ajoint_kinematics_systemthat writesFrameRotCfrom the joint state each tick — the same patternplanet_fixed_rotation_systemuses today for pfix frames.FrameAngVelCfrom joint rate, if velocity composition matters downstream. -
End-effector pose in any reference frame is just
rel.position(reference_frame, end_effector_frame). Thecompose_to_ancestorwalk traverses the entire chain viaincr_left; the precision properties are the same as the 4-level test inframe_tree.rs(~1e-13 position, 1e-14 rotation). -
Cycle detection: Bevy's hierarchy plugin rejects
ChildOfinsertions that would create a cycle, mirroringFrameTree::reparent'sassert!(!is_descendant_of).
No new infrastructure beyond what #268 already lands.
Status today: fully implemented in crates/astrodyn_dynamics/src/mass_body.rs,
arena-shaped (MassTree { nodes: Vec<MassBody>, parent: Vec<Option<MassBodyId>>, children: Vec<Vec<MassBodyId>> }). Owned
by Simulation::mass_tree: Option<MassTree> in the runner;
no Bevy-side equivalent. The Bevy adapter exposes
MassPropertiesC snapshots on individual entities but never
recomposes through a tree.
The composition algorithm is parallel-axis (Steiner) — same shape
as compose_to_ancestor for the frame tree, just with inertia
and mass × offset² instead of position/rotation:
// astrodyn_dynamics::mass_body, paraphrased:
fn calc_composite_inertia(&mut self, id: MassBodyId) {
let cm = self.nodes[id].composite_properties.position;
let core_offset = core.position - cm;
let mut composite_inertia = core.inertia + point_mass_inertia(core.mass, core_offset);
for &child_id in self.children[id] {
let child_offset = child.composite_wrt_pstr.position - cm;
let t = child.structure_point.t_parent_this;
let rotated_inertia = t.transpose() * child.composite_properties.inertia * t;
composite_inertia += rotated_inertia + point_mass_inertia(child.composite_properties.mass, child_offset);
}
self.nodes[id].composite_properties.inertia = composite_inertia;
}ECS-native shape: a composite_mass_system that walks a
mass-tree hierarchy bottom-up (post-order) and writes
MassPropertiesC to each parent from its children's composite
masses.
Whether that hierarchy reuses the frame tree's ChildOf or lives
on its own relation deserves a precedent check, because JEOD
has decisively chosen separation.
JEOD maintains three independent TreeLinks<…> instances:
| Tree | JEOD class | What it carries |
|---|---|---|
| Reference-frame tree |
RefFrame (specialised by BodyRefFrame) |
Kinematic state (position, velocity, attitude, ang vel relative to parent). |
| Mass-body tree |
MassBody via MassBodyLinks : public TreeLinks<MassBodyLinks, MassBody, …>
|
Composite mass + inertia rolled up by the parallel-axis theorem. |
| Mass-point sub-trees (per body) |
MassPoint via MassPointLinks
|
Named structural points on a body (attach ports, sensor mounts, CoG). |
The two top-level trees are kept coherent by an explicit back-pointer on the frame side:
class BodyRefFrame : public RefFrame {
/// Pointer to the mass point that defines the origin and
/// orientation of this frame, but with respect to the mass tree
/// rather than with respect to the reference frame tree.
MassPoint * mass_point{};
...
};The separation is operational too — DynBody exposes two distinct
attach APIs:
// Full attach: mass tree + frame tree both updated.
virtual bool attach_to(point_name, parent_point_name, DynBody & parent);
// Kinematic-only attach: frame tree updated; mass tree untouched.
virtual bool attach_to_frame(RefFrame & parent);attach_to_frame exists precisely because the design hit cases
where mass and frame topology need to move independently — a body
whose kinematics ride a parent frame without contributing to that
parent's mass (sensor mounts on docking adapters, vehicles
station-keeping in a planet's pfix frame, debris tracked
kinematically after detach).
Our runner already honours the tree separation: MassTree and
FrameTree are independent arenas with no shared topology. What
the runner does not yet carry is the BodyRefFrame::mass_point
back-pointer — astrodyn_dynamics::MassBody.mass_points is a
Vec<MassPoint> per body rather than a tree, and frames have no
mass-side handle at all.
Follow the JEOD precedent:
-
Frame tree:
ChildOf(Bevy hierarchy primitive) — what #268 lands. -
Mass tree: a separate Bevy relation, e.g.
MassChildOf(Entity), on the same entities. Mass-tree algorithms walk it viaQuery<&MassChildOf>instead ofQuery<&ChildOf>. TheFrameStoragetrait pattern from Section 7 has a directMassStorageanalogue: same shape, different parent relation. -
Coupling: a back-pointer component on the body's frame
entity, e.g.
MassPointRef(Entity), mirroringBodyRefFrame::mass_point. Present when the frame entity describes the origin / orientation of a mass-body element; absent for kinematic-only attaches (theattach_to_framecase). -
Two attach paths in the Bevy adapter, mirroring JEOD:
- Full attach (
AttachEvent): mutates bothChildOfandMassChildOf, optionally writesMassPointRef. Composite mass recomputed. - Kinematic attach (a new
AttachToFrameEventor just the existingAttachEventwith a flag): mutates onlyChildOf. No mass-tree change. NoMassPointRefwritten.
- Full attach (
The trait pattern from Section 7 covers it cleanly. FrameStorage
already abstracts "give me parent + state for an Id" over
arbitrary parent edges; it just happens to default to ChildOf in
the Bevy adapter. A MassStorage trait of the same shape would
let composite_mass_system walk MassChildOf edges via the same
generic compose_to_ancestor-style algorithm — the only thing
that changes is which parent relation the impl reads.
The decision is made by JEOD's precedent: separate trees with
explicit coupling. The earlier "reuse ChildOf first cut" lean
was wrong; reusing would conflict with the kinematic-only
attachment case JEOD specifically supports.
The existing tier3_mass_attach_detach test plus a future
tier3_attach_to_frame (kinematic-only attach: mass topology
unchanged, frame topology updated) would protect both attachment
paths.
The FrameStorage trait pattern (Section 7) has a direct analogue
here: a MassStorage trait abstracting "give me the mass + offset
of node id" lets the composition algorithm move from
astrodyn_dynamics::MassTree (arena) to a Bevy MassPropertiesC query
(ECS) without rewriting the math.
Risk: low. The algorithm is well-tested in the runner; the ECS port is a mechanical translation.
Status today: not implemented in runner or Bevy in any joint-aware form.
JEOD's stable model uses composite-rigid-body integration:
when bodies are attached, only the root integrates; children are
kinematic, derived from the root via DynBody::propagate_state()
walking down the tree. The relevant code in
dyn_body_propagate_state.cc:526–600:
void DynBody::propagate_state() {
if(dyn_parent != nullptr) {
dyn_parent->propagate_state(); // pass the buck to root
return;
}
update_integrated_state(); // only the root integrates
if(integrated_frame == &structure) {
propagate_state_from_structure(); // root → composite → core → children
}
}
void DynBody::propagate_state_from_structure() {
// Compose this body's structure state into composite/core states.
// Then for each child:
for (auto child : dyn_children) {
compute_derived_state_forward(structure,
child->mass.structure_point,
child->structure);
child->propagate_state_from_structure(); // recursive
}
}JEOD's stable code has zero joint, spring, damper, constraint,
or articulation primitives. A grep across models/dynamics/ for
those terms hits docs/specs and one experimental model only.
The experimental model (models/experimental/constraints/) does
exist, but its README.txt is explicit: it's an unfinished stub
for fuel slosh — fluid in tanks modeled as constrained
pendulums, not general articulation. "There is currently no known
S_define that integrates the various source code files in a
structured fashion nor a working input file." The component
catalogue (PendulumConstraintComponent, ForceConstraintComponent,
BasePendulumModel, …) is pendulum-shaped, not Featherstone-shaped.
Implication: JEOD makes the algorithm choice for us — the default is composite-rigid-body, matching JEOD stable. The "Featherstone or composite-rigid-body?" question has an answer. General-purpose joint dynamics (a robotic arm with relative joint motion under control) are out of JEOD's scope; mission code that needs that today either treats the arm kinematically (drives joint angles, no inverse dynamics) or builds joint primitives from scratch.
A propagate_state_from_root_system running after
integration_system walks the ChildOf hierarchy root→leaves,
mirroring DynBody::propagate_state_from_structure:
// Sketch — composite-rigid-body kinematic propagation.
fn propagate_state_from_root_system(
rel: RelativeFrameState,
mut bodies: Query<(&FrameEntityC, &mut TranslationalStateC, ...), With<BodyMarker>>,
children: Query<&Children>,
roots: Query<Entity, (With<BodyMarker>, Without<ChildOf>)>,
) {
// For each root body, walk children depth-first; for each child
// body, compute its kinematic state from its parent's integrated
// state plus the rigid attach offset stored in the child's frame
// entity (FrameTransC + FrameRotC, written once at attach).
// Only root bodies have non-trivial TranslationalStateC after
// integration; child bodies are kinematic.
}For the force-collection side, JEOD's machinery is
BodyWrenchCollect / BodyForceCollect on the
StructureIntegratedDynBody root. Children's forces are accumulated
into the root's wrench via the structure transform. ECS analogue:
forces continue to be applied to individual body entities
(e.g. drag on a panel), but during force collection a
wrench_aggregation_system walks ChildOf leaves→root, transforms
each child's force/torque into the root's structural frame using
rel.relative_state, and adds it to the root's TotalForceC /
Torque. Only the root's wrench enters the integrator.
Updated open questions (much smaller set):
-
What about the kinematic-only attach case (
attach_to_frame, Section 15.2)? A body attached kinematically to a parent frame but not to a parent body still integrates independently — its forces shouldn't aggregate into the frame's "owner". The wrench_aggregation_system must respectMassChildOf(Section 15.2) notChildOffor the upward force walk. Frame-only attachments carry no mass coupling, and so no force coupling either. - Slosh-style constrained pendulums are a real future need for realistic vehicle modeling. Following JEOD's pattern, they land as an experimental module on top of (not embedded in) the composite-rigid-body design. Out of scope for the issue that builds 15.3 itself.
Risk: lower than originally assessed. JEOD's stable design
gives both the algorithm (composite-rigid-body propagation) and
the answer to "no joints" (mission code uses ExternalForceC /
ExternalTorqueC on individual children; the system aggregates
their wrenches into the root). The dynamics issue that lands 15.3
is a JEOD port, not a fresh design. Bounded by Tier 3 tests against
JEOD's tier3_mass_attach_detach and any future multi-body
verification SIMs.
Status today: partial.
-
Runner:
attach_subtree_aligned+combine_states_at_attachconserve momentum across attach.detach_subtreepopulates aDetachedSubtreeState { composite_position, composite_velocity, composite_attitude, composite_ang_vel_body }that propagates ballistically viastep_ballistic. -
Bevy:
AttachEvent/DetachEventMessages exist;staging_systemcallsMassTree::attach/detachand syncsMassPropertiesC. No momentum conservation, no detached-subtree tracking, and no frame-tree mutation — the child'sBodyFrameIdCcontinues to point at its old arena node.
ECS-native shape under #268: this is where the design pays the biggest dividend. Frame-tree mutation collapses to a single hierarchy operation, and the existing runner logic ports cleanly.
// On AttachEvent { child, parent, offset, t_parent_child }:
//
// 1. Frame tree (#268): reparent the child's frame entity.
commands.entity(child_frame_entity).insert(ChildOf(parent_frame_entity));
// Bevy's hierarchy plugin updates Children automatically.
// The child's frame entity must be re-pinned with the new
// structural offset:
commands.entity(child_frame_entity).insert(FrameTransC { position: offset, .. });
commands.entity(child_frame_entity).insert(FrameRotC { t_parent_this: t_parent_child, .. });
//
// 2. Mass tree (15.2): port from runner — mass_tree.attach(child_id, parent_id, offset, t_parent_child)
// becomes a system that walks the ChildOf hierarchy and recomputes
// composite mass bottom-up.
//
// 3. Momentum conservation (port `combine_states_at_attach`):
// cache the parent's pre-attach RotationalStateC + child's pre-attach
// SixDofState; compute the new composite rotational state preserving
// angular momentum about the new composite CoM. Write back to the
// parent body entity.
//
// 4. If the child was previously a DetachedSubtreeState (subtree free
// flight), drop that component on the child entity.
// On DetachEvent { child }:
//
// 1. Capture pre-detach inertial state of the child via
// rel.relative_state(root_frame, child_frame_entity)
// + the child's own RotationalStateC.
// 2. Reparent the child's frame entity to the root frame:
commands.entity(child_frame_entity).insert(ChildOf(root_frame_entity));
// or to a "free-floating debris" parent, depending on what the
// mission wants.
// 3. Insert a DetachedSubtreeStateC component on the child entity
// carrying the captured pos/vel/attitude/ang_vel for ballistic
// propagation.
// 4. Mass tree: mass_tree.detach(child_id) recomputes parent
// composite mass bottom-up.
// 5. Adjust the parent's composite-CoM-shift bookkeeping (the
// `ω × Δr_struct` correction the runner already does).The frame-tree half is one line each
(commands.entity(...).insert(ChildOf(...))). The mass-tree and
momentum-conservation halves port the runner's existing logic
into systems that read / write Bevy components. The
DetachedSubtreeState becomes a DetachedSubtreeStateC Component
on the detached entity, integrated forward by a step_detached_system
(direct port of step_ballistic).
JEOD's attach/detach is synchronous and includes a step our Bevy
handler currently misses. From dyn_body_attach.cc:854–862:
// "Even this simple attachment may cause a jolt to state
// integration, so reset integrators for this body."
mass.attach_update_properties(...);
root_body->set_state_source_internal(RefFrameItems::Pos_Vel_Att_Rate, root_body->structure);
root_body->propagate_state();
get_dynamics_integration_group()->reset_integrators();And the same pattern appears in dyn_body_detach.cc:271–273. The
reset_integrators() call is load-bearing: multi-stage integrators
(Gauss-Jackson, ABM4) carry per-body bootstrap state — back-step
buffers, predictor history — that becomes invalid when the
composite mass changes mid-flight. Without the reset, GJ's
predictor steps point at the pre-attach dynamics, and the
post-attach trajectory carries an integration jolt that compounds
over the reboot window.
Today's Bevy adapter does not reset integrator state on attach
or detach. staging_system mutates MassTree and syncs
MassPropertiesC, but GaussJacksonStateC and Abm4StateC
remain untouched. Any body that attaches mid-flight while
integrating with one of those propagators carries stale predictor
history. This is a real gap that needs to land alongside the Bevy
port of combine_states_at_attach.
The atomicity contract this implies for the ECS-native shape:
mass-tree update + frame-tree update + integrator reset all
complete before AstrodynSet::Integration. Implementation is
straightforward — all three writes happen in staging_system
before AstrodynSet::Integration, so the existing message-based
attach handler is fine if it adds the integrator-reset writes.
The "messages vs immediate Commands" sub-question gets answered:
messages are fine because they're consumed early enough; the
atomicity is between steps, not within commands.
Step 5 of the AttachEvent handler sketch above grows to:
// 5. Reset integrator state on root body (mirroring JEOD's
// reset_integrators()). Per-integrator: GJ clears its back-step
// buffer, ABM4 marks itself as needing rebootstrap, RK4 needs
// nothing (single-step).
commands.entity(root_body_entity)
.insert(GaussJacksonStateC::needs_rebootstrap()) // sentinel
.insert(Abm4StateC::needs_rebootstrap()); // sentinelRisk: low for the frame-tree half, low-medium for the
mass-tree + momentum half (mechanical port of tested runner code).
The integrator-reset gap is small but non-trivial — it touches the
GJ / ABM4 bootstrap logic and needs a bevy_parity_mass_attach_detach_with_gj
test that exercises the failure mode (attach mid-GJ, verify no
post-attach jolt against the runner).
Test protection: tier3_mass_attach_detach is the existing
runner-side test. The Bevy port would need
bevy_parity_mass_attach_detach (basic momentum + frame state)
plus the integrator-reset variant.
These are explicitly not part of #268 and would land as separate issues:
-
Constraint / joint forces — springs, dampers, revolute joints
at configured angles with constraint reaction forces. JEOD's
stable model has no joint primitives at all; the experimental
models/experimental/constraints/model is a fuel-slosh stub (constrained pendulums for fluid in tanks), not general articulation. Mission code today applies joint-equivalent forces viaExternalForceC/ExternalTorqueC. A future issue could introduce aJointForceCor, following JEOD's experimental pattern, a slosh-style constrained-pendulum module on top of the composite-rigid-body design. - Inverse kinematics / trajectory generation for arms. Mission- code concern; out of physics scope.
- Multi-body contact beyond the existing ground-contact model (#88). Separate issue.
-
Sensor frames as named ports on bodies (JEOD
MassPoints). Naturally maps to frame entities under the body, but the ergonomic surface for declaring them ("body X has a sensor port namednav_camera1.2m forward of CoG") is its own design. Not load-bearing for #268.
| Subsystem | Solved by #268? | Risk under ECS-native shape |
|---|---|---|
| 15.1 body-fixed kinematic chain | Yes — direct application of frame-tree migration | None |
| 15.2 mass / inertia composition | No (separate dynamics issue) | Low — mechanical port of runner-tested algorithm |
| 15.3 force / torque routing | No (separate dynamics issue) | Low-medium — JEOD precedent dictates composite-rigid-body (DynBody::propagate_state); algorithm is a port, not fresh design |
| 15.4 attach / detach + momentum | Partial (frame-tree half is a one-liner) | Low — runner has it; ECS port is mechanical. New gap surfaced: integrator state (GJ / ABM4) must be reset on attach (matches JEOD's reset_integrators()); not done today. |
The frame-tree migration is a prerequisite for clean attach/detach and a strong fit for body-fixed kinematic chains, but it does not itself implement chain dynamics. The path forward is plausible: each piece either ports existing tested code or maps to a small new system on top of the ECS hierarchy.
This appendix enumerates every item the design doc flags as unimplemented (in either the runner or the Bevy adapter) or deliberately out of scope for #268, and maps each to an existing tracker issue where one exists. It exists so that "things this design assumes will be done elsewhere" don't quietly fall on the floor when implementation begins.
Status legend:
- #268 — delivered by the migration in Section 13 (PR 1–5).
- Tracked — open issue exists; cite the number.
- Untracked — flagged here, no open issue covers it; needs a new issue or has to bundle into an adjacent one.
- Out of scope (no tracker needed) — explicitly deferred, not on any near-term roadmap.
These are the deliverables of Section 13's PR sequence. All landed.
| Item | Section | Status |
|---|---|---|
FrameTransC, FrameRotC, FrameAngVelC components |
5, 13 PR 1 | LANDED |
*FrameMarker marker components |
5, 13 PR 1 | LANDED |
FrameEntityC, PfixFrameEntityC, RootFrameEntityR
|
5, 13 PR 1 | LANDED |
FrameStorage trait + shared algorithms in astrodyn_frames::frame_storage
|
7, 13 PR 1 | LANDED |
RelativeFrameState SystemParam
|
6, 13 PR 1 | LANDED |
FrameOrigin SystemParam
|
6, 13 PR 2 | LANDED |
| Dual-write extensions to existing registration / sync / pfix-rotation systems | 9, 13 PR 1 | LANDED (since removed at PR 4) |
Rewrite gravity_computation_system / integration_system to drop FrameTreeR reads |
9, 13 PR 3 | LANDED |
Rewrite frame_switch_system to use commands.entity(...).insert(ChildOf(...))
|
10, 13 PR 4 | LANDED (#280) |
Extend SourceMutator to cover astrodyn::source_state's 5 functions |
6, 13 PR 4 | LANDED (#280) |
Remove FrameTreeR Resource, RootFrameIdR, all *FrameIdC components, sync systems, despawn observers |
9, 13 PR 4 | LANDED (#280) |
| Item | Section | Status |
|---|---|---|
Frame<P> markers on source frame entities + Frame<RootInertial> on root |
11, 13 PR 5 | LANDED (#263) |
TranslationalStateC<P> typed over parent frame's planet |
11, 13 PR 5 | LANDED (#263) |
| Migration of every shift-vs-non-shift consumer per the RF.10 table | 11, 13 PR 5 | LANDED (#263) |
RF.10 catalog upgrade from partial to structural for non-shift sites |
11, 13 PR 5 | LANDED (see docs/JEOD_invariants.md) |
PR 5 of #268's migration is effectively the resolution path for #263's Bevy-component-genericity items.
These extend the design beyond #268's core scope. Some are tracked; several are not.
| Item | Section | Status |
|---|---|---|
Body-fixed kinematic chains (frame entities + ChildOf chains) |
15.1 | LANDED with #268 |
joint_kinematics_system writing variable-joint FrameRotC from joint state |
15.1 | LANDED — see crates/astrodyn_bevy/src/systems/ (constant-rate, sinusoidal, closure, multi-DOF drivers) |
Bevy-side mass-tree (frames-as-entities for mass) — MassChildOf relation, mass-side queries |
15.2 | LANDED (#308) |
MassPointRef(Entity) back-pointer (mirrors BodyRefFrame::mass_point) |
15.2 | LANDED |
MassStorage trait (analogue of FrameStorage) + composite_mass_system
|
15.2 | LANDED — see crates/astrodyn_bevy/src/mass_tree.rs
|
| Two attach paths in Bevy adapter (full vs kinematic-only) | 15.2, 15.4 | LANDED (#198, #206, and the cross-integ-frame attach work in #299/#312/#314/#319/#350/#381) |
Composite-rigid-body propagation (propagate_state_from_root_system, port of DynBody::propagate_state_from_structure) |
15.3 | LANDED — see crates/astrodyn_bevy/src/kinematic_propagation.rs
|
wrench_aggregation_system (port of BodyWrenchCollect / BodyForceCollect) |
15.3 | LANDED — see crates/astrodyn_bevy/src/wrench.rs
|
Bevy port of combine_states_at_attach (momentum conservation across attach) |
15.4 | LANDED |
DetachedSubtreeStateC Component + step_detached_system (port of step_ballistic) |
15.4 | LANDED — see step_detached_system::<P>
|
Frame-tree mutation in attach handler (Bevy staging_system does not currently touch FrameTreeR) |
15.4 | LANDED with PR 4 |
| Integrator reset on attach (GJ / ABM4 stale-bootstrap on topology change) — JEOD-precedent gap | 15.4 | LANDED — frame_attach_system resets multi-step integrator history |
bevy_parity_mass_attach_detach parity test |
15.4 | LANDED — see crates/astrodyn_verif_parity/tests/
|
These are explicitly deferred in the design doc, with rationale. Listed so the gaps are visible; not requesting trackers.
| Item | Where flagged | Notes |
|---|---|---|
| Constraint / joint forces (springs, dampers, revolute joints with reaction forces) | 15.5 | JEOD has zero stable-model joint primitives; the experimental constraints model (models/experimental/constraints/) is an unfinished fuel-slosh stub. Mission code applies joint-equivalent forces via ExternalForceC / ExternalTorqueC. |
| Featherstone or articulated-body-algorithm dynamics | 15.3 | JEOD precedent decides this — composite-rigid-body only. |
| Slosh-style constrained-pendulum module | 15.3, 15.5 | A future module on top of composite-rigid-body, following JEOD's experimental pattern, if anyone needs it. |
| Inverse kinematics / trajectory generation for arms | 15.5 | Mission-code concern, not physics scope. |
| Multi-body contact beyond ground-contact (#88) | 15.5 | Tier 3 ground-contact landed in #88; further multi-body contact is a separate dynamics workstream. |
Sensor frames as named ports on bodies (JEOD MassPoint with names) |
15.5 | Maps naturally to frame entities under the body, but the ergonomic surface for declaring named ports is its own design. |
BodyRefFrame::mass_point back-pointer in the runner (currently absent — runner uses Vec<MassPoint>, not a sub-tree) |
15.2 | Discovered while reading JEOD; runner-side limitation, not on the Bevy adapter critical path. |
These are choices the design doc explicitly leaves to the implementing PR rather than committing up front. None need trackers — they're resolved when the PR lands.
- Reflect derive style (
#[reflect(opaque)]vs field-level) - Exact wording of panic / diagnostic messages (must follow CLAUDE.md "Fail Loudly")
- Component naming (
FrameEntityCvsFrameEntityRefvsBodyFrameRef;*Markersuffix vs other conventions) - Whether
SourceMutatorextension uses methods or free functions - Shape of the
Frame<RootInertial>marker (component vs phantom) - Stretch SystemParams (
FrameSwitchHandler,RelativeRotationalState, …): added on demand only
Not direct gaps, but worth surfacing because the implementing PRs may bundle:
| Issue | Title | Why it touches this design |
|---|---|---|
| #198 | Implement frame-attached body integration (Phase 5 DB.13–DB.21 deferred invariants) | Closes the kinematic-only attach path (JEOD's attach_to_frame). Issue body cites SIM_dyncomp RUN_attach_to_ref_frame cross-validation. |
| #206 | Implement reference-frame attachment (SIM_ref_attach parity) | Same workstream as #198; #206's body explicitly notes "likely a single PR can close B1.1 + B1.9". |
| #199 | Support dynamic body-action lifecycle (SIM_removable_body_action parity) | Touches the attach/detach API surface (Bevy Commands-based body-action insertion). |
| #163 | Pre-1.0 API audit of astrodyn_runner::Simulation | The SourceMutator extension (A.1, PR 4) and the runner-side BodyRefFrame::mass_point gap (A.4) are natural items for this audit. |
| #65 | Performance optimizations (batch staging recompute, preallocate coupled integrator buffers) | The integrator-reset gap (A.3, NEW) interacts: a system that resets GJ/ABM4 state on topology change must coexist with batched staging. |
The five distinct workstreams flagged at design time have all since been completed. Listed here for historical traceability:
-
Mass-tree ECS migration — LANDED via
#308. Lives in
crates/astrodyn_bevy/src/mass_tree.rsandcomponents/mass_tree.rs(theMassChildOfrelation,MassPointRef,MassStoragetrait, andcomposite_mass_system). -
Composite-rigid-body propagation + wrench aggregation in Bevy —
LANDED.
propagate_state_from_root_systemlives incrates/astrodyn_bevy/src/kinematic_propagation.rs;wrench_aggregation_systemlives incrates/astrodyn_bevy/src/wrench.rs(with the per-body collapse refactored under #358 and #363). - Bevy attach/detach: momentum conservation + ballistic subtree tracking — LANDED. Cross-integ-frame attach via #299/#312/#314/#319/#350; the runner-side cross-integ-frame attach kernel landed in #381; staging-system simplification in #357.
-
Integrator-state reset on attach — LANDED.
frame_attach_systemresets multi-step integrator history (Gauss–Jackson, ABM4) when topology changes. -
Joint-kinematics driver for variable joints — LANDED. Four
sibling drivers live in
crates/astrodyn_bevy/src/systems/: constant-rate, sinusoidal, closure, multi-DOF, all wired intoAstrodynSet::EphemerisUpdatewith pairwise-disjoint exclusivity enforced viaWithout<...>filters andon_inserthooks.
Prototype branch: study/268-frame-tree-ecs-native
Prototype tests: tests/prototype_relative_frame_state.rs
Frame-switch sketch: src/frame_param.rs#frame_switch_system_ecs_native_sketch