Skip to content

Frame Tree ECS Native

Test User edited this page May 9, 2026 · 7 revisions

Frame Tree ECS-Native Shape — Design (issue #268)

Status (May 2026): the migration described in Section 13 is complete. PRs 1–4 of the migration sequencing landed, removing FrameTreeR, the five *FrameIdC components, and the two sync_*_to_frame_system arena-mirror systems. Frames are Bevy entities; cross-frame state goes through the RelativeFrameState / FrameOrigin SystemParams; the shared astrodyn::FrameStorage trait factors the read-only graph walks for both the Bevy adapter and astrodyn_runner's arena. PR 5 (the Frame<P> typed-state work in #263) landed via the typed TranslationalStateC<P: Planet> end-to-end migration in #263. The articulated- bodies extension (Section 15) has also progressed substantially: the mass-tree ECS migration with MassChildOf + composite_mass_system shipped 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.

1. Context & scope

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 branchstudy/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 new RelativeFrameState SystemParam (ECS).
  • All 115 existing bevy_parity_* tests stay green under the prototype's dual-write infrastructure.
  • A compile-only frame_switch_system_ecs_native_sketch shows the full Sketch 2 below is real Rust.

2. Bevy-native goal

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 / Children relationship (Bevy ≥ 0.16 renamed Parent to ChildOf). Reparenting on a frame switch is commands.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 via With<…>. Replaces the runtime RefFrameKind enum on the arena side.
  • Cross-frame operations expose as Bevy SystemParamsRelativeFrameState, FrameOrigin. Mission code passes Entity handles and never sees a FrameId, the arena, or any trait method.
  • FrameTreeR and the five *FrameIdC components disappear from the user-facing surface. astrodyn_runner keeps its arena (it has no World); 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).

3. Constraints

Three-layer rule (CLAUDE.md, verbatim)

All physics lives in astrodyn_* crates (pure Rust, zero Bevy dependency). Orchestration lives in astrodyn (composes astrodyn_* functions into pipeline stages, re-exports all types; zero Bevy dependency). Bevy wiring lives in the astrodyn root package (src/ — thin glue: component derives, systems that delegate to astrodyn functions, plugin registration).

The root package depends only on astrodyn + bevy — never on astrodyn_* 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.

RF.10 (docs/JEOD_invariants.md, verbatim)

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 makes body - sun_pos refuse to compile, forcing to_inertial(&o)); convention for non-shift sites (consumer takes raw DVec3, frame correctness depends on runtime invariant body.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).

Tier 3 regression surface

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 half-baked"

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.

4. Current architecture summary

                ┌──────────────────────────────────────────────────┐
                │             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.

5. Target ECS shape — per-state mapping

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 RefFrameState are independently mutated. FrameTransC is rewritten by integration / source-state updates; FrameRotC by pfix rotation; FrameAngVelC alongside 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 with astrodyn's phantom-frame types (BodyFrame, PlanetFixed, IntegrationFrame).

6. Mission-code surface — SystemParam catalog (load-bearing)

Three Bevy SystemParams replace the entire mission-code reach into the frame tree.

RelativeFrameState<'w, 's> (prototype landed)

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 / After diff (prototype branch evidence)

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
}

AfterRelativeFrameState 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.

FrameOrigin<'w, 's> (planned)

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.

SourceMutator<'w, 's> (extended in this design)

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.

Catalog scope discipline

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.

7. Internal algorithm sharing

The factoring split has three layers:

  1. Pure state math stays in astrodyn_frames unchanged (RefFrameState::incr_left, incr_right, negate, plus the underlying RefFrameTrans / RefFrameRot ops). Pure functions on RefFrameState pairs with no tree storage involved. Both consumers reuse them.
  2. Tree-walking algorithms (compose_to_ancestor, common_ancestor, compute_relative_state) are factored over a thin internal FrameStorage trait in astrodyn_frames::frame_storage, generic over the storage's Id type. One implementation; each backend implements the trait:
    • astrodyn_runner's arena: impl FrameStorage for FrameTree { type Id = FrameId; … } (unchanged signatures elsewhere).
    • astrodyn's RelativeFrameState SystemParam: impl FrameStorage for RelativeFrameState<'_, '_> { type Id = Entity; … }. Internally walks Query<&ChildOf> + the new state-component queries; from the trait's perspective, "give me the parent of this id" is the same operation either way.
  3. Mutation (reparent) stays native per consumer. The arena does it directly; the Bevy adapter does it via commands.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.

Why the trait, after initially declining it

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_state collapses 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 of impl FrameStorage trait 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.

Mission-code visibility (none)

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.

What stays per-consumer

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.

8. astrodyn_runner strategy

Decision: astrodyn_runner keeps its arena unchanged.

  • The Simulation struct in crates/astrodyn_runner/src/simulation/mod.rs is a monolithic owner (frame_tree: FrameTree, bodies: Vec<SimBody>, etc.). Moving it onto a Bevy World would 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::World would 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 to astrodyn_runner are 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.

9. System consolidation

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.

10. Frame-switch flow under the new shape

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.

11. #263 tie-in (in scope)

#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 the PlanetC<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 singleton Frame<RootInertial> marker; the body's state types as TranslationalStateC<RootInertial>.
  • For IntegSourceC(Some(earth)) bodies, the parent is Earth's frame entity; the body's state types as TranslationalStateC<PlanetInertial<Earth>>.

Sequencing — the typed state lands after the frame-tree migration, in three steps:

  1. Migrate frame tree to ECS-native (steps in Section 13 below). TranslationalStateC stays <RootInertial>-tagged with the same documented caveat as today.
  2. Add Frame<P> markers to source frame entities at registration. Add Frame<RootInertial> to the root frame entity. Update bevy_parity_* tests if any fail by reaching into a now-typed marker. (Minimal change; just adds info.)
  3. Type TranslationalStateC over its parent frame's planetTranslationalStateC<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.

12. Tier 3 risk register

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.

13. Migration sequencing

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).

PR 1 — Land the prototype as additive infrastructure (LANDED)

Scope:

  • Components FrameTransC, FrameRotC, FrameAngVelC, *FrameMarker, FrameEntityC, PfixFrameEntityC.
  • RootFrameEntityR resource.
  • FrameStorage trait + shared compose_to_ancestor / common_ancestor / compute_relative_state algorithms in astrodyn_frames::frame_storage. impl FrameStorage for FrameTree in the same module.
  • RelativeFrameState SystemParam, impl FrameStorage for it, delegating its relative_state to 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_storage unit tests asserting the shared algorithm matches FrameTree'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.

PR 2 — Rewrite mission-facing read paths (LANDED)

Scope:

  • Identify any current mission-code consumers of Res<FrameTreeR> (most are internal). For each, rewrite to use RelativeFrameState / FrameOrigin.
  • Add FrameOrigin SystemParam.
  • Mark FrameTreeR as #[deprecated] for mission-code use (with a doc note pointing at RelativeFrameState).

Exit: No mission-code-shaped consumer reads from FrameTreeR. Internal systems still do.

Depends on #263: No.

PR 3 — Rewrite internal physics systems (LANDED)

Scope:

  • Rewrite gravity_computation_system, integration_system (per-stage origin interpolation), and any derived-state system that currently calls frame_origin/compute_relative_state to use the SystemParams.
  • Drop the IntegFrameIdC component — bodies' integration frame comes from Query<&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_system to use commands.entity(frame).insert(ChildOf(...))
    • RelativeFrameState (as in Section 10).
  • Extend SourceMutator to cover all 5 astrodyn::source_state functions.
  • Remove FrameTreeR Resource, RootFrameIdR Resource, all *FrameIdC components, 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.

PR 5 — Type TranslationalStateC over parent frame (#263) (LANDED)

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.10 invariant 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.

14. Open questions deferred to implementation

These are decisions intentionally left to the implementing PRs; the design doesn't preempt them.

  • Reflect derives: Whether FrameTransC etc. need non-opaque Reflect (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: FrameEntityC vs FrameEntityRef vs BodyFrameRef. Pick during PR 1.
  • SourceMutator extension shape: whether the 5 astrodyn::source_state functions become methods or stay as free functions over the mutator's components. Pick during PR 4.
  • Frame<RootInertial> marker: whether to introduce a RootInertial marker component, or to use a Frame<RootInertial> generic over the existing phantom. Pick during PR 5.
  • Mass-tree integration: the existing MassTree (crates/astrodyn_dynamics) lives in the runner's Simulation and 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.

15. Articulated-bodies extension (beyond #268's core scope)

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:

  1. Body-fixed sub-trees (kinematic).
  2. Mass / inertia composition through the chain.
  3. Force / torque routing through the chain.
  4. 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.

15.1 Body-fixed sub-trees (kinematic) — solved by #268

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): FrameTransC and FrameRotC written 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 a joint_kinematics_system that writes FrameRotC from the joint state each tick — the same pattern planet_fixed_rotation_system uses today for pfix frames. FrameAngVelC from joint rate, if velocity composition matters downstream.
  • End-effector pose in any reference frame is just rel.position(reference_frame, end_effector_frame). The compose_to_ancestor walk traverses the entire chain via incr_left; the precision properties are the same as the 4-level test in frame_tree.rs (~1e-13 position, 1e-14 rotation).
  • Cycle detection: Bevy's hierarchy plugin rejects ChildOf insertions that would create a cycle, mirroring FrameTree::reparent's assert!(!is_descendant_of).

No new infrastructure beyond what #268 already lands.

15.2 Mass / inertia composition

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 precedent: two trees, explicit coupling

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.

What this implies for the ECS-native shape

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 via Query<&MassChildOf> instead of Query<&ChildOf>. The FrameStorage trait pattern from Section 7 has a direct MassStorage analogue: same shape, different parent relation.
  • Coupling: a back-pointer component on the body's frame entity, e.g. MassPointRef(Entity), mirroring BodyRefFrame::mass_point. Present when the frame entity describes the origin / orientation of a mass-body element; absent for kinematic-only attaches (the attach_to_frame case).
  • Two attach paths in the Bevy adapter, mirroring JEOD:
    • Full attach (AttachEvent): mutates both ChildOf and MassChildOf, optionally writes MassPointRef. Composite mass recomputed.
    • Kinematic attach (a new AttachToFrameEvent or just the existing AttachEvent with a flag): mutates only ChildOf. No mass-tree change. No MassPointRef written.

What this implies for the algorithm sharing layer

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.

Recommendation

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.

15.3 Force / torque routing through articulated chains

Status today: not implemented in runner or Bevy in any joint-aware form.

JEOD precedent: composite-rigid-body, no joints

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.

ECS-native shape under JEOD's precedent

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 respect MassChildOf (Section 15.2) not ChildOf for 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.

15.4 Attach / detach at runtime

Status today: partial.

  • Runner: attach_subtree_aligned + combine_states_at_attach conserve momentum across attach. detach_subtree populates a DetachedSubtreeState { composite_position, composite_velocity, composite_attitude, composite_ang_vel_body } that propagates ballistically via step_ballistic.
  • Bevy: AttachEvent / DetachEvent Messages exist; staging_system calls MassTree::attach / detach and syncs MassPropertiesC. No momentum conservation, no detached-subtree tracking, and no frame-tree mutation — the child's BodyFrameIdC continues 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 precedent: synchronous attach + integrator reset

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());          // sentinel

Risk: 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.

What this section deliberately does not solve

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 via ExternalForceC / ExternalTorqueC. A future issue could introduce a JointForceC or, 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 named nav_camera 1.2m forward of CoG") is its own design. Not load-bearing for #268.

Summary

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.

Appendix A — Open work register

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.

A.1 Items delivered by the #268 migration itself

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)

A.2 Items #268 enables but doesn't deliver — typed state

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.

A.3 Items in the articulated-bodies extension (Section 15)

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/

A.4 Items deliberately out of scope (no tracker needed)

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.

A.5 PR-implementation-time decisions (Section 14)

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 (FrameEntityC vs FrameEntityRef vs BodyFrameRef; *Marker suffix vs other conventions)
  • Whether SourceMutator extension uses methods or free functions
  • Shape of the Frame<RootInertial> marker (component vs phantom)
  • Stretch SystemParams (FrameSwitchHandler, RelativeRotationalState, …): added on demand only

A.6 Adjacent open issues that may close together with #268's articulated extensions

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.

A.7 Summary of items that originally needed new tracker issues

The five distinct workstreams flagged at design time have all since been completed. Listed here for historical traceability:

  1. Mass-tree ECS migration — LANDED via #308. Lives in crates/astrodyn_bevy/src/mass_tree.rs and components/mass_tree.rs (the MassChildOf relation, MassPointRef, MassStorage trait, and composite_mass_system).
  2. Composite-rigid-body propagation + wrench aggregation in Bevy — LANDED. propagate_state_from_root_system lives in crates/astrodyn_bevy/src/kinematic_propagation.rs; wrench_aggregation_system lives in crates/astrodyn_bevy/src/wrench.rs (with the per-body collapse refactored under #358 and #363).
  3. 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.
  4. Integrator-state reset on attach — LANDED. frame_attach_system resets multi-step integrator history (Gauss–Jackson, ABM4) when topology changes.
  5. 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 into AstrodynSet::EphemerisUpdate with pairwise-disjoint exclusivity enforced via Without<...> filters and on_insert hooks.

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

Clone this wiki locally