Skip to content

ADR-077 — /fly throne-of-glory iconic-shot architecture (body wiring + cislunar hold detector + per-event compositions + race-free test hooks)

Status · Accepted Date · 2026-06-14 Gates · v0.7.x ship; v0.8+ extension point for issue #341

Gating sentence: every flown / active mission across the four /fly segments (multi-flyby helio, single-flyby helio, Mars-direct, cislunar global) composes an iconic hero shot at its narrative beat via a per-body composition layer + a Tier-1.5 hybrid-waypoint hold detector + per-event MoonComposition variants; new destinations are added by following the 15-step §body-wiring checklist in TA.md.

Context

The pre-this-ADR state of /fly:

  1. Iconic shots worked only for the 8 main planets + the Moon. New Horizons at Pluto / Arrokoth, Dawn at Vesta / Ceres, Giotto at Halley, Rosetta at 67P, OSIRIS-REx at Bennu — all fell back to "no body resolved" → cruise framing, or fell back to whatever planet findClosestPlanetToShip happened to think they were near (often Venus by default). Empty-space hero shots.

  2. Cislunar iconic shots worked for Apollo 11 + Chang'e 5, broke for everyone else. Free-return missions (Apollo 13 swing-by), direct landers (Luna 9 impact), powered-descent landers (Luna 16, Chandrayaan-3), low-energy transfers (SLIM), and the generic-arrival-event missions (SLIM LOI, Chandrayaan-1 LOI) all sailed past the iconic moment with the camera at the wide Earth-Moon-system framing. The auto-zoom dispatcher only recognised lunar_orbit / spiral_lunar / descent / ascent phases — and Tier-2 waypoint missions only ever got a single tli_coast phase out of buildFromWaypoints.

  3. Outer-system planets rendered dark. Sun PointLight had distance=2000, decay=1.2 — Neptune at 30 AU = 2400 scene units was beyond the cutoff and unlit.

  4. HUD showed NaN km/s past the embedded Mars Hohmann SMA. Voyager 2 at Neptune (r=30 AU) against aTransferAu = 1.262 returned 2/30 − 1/1.262 = −0.726sqrt(negative) → NaN.

  5. Same SPA mission swap broke under rapid URL pushes. $page.url-tracking effect read $page reactively inside localeFromPage(...), propagating noise from every unrelated $page mutation.

  6. Verifying any of this was racing other Claude Code sessions using the same chrome-devtools-mcp browser instance. Pause clicks, scrubber events, and even page navigations were getting clobbered.

The throne-of-glory polish bar — the user-stated goal that every flyby compose at the same tier as Voyager 2's Neptune flyby photograph or Cassini's Saturn-OI hero shot — was a long way off. This ADR locks the architecture that closes that gap and makes it grindable per new destination.

Decision

1. Body wiring is a 15-step checklist with one canonical example

Adding a new flyby body (any destination — planet, dwarf, comet nucleus, asteroid, KBO, contact binary) is exactly 15 touchpoints across ~10 files. Arrokoth (commit e6e9175b) is the worked example end-to-end. The checklist lives in TA.md §body-wiring and AGENTS.md §"Adding a new flyby body to /fly", both pointing to the Arrokoth diff. Halley + 67P (commit 3c1e6938e) is a shorter follow-up adding the synonym pattern for ambiguous data labels ("Churyumov" → '67p').

The most subtle step is #7 labelToPlanetId + FLYBY_RADIUS_AU in src/lib/fly-mission-apply.ts. Without this, the trajectory.json waypoint at the body's label stays at the raw (x, y, z) coords from /explore (which can be 20+ AU off-axis from where destinationPos() says the body is), and the iconic composition frames empty space. Every new-body wiring failure I hit during this session traced back to a missing entry here.

2. Cislunar Tier-1.5 hybrid-waypoint hold detector

buildFromWaypoints walks every Tier-2 mission's cislunar_profile.waypoints_km for "hold" runs — 3+ consecutive entries with identical (x, y, z) within 10 km tolerance. Each hold is a data-designer signature for an iconic beat the analytic model couldn't compute (LOI capture, surface stay, TEI burn, free-return periselene). Multi-hold per mission is supported (Apollo 17 = LOI + surface stay + TEI).

Phase synthesis: tli_coast (outbound) → hold[0] (lunar_flyby or descent) → tli_coast (between holds) → hold[1] → … → tei_coast (after last hold). Phases interleave with no overlap; MET-ordered. descent if closest_approach_km ≤ R_MOON + 50 (Luna 9, Apollo 11 landing), lunar_flyby otherwise (Apollo 13 swing-by — the conservative LUNAR_PHASE_TYPES default; both compose iconically against the Moon).

Moon-local convention (the subtle bit): lunar_flyby and descent are in MOON_LOCAL_PHASE_TYPES. The sampler in $lib/orbital/sample-cislunar-spacecraft.ts does point + (live_moon − moonRefPos). The caller hard-codes moonRefPos = moonEciPos(flyby_day = dep + transit_days). So we store points = moonAtFlyby — then sample = moonAtFlyby + (live_moon − moonAtFlyby) = live_moon, pinning the spacecraft to the live Moon centre for the entire hold window. For Tier-1.5 hybrid data the hold's absolute coords don't align with the stylized moonEciPos model anyway, so storing the live-moon reference is the right convention. (Earlier attempts at storing (0, 0, 0) or moonAtSwingby had the spacecraft drift off-axis as simDay advanced.)

3. Per-event MoonComposition variants

Seven event types each drive their own MoonComposition:

TypeSidePitchcamR×targetBiasUse case
loi85°15°40Apollo-8 earthrise default
tei85°15°40(same)
descent_start85°15°40(same)
ascent85°15°40(same)
flyby60°35°50.4Apollo 13 free-return limb arc
edl_or_oi45°10°30.5Luna 9 / Chandrayaan-3 descent tightness
arrival85°15°40SLIM / Chandrayaan-1 LOI (same as loi)

findActiveCislunarHero widens to match all seven (was just loi/tei/descent_start/ascent). CISLUNAR_HERO_LEAD_DAYS carries per-event lead offsets. planCislunarHeroShot picks the right variant via CISLUNAR_HERO_COMPOSITION map; explicit ctx.composition override still wins for testing.

4. Live-moon hero tracking

planCislunarHeroShot's ctx.moonPos reads moonEciPos(simDay) — live moon, not Moon-at-iconicMet. The cislunarMoon mesh always renders at live position; previously the planner's cameraTarget = moonEciPos(iconicMet) drifted ~13°/day from the rendered Moon position, so users landing 0.1 d past iconicMet saw the moon out-of-frame. shipPosAtMet still uses iconicMet so the approach-direction geometry stays invariant inside the hero window.

5. Sun-lit hemisphere bias for flyby cinema

planFlybyShot flips the perp vector when cos(α) < −0.7 (α > 134° — camera deep on night side). At side-angle 85° the perp dominates the camera offset, so this controls which hemisphere ends up facing camera. V2 Jupiter pre-fix rendered as a thin-lit-limb at α ≈ 170°; post-fix is the iconic banded gas-giant lit hemisphere. Threshold -0.7 keeps the terminator-grazing compositions (V2 Uranus α ≈ 80°, Cassini SOI varies) — they're aesthetically valid Cassini-mission-art frames; we only flip the extreme back-lit cases.

6. MOI=arrival epilogue gate removed

For missions whose iconic moment IS the arrival event (Mars Express MOI = arrival, Mars Pathfinder EDL = arrival, etc.), the pre-fix gate epilogueActive && (rawActiveFlybyMet ≤ arrivalMet + 1) → suppress cinema immediately killed the iconic shot at landing. The gate's original purpose was Saturn-OI peakHold pinning. Gate removed; Saturn-OI still composes iconically (verified Cassini SOI). The peakHold release condition is a separate concern (open follow-up).

7. Race-free DEV-only test hooks

Two hooks added to src/routes/fly/+page.svelte:

  • window.__flySetSimDay(metDays) — sets simDay + pauses. Skips biasJumpToIconicMoment / 700 ms snap-cut / peakHold clearing — all upstream input concerns. Everything downstream (auto-zoom lerp, scene render, hero detector) runs identically to a real user landing at the same MET.
  • window.__flyCislunarDebug() — mirror of __flyDebug for cislunar missions (__flyDebug is gated by !isMoonMission). Returns the auto-zoom + camera + phase state needed to diagnose without spelunking Three.js refs.

Both DEV-gated. Used during this session to verify Apollo 13/17, Luna 9, SLIM, Chandrayaan-3 under contention with a podcast_scraper agent on the same Chrome instance.

8. Bonus items locked

  • Outer-system lighting: Sun PointLight now distance=0, decay=0 (uniform sunlight). Neptune at 30 AU lit. Threshold for over-bright: at side-angle 85° + the flyby flip, all outer planets keep terminator visibility.
  • Vis-viva NaN guard: heliocentricSpeed(r, a) returns circular-orbit speed (sqrt(µ/r)) when radicand ≤ 0 (r beyond transfer apohelion). At Neptune that's 5.43 km/s — coincidentally Neptune's mean orbital speed.
  • URL→mission effect isolated via untrack(): matches feedback_svelte5_effect_untrack memory; source-of-truth check short-circuits same-URL re-fires.
  • NH mission arc extended to Arrokoth (arrival_date 2015-07-14 → 2019-01-01, transit_days 3463 → 4730). Pluto stays an iconic flyby event mid-arc; Arrokoth becomes arrival.

Consequences

Positive:

  • Adding a new flyby destination is a grindable 15-step checklist with one canonical commit to copy from. Issue #341 (13 missing notable missions: Parker Solar Probe, OSIRIS-REx, DART, Lucy, Psyche, Europa Clipper, Mariner 9, Phoenix, Spirit, Opportunity, Magellan, Akatsuki, Hayabusa 1, Solar Orbiter) is well-scoped.
  • Every existing mission in segments A-B-C-D composes at the throne-of-glory bar. Verified iconic this session: V1, V2 (Jupiter/Saturn/Uranus/Neptune), Cassini SOI, Galileo JOI, NH (Pluto + Arrokoth), JUICE JOI, Mariner 4, Juno, MESSENGER, BepiColombo, Dawn (Vesta + Ceres), Giotto/Halley, Curiosity, Perseverance, Tianwen-1, MAVEN, InSight, Hope, Mangalyaan, Schiaparelli, Mars Express, Apollo 11/13/17, Luna 9, SLIM, Chandrayaan-3, Chang'e 5, Vega 1, Venera 13, Ulysses.
  • Multi-Claude-session contention on chrome-devtools-mcp is no longer a verification blocker.
  • 340/340 orbital + lambert + cislunar + hero tests pass.

Negative / known limitations:

  • Apollo 8/10/12/14/15/16, Change1, Luna 10, Smart-1, Lunar-prospector, Mariner 10, Apollo 7/9/1, Apollo-Soyuz, Starship-demo, Starship-mars-crew: missions ship without flight.events arrays — data gap, not architecture. Filing follow-up in the next data-authoring session.
  • Apollo 17's authored waypoint data only has the post-TEI hold; LOI / descent / ascent rely on the live-moon track to compose correctly. Other Apollo missions may have the same single-hold limitation — fix is to author richer waypoints.
  • Earth-orbit missions (dest=EARTH: Apollo 7/9, Vostok, Mercury, Polaris Dawn, OTV) ride the cislunar branch with a Moon-bound trajectory model. The renderer composes but the trajectory shape is wrong for LEO/MEO/GEO. Separate-scene work; not blocking the throne-of-glory bar for the existing segments.
  • Saturn-OI peakHold suppression of the epilogue wide tableau survives. The cinema composes correctly at SOI, but the wide bookend pull-out after peakHold's release window is its own follow-up.
  • Commit e6e9175be — Arrokoth as a first-class destination (canonical body-wiring example)
  • Commit 3c1e6938e — Halley + 67P comet wiring (synonym pattern)
  • Commit 2428b1d41 — Pluto wiring (NH 2015 encounter)
  • Commit 034942a4e — Asteroid wiring (Vesta + Ceres + Psyche + Bennu)
  • Commit f7919f654 — Tier-1.5 hybrid waypoint hold detector
  • Commit 35902641b — Multi-hold + live-moon track
  • Commit b98ce9c08 — Sun-lit hemisphere bias for flyby cam
  • Commit eb88418a2 — Vis-viva NaN guard
  • Commit 88a6037ac — Outer-system lighting
  • ADR-058 — original cislunar scene + camera architecture
  • ADR-030 — /fly math + per-mission validation harness
  • GH issue #341 — 13 missing notable missions to add in a follow-up session

Orrery — architecture documentation · MIT · No tracking