Skip to content

ADR-058 — Cislunar view: Earth-centered second camera + per-mission Moon trajectory profiles

Status · Accepted Date · 2026-05-13 Related ADRs · ADR-001 (Three.js), ADR-009 (free-return scenario), ADR-010 (Keplerian half-ellipses), ADR-019 (schema validation), ADR-020 (canonical mission schema), ADR-024 (mission URL contract), ADR-027 (flight params + timeline), ADR-030 (/fly trajectory math: pure-function isolation) Closes · The longstanding "Moon missions render as two straight chords" gap surfaced by the maintainer reviewing the live Artemis II rendering against the published NASA trajectory

Context

/fly renders every Moon mission's trajectory via moonHelioArc() (src/routes/fly/+page.svelte:183), which linearly blends an Earth-relative offset over the mission duration. Because the entire /fly scene is heliocentric (Sun at origin, Earth orbit at 80 scene units, Moon-Earth distance exaggerated ×150 via MOON_FLY_RADIUS_AU = 0.4), the rendered cislunar trajectory degenerates visually to two straight chords — outbound from Earth to a fixed offset, return from offset to Earth.

This is not just a fidelity problem; it is a structural one. The real Earth-Moon system spans ~0.0026 AU (~0.21 scene units at current SCALE_3D). At Sun-out zoom every real cislunar trajectory — Apollo's free-return figure-8, Artemis II's hybrid free-return swinging 9,200 km past the lunar far side, Chandrayaan-3's multi-burn Earth-bound spiral, Chang'e 5's lunar-orbit rendezvous — occupies a fraction of a pixel. Even a perfect moonHelioArc() replacement would render the same two-chord-on-a-pixel shape at this zoom.

Sixteen Moon missions in static/data/missions/moon/ share the same broken visualisation today. The mission JSONs themselves (ADR-027 flight schema) already encode the physical differences — arrival.periapsis_km, orbit_insertion_dv_km_s, totals.tli_or_tmi_dv_km_s — but nothing in the rendering path consumes the differences. Two missions with radically different trajectory architectures (Apollo 11 direct ascent + LOI vs Chandrayaan-3 5-burn spiral up) currently render as visually identical straight-line pairs.

The project's stated ambition (AGENTS.md, the maintainer's user role memory, and ADR-009's framing of free-return as "the mission that inspired Orrery") explicitly includes showing each mission's actual trajectory architecture. Apollo's figure-8, Artemis II's hybrid free-return, and the variety of post-Apollo profiles are themselves a teaching artefact — why mission design changed is one of the stories the app exists to tell.

Decision

Add a second view mode to /fly — "cislunar" — with its own Earth-centered scene + camera, rendered alongside the existing heliocentric scene. Auto-switch to it when a Moon mission loads. Drive its per-mission geometry from a new optional flight.cislunar_profile block in the mission schema.

View architecture

Note (2026-05-14 amendment): the user-facing Solar/Cislunar toggle and the picture-in-picture inset described in this section were de-scoped during smoke-test polish. The current implementation derives viewMode from isMoonMission directly and renders only the active scene. See the Amendment — 2026-05-14: Solar/Cislunar toggle de-scoped section at the bottom of this ADR for the rationale. Everything else in this section still describes the shipped architecture.

  • A second THREE.Scene (cislunarScene) + THREE.PerspectiveCamera (cislunarCamera) live alongside the existing heliocentric scene/camera in src/routes/fly/+page.svelte. No restructuring of the existing scene graph.
  • cislunarScene is in Earth-Centered Inertial (ECI) coordinates measured in km, scaled to scene units by a new constant SCALE_CISLUNAR such that Earth-Moon distance (384,400 km) maps to ~38 scene units — same visual span as Earth→Mars in the heliocentric scene.
  • The render loop branches once per frame on a new viewMode: 'heliocentric' | 'cislunar' state, rendering whichever scene is active. Mouse controls are gated likewise.
  • An ~800 ms camera tween on toggle, using an inline rAF lerp in the existing animate() loop. No new tween dependency. (superseded — no toggle)
  • A picture-in-picture inset (~220×220 px, bottom-right) renders the other scene each frame via renderer.setScissor + setScissorTest. Single WebGLRenderer, two render passes. Click the inset to swap which view is main. (superseded — inset descoped)

Default behavior

  • On mission load (applyMissionAsLoaded), if (m.dest === 'MOON') viewMode = 'cislunar'; else heliocentric.
  • A toggle button is shown in the HUD whenever isMoonMission === true, mirroring the existing 2D/3D toggle pattern (+page.svelte:660 + button at line 2496).
  • View mode is session-only state (no URL persistence, no cookie — consistent with ADR-024 and ADR-057). The 2D/3D toggle is the same; this matches.

Schema extension — flight.cislunar_profile

Optional sub-object under flight, additive (no existing-field changes):

json
"cislunar_profile": {
  "source_tier": "tier_1_analytic" | "tier_2_published",
  "parking_orbit": { "altitude_km", "inclination_deg", "revs" },
  "tli": { "dv_kms", "c3_km2_s2" },
  "translunar": { "type": "direct" | "free_return" | "hybrid_free_return" | "spiral" },
  "lunar_arrival": {
    "type": "impact" | "orbit" | "flyby" | "lor_orbit",
    "altitude_km", "inclination_deg", "periselene_km"
  },
  "return": { "type": "none" | "tei_direct" | "tei_lor", "dv_kms" },
  "waypoints_km": [[met_days, x_km, y_km, z_km], ]  // Tier 2 only
}

flight.events[].type enum extended with: parking_orbit_exit, loi (lunar orbit insertion), tei (trans-Earth injection), descent_start, ascent. Optional dv_km_s field on events so the HUD can annotate burns.

Two-tier data strategy

  • Tier 1 — parametric (all 16 Moon missions). Trajectory shape generated procedurally from the small parameter set above. Real Keplerian arcs with Earth at focus (translunar/trans-Earth) and Moon at focus (lunar orbit/flyby). Looks physically correct, uses real numbers, but does not match published state vectors to the kilometre.
  • Tier 2 — published waypoints (4 marquee missions). Apollo 11, 13, 17, Artemis II carry cislunar_profile.waypoints_km arrays of ECI state-vector samples reconstructed from NASA publications (TND reports for Apollo, post-flight reconstruction for Artemis II). Renderer prefers waypoints when present, falls back to parametric.
  • No Tier 3 (live SPICE / Horizons ephemerides). SPICE kernel parsing in-browser is heavy; Apollo records pre-date NAIF IDs; deferred.

Trajectory math isolation

Per ADR-030, the cislunar trajectory math lives in a pure-function module src/lib/cislunar-geometry.ts, parallel to src/lib/mission-arc.ts. Unit-testable in isolation; /fly consumes it and renders into Three.js. Phase generators are pure functions returning Vec3Km[] arrays; buildCislunarTrajectory(mission): CislunarTrajectory composes them from the mission's cislunar_profile + flight.events[].

Validation

scripts/validate-data.ts extends to enforce, for Tier 2 missions: waypoints_km is sorted by met_days, waypoints_km[0].met_days === 0, last.met_days <= transit_days * 2. Tier 1 fields validate automatically through AJV against the schema. npm run preflight continues to gate all changes.

i18n

New Paraglide UI strings in src/lib/i18n/en-US.json for the toggle (fly_label_view_cislunar, fly_label_view_solar, etc.) and phase names (fly_phase_parking_orbit, fly_phase_translunar_coast, …). Other 13 locales receive translations through the established wave23 toolchain (project_science_translation_pipeline.md). Mission-overlay schema gains an optional cislunar_notes field for editorial annotations per phase.

Implementation rollout (sequential stages, separate PRs)

  • Stage 0 — ADR + spike (3–4 days). This ADR + schema fields + cislunar-geometry.ts core phases + dual-camera wiring + render Artemis II only. Maintainer checkpoint before Stage 1.
  • Stage 1 — Tier 1 (~8 days). Remaining phase generators (lunar_orbit, descent, ascent, spirals); cislunar_profile for all 16 Moon missions; picture-in-picture inset; Paraglide strings + wave23 translation roll.
  • Stage 2 — Tier 2 marquee (~6 days). Apollo 11/13/17 + Artemis II waypoints. (Apollo 13 mission JSON does not exist yet; created alongside its waypoints.)
  • Stage 3 — polish (~3 days). Science-lens phase-boundary annotations, HUD strings for cislunar phase ticker, pre-rendered cislunar mission-card thumbnails, perf + a11y passes.

Rationale

The fundamental problem is a coordinate-system mismatch, not a math bug. Replacing moonHelioArc() with a true Keplerian translunar ellipse in the existing heliocentric scene would produce a more accurate chord but still no visible cislunar geometry — the chord would still occupy ~1 scene unit at Sun-out zoom. The only path that surfaces real cislunar architecture is a second camera at the right scale.

Two-tier data avoids the false dichotomy of "ship parametric vagueness" vs "build a SPICE pipeline." Parametric Tier 1 gets all 16 missions to a state where each renders its correct architecture (figure-8 / spiral / LOR / direct) at the right scale; Tier 2 then upgrades the four marquee stories to match published reality. Each tier is independently shippable — Stage 0 alone produces a feature; Stage 2 deepens it without re-architecture.

The Earth-centered scene is in km, not AU, because cislunar dynamics are naturally km-scale. Translating between the two coordinate systems happens only at scene-graph entry (apply SCALE_CISLUNAR), not inside the trajectory math, which keeps the math module pure and unit-test-friendly per ADR-030.

The picture-in-picture inset is included rather than deferred because it removes the cognitive cost of mode-switching: the maintainer's pivot review of Artemis II (which prompted this work) was specifically about how disconnected the heliocentric and cislunar contexts feel without it. Two render passes per frame is the standard Three.js inset pattern; perf impact is bounded.

Session-only view-mode state matches ADR-024 (URL = mission) and ADR-057 (URL is canonical except for one narrow locale-cookie exception). Adding a ?view= param would either duplicate the mission-derived default or invite future "remember my view" requests — both rejected.

Alternatives considered

  • Fix moonHelioArc() in place (curved, no second scene). Cheap (~1 day). Trajectory becomes a proper Keplerian curve instead of a straight line, but still renders at heliocentric zoom where the cislunar system is one pixel wide. Solves the math, not the visualisation. Rejected as too narrow given the project's intent.
  • Inset-only (no main-view swap). Render the cislunar view permanently as a 250 px corner inset; heliocentric stays main. Cheaper UX, no toggle. Rejected because cislunar is the natural main view for Moon missions; making it permanently secondary undersells the geometry.
  • Replace heliocentric for Moon missions entirely (no toggle). No second mode, just swap the scene wholesale when a Moon mission loads. Simpler. Rejected because losing solar context for crewed lunar missions is exactly the kind of disorientation the maintainer flagged in the review — the toggle + inset preserves "I am at Earth, on a journey to the Moon, while Earth orbits the Sun" as a single coherent story.
  • JPL Horizons / SPICE ephemeris (Tier 3) from day one. Most accurate possible. Rejected: SPICE in-browser is a 2–3 week pipeline by itself; Apollo records pre-date NAIF IDs; Tier 1 + Tier 2 already cover >95% of the educational value at ~15% of the engineering cost. Revisit if Artemis II's full DSN telemetry is ever released and a single mission warrants the effort.
  • Earth-Moon rotating (synodic) frame instead of ECI inertial. Mathematically elegant — free-return becomes a literal figure-8 — but pedagogically misleading: the Moon "doesn't move," which hides the fact that real cislunar missions chase a moving target. Rejected as default; could be added as a sub-toggle inside cislunar view later if needed.
  • Defer to v2.x (don't ship in current release). Rejected: this is the feature the project was conceived around (per AGENTS.md framing and user memory). The current straight-chord rendering is the single biggest fidelity gap in /fly.

Consequences

Positive:

  • Moon missions visibly differ from each other for the first time — Apollo's free-return, Artemis II's hybrid, Chandrayaan-3's spiral, Chang'e 5's LOR all render their actual architecture.
  • The maintainer's "what I had in mind at the start" outcome is met.
  • The schema extension is additive — every existing mission JSON remains valid.
  • Pure-function trajectory math (cislunar-geometry.ts) is unit-testable in isolation per ADR-030.
  • Tier 2 path leaves room to ratchet specific missions to published reality without re-architecture.

Negative:

  • Adds a second scene graph + camera + render pass — roughly doubles the /fly Three.js surface area. Mitigated by the flat-scene insertion strategy and the existing rAF loop.
  • Adds ~10 mission JSON updates across static/data/missions/moon/ (Tier 1) plus 4 Tier 2 waypoint sets — sustained data-entry effort.
  • The wave23 translation toolchain has to roll for 12 non-en-US locales for the new UI strings (Stage 1).
  • Tier 1 trajectories are plausible, not literally accurate; this must be honestly communicated. The existing flight_data_quality flag provides the precedent; cislunar fields adopt the same honesty convention (source_tier field).
  • Picture-in-picture adds GPU work per frame; needs a perf gate on low-end devices (Stage 3).

Decision status

Accepted on 2026-05-13. Implementation begins immediately with Stage 0 (this ADR + schema fields + spike rendering Artemis II in cislunar view). Maintainer checkpoint at end of Stage 0 before committing to Stage 1.

Amendment — 2026-05-14: Solar/Cislunar toggle de-scoped

Stages 0–3 shipped. The user-facing Solar/Cislunar toggle and the picture-in-picture inset were removed during smoke testing.

The toggle was meant to let Moon missions also render in the heliocentric Solar scene. In practice the Solar view of a Moon mission was either:

  • Misleading — showing the exaggerated 0.4 AU moonHelioArc chord, the same straight-line rendering this ADR was conceived to fix; or
  • Redundant — once the Earth-Moon pair was exaggerated to fit at solar zoom, the view collapsed into a fuzzy duplicate of the cislunar view.

The smoke-tester's question "what is the difference between solar and cislunar?" was the honest signal: the two views are not differentiable when the cislunar trip is physically sub-pixel at solar zoom.

Resolution. Each mission now has the one view its scale fits:

  • dest === 'MOON' → cislunar scene only.
  • Everything else → heliocentric scene only.

viewMode becomes a derived state from isMoonMission (no user-toggle, no auto-switch animation). The picture-in-picture inset, the 800 ms cross-fade, the inset click hitbox, the four toggle Paraglide keys (fly_label_view_cislunar / _solar / _*_aria), and the ten phase-name Paraglide keys (fly_phase_*, never wired into the HUD) are removed across all 14 locales.

The cislunar scene infrastructure (camera, mouse controls, phase-coloured lines, ∆v annotation sprites, Earth/Moon meshes, spacecraft sprite) is unchanged. Both Three.js cameras still exist (they back different coordinate spaces), but only one is ever active per frame.

Amendment — 2026-05-14 (later): Geometry + UX smoke-test pass

Smoke testing on the cislunar view (Apollo 11, Apollo 13, Artemis II, SLIM, Chandrayaan-3, Chang'e 5) surfaced five geometry corrections and two UX polish items. All fixed:

Geometry fixes:

  • Return-leg straight-line bug (Artemis II): tei_coast re-entry offset 150° prograde from apogee azimuth to break the collinear-through-Earth degeneracy.
  • Phase-pinning gaps closed: lunarOrbit, spiralLunar, spiralEarth accept startPoint/endAzimuth so phases connect at exact endpoints (all 17 missions now 0.0 km gap).
  • Orbiter vs lander disambiguated: lunar_arrival.type enum split into orbit (orbiter only — LRO, Clementine, Chandrayaan-1) and orbit_and_land (lander after orbit — Chandrayaan-3, Chang'e 4, Luna 17, SLIM, Blue Moon Mk1). The 3 orbiters no longer fake-descend.
  • Inclined orbits: lunarOrbit rotates the orbit plane to contain both moonPos and startPoint when startPoint is provided. inclination_deg is ignored in this Tier 1 mode (recoverable in Stage 2 via waypoints). Closes ~5 km y-axis gaps across Apollo 17, Artemis III, Luna 17/24, Chang'e 5/6, Blue Moon Mk1.
  • Time-consistent lunar-phase exit: lastPoint is shifted by (moonAtExit - moonAtFlyby) after the lunar phase so tei_coast departs from the spacecraft's true co-moving position with the Moon, not from the frozen flyby point.

Moon-frame group: lunar-phase lines (lunar_orbit, spiral_lunar, descent, ascent) and the spacecraft sprite ride inside a THREE.Group whose position tracks (currentMoon - moonAtFlyby) × SCALE_CISLUNAR, so orbit + descent visibly move with the Moon mesh instead of staying anchored where the Moon was at flyby_day. Same offset is applied in 2D.

Auto-zoom (3D + 2D): when the active phase is lunar (lunar_orbit / spiral_lunar / lunar_flyby / descent / ascent), the camera lerps to a close-up Moon framing (~3.5 unit distance in 3D; ×8 scale + Moon-centred in 2D). Otherwise wide Earth-Moon view. ~1.25 s lerp; mouse wheel in 3D cancels for the current phase.

UX:

  • Spacecraft glyph: rotation-dependent chevron → solid red filled circle (#ff3a4c) with halo + dark outline. Always readable, distinct from every phase colour.
  • Moon mesh exaggeration 5× → 3× so the lunar orbit reads as a ring around the mesh.
  • Lunar phase MET allocation 0.15 → 0.4 of transit_days (round-trip) — closer to real Apollo (~30 % of flight in lunar operations) and gives the auto-zoom enough on-screen time.
  • Moon-mission default simSpeed 1× → 0.4× so the camera transitions feel deliberate.
  • Press Play after arrival rewinds to dep_day (replays mission).

2D Moon-mission view now renders the cislunar phase model (Earth at canvas centre, Moon orbit ring, phase-coloured lines with moon-frame offset, red spacecraft sprite, auto-zoom on lunar phases). Replaces the older heliocentric drawSplit chord. Non-Moon missions unchanged.

Tier 2 NASA waypoints (Apollo 11/13/17 + Artemis II) remain tracked at issue #107.

Orrery — architecture documentation · MIT · No tracking