ADR-072 — Surface routes (/moon and /mars) share SurfaceScene.svelte; differences are justified per body or rejected as drift
Status · Accepted Date · 2026-05-30 Closes into ·
src/lib/surface-scene/SurfaceScene.svelte(new shared component) +src/lib/surface-scene/README.md(implementation contract) Related · ADR-037 (Sharedsurface-sitetype, deferred shared<SurfaceMap>component — this ADR is the closing of that deferral), ADR-061 (Surface hotspots are regions), Issue #42 (closed — partial extraction of pure helpers), Issue #283 (Slice 2 epic) Triggered by · Drift audit comparing/moon/+page.svelte(2143 LOC) and/mars/+page.svelte(2763 LOC), 2026-05-30. Twenty-two divergences identified that were not justified by the bodies being different — they were drift from Mars being implemented after Moon and the implementer making improvements only on Mars.
Context
The original plan (ADR-037, closed RFC-012 OQ-1) was for /moon and /mars to share a <SurfaceMap> component. ADR-037 explicitly deferred the extraction: Mars V1 shipped as a focused clone of Moon's pattern, with the intention to extract once both routes were known-good.
Issue #42 opened to track that extraction. It closed with partial work: ~35 pure helpers were extracted into src/lib/surface-map/* (12 modules) and src/lib/three/* (23 modules). The orchestration — onMount Three.js scene assembly, per-frame animation loop, reactive Svelte state, template HUD pieces — stayed inline in both routes. Closed as "good enough for v0.6 ship" with a list of drift risks left for later.
By 2026-05-30, those drift risks had matured. A focused recon of both routes (Issue #283 Slice 2 design phase) found eight true body differences and twenty-two accidental drifts — places where Mars had been improved without back-porting to Moon (or vice versa, in three cases). The drifts included:
- UX behaviour (Mars's drag inertia, smooth zoom lerp, fly-in animation, altitude indicator)
- Visual constants (different label sizes, halo radii, label offsets)
- Code architecture (status-badge as CSS classes vs as a function, hover outline target, orbital ring parenting)
- Latent bugs (Moon's
moon-sites.jsonhas 8 orbiters that the route silently filters out)
Without a deliberate consolidation, every future surface-route change would have to be made twice — and the drift would keep widening, because Slice 3+ of Issue #283 is going to touch this code heavily (rectangular hotspots, sphere → flat-patch transition, scale-aware markers, traverse polyline, HUD).
Decision
Build SurfaceScene.svelte and migrate both routes onto it. The component owns the shared 90 %; routes become thin shells that pass a planet config and a panel snippet. Drift is resolved on the way in — Mars's improvements back-port to Moon, Moon's improvements forward-port to Mars, compromise picks where neither was clearly right.
True body differences (stay as SurfaceSceneConfig knobs)
| Knob | Justification |
|---|---|
textureUrl | Different bodies → different surfaces |
atmosphere? | Mars has one (CO₂, dust); Moon doesn't (vacuum) |
tidalLockOverlay? | Moon is tidally locked → "near side" is a real concept. Mars isn't |
axialTiltDeg | Mars 25.19°; Moon ~0° (1.5° in reality, effectively zero at this rendering scale). Real obliquity is physical, not arbitrary |
landerModelBuilder | Moon has Apollo/Luna/Chang'e/Beresheet/SLIM/Chandrayaan-3 catalogue; Mars has Vikings/Pathfinder/MER/Curiosity/Phoenix/Mars 3/Zhurong/Schiaparelli/Beagle 2. Domain content differs |
twoDMode | Mars rotates → equirectangular is honest. Moon is tidally locked → two-disc near/far is the only honest projection (a single equirectangular Moon map would imply rotation relative to Earth) |
traverses? | Mars has 4 vendored rover paths (Curiosity, Perseverance, Spirit, Opportunity). Moon has historical EVA paths (Apollo 14/15/16/17) and Lunokhod tracks that should be added in a future slice but aren't vendored today |
registerHotspotBuilders | Already consolidated as per-planet bundles in Slice 2A (registerMoonHotspotBuilders / registerMarsHotspotBuilders) |
Drifts that get consolidated
| Drift | Old Moon | Old Mars | Consolidation | Why |
|---|---|---|---|---|
| Camera near plane | 0.5 | 0.05 | 0.05 | Mars's allows closer zoom; required for Tier-2 detail |
Initial camR | 80 | 90 | 85 | Compromise — neither value was special |
Initial camP (pitch) | π/2 | 45° | 45° | Mars's angled view is more inviting than Moon's straight-equator view |
| Camera zoom range | [30.2, 200] | [30.2, 180] | [30.2, 200] | Wider zoom-out preserved from Moon |
| Surface light intensity | 0.7 | 0.8 | 0.8 | Mars's value is brighter; preserve tint difference (Moon 0x666688, Mars 0x886655) since tint hints at body palette |
| Star field | default (~150 stars) | STAR_FIELD.planet | STAR_FIELD.planet | Deliberate value beats accidental default |
| Tier-2 opacity ramp range | camR 50 → 33 | camR 33 → 30.5 | camR 33 → 30.5 | Mars's range was the more recent tuning that matches actual Tier-2 patch dimensions |
| Marker label offset | (0, 3.2, 0) straight up | (2.6, 1.4, 0) side+up | (2.6, 1.4, 0) | Mars's offset (deliberate 2026-05-22 improvement) avoids labels sitting on top of the marker |
| Marker label size | 1.6 | 1.4 | 1.4 | Mars's tighter sizing reads better without competing with the lander glyph |
| Marker halo radius | 1.8 | 1.4 | 1.4 | Mars's smaller halo is less intrusive |
| Auto-spin pause | URL deep-link only | Any selection | Any selection | Clicking a marker without the body coming to rest is jarring |
| Drag inertia | none | 0.92/frame decay | 0.92/frame on both | Inertia makes drag feel natural |
| Smooth zoom lerp | snap | lerp toward target | Lerp on both | Mars's lerp feels much better than instant snap |
| Fly-in animation | longitude flip only (instant) | full 3D tween (800 ms ease-out cubic) | Full fly-in on both | Mars's deep-link fly-in is a great UX moment |
| Hover outline target | entire marker group | tier0Group only | tier0Group only | Outlining the label sprite + halo looks messy |
| Altitude indicator | absent | shown in km above surface | Shown on both | "How zoomed am I" is body-agnostic |
| Status badge | predefined CSS classes (.status-completed/ongoing/planned) | dynamic statusTone() fn | statusTone() on both | Function approach is more flexible and centralised |
| 2D pick tolerance | 22 px | 18 px | 20 px on both | Compromise; neither value had stated rationale |
| Orbital ring opacity | default (0.35) | explicit 0.35 / 0.18 dimmed | Explicit on both | Dimmed-orbiter visual cue is useful |
Site kind filtering | onMount filters out orbiters | both surface + orbiter | Both kinds on both | Moon's moon-sites.json has 8 orbiters (LRO, Chandrayaan-1, Chang'e 1/2, SMART-1, Luna 10, Clementine, Lunar Prospector) that the route was silently filtering out — latent bug |
| Orbital ring log divisor | 50 | 100 | Derive from max(altitude_km) at load | Hardcoded values are drift; data-derived scaling adapts to whichever bodies the route renders |
| Orbital parent | scene | marsAxis (inherits planet tilt) | scene on both | Orbital mechanics are inertial — orbits shouldn't inherit a planet's tilt. Mars was wrong |
| Panel structure (amendment, added Slice 2 Wave E) | .grid / .cell cell layout with cells for year / landing / coords / status / surface time / EVA / samples | dt/dd .meta-grid with badges-row (agency + status + kind) + mission-type chip + dt/dd pairs | Moon's .grid / .cell layout on both | Mars's dt/dd structure surfaced the same information with a different HTML scaffold; not a body-justified divergence. Consolidated to Moon's layout since it's the version already living in SurfaceScene. Amendment to this ADR — wasn't in the original 22 drifts because the panel divergence didn't surface until we tried to migrate /mars |
Behavioural changes shipped by this consolidation
The consolidation lands UX changes that users will notice — flagged here so the migration PR can call them out:
- Moon gains: drag inertia, smooth zoom lerp, deep-link fly-in animation, altitude indicator HUD, orbiter rendering (8 missions previously hidden), auto-spin pause on any selection, smaller markers/halos, side-offset labels,
statusTone()badges. - Mars loses (in the sense of "reverts to common"): the marsAxis-parented orbiters (orbits will no longer share the planet tilt); the
dt/dd.meta-gridpanel layout (replaced by Moon's.grid/.cellcell layout per the drift #23 amendment); thescaleNoteline in TierContextCard (Mars-only field that the canonical TierContextCard usage in SurfaceScene doesn't pass — see retrospective fix #13). - Both gain: data-derived orbital ring scaling, more sensible 2D pick tolerance (20 px compromise).
Alternatives considered
- "Preserve all current behaviour" — would have meant 25+ config knobs on SurfaceScene to preserve drift. Rejected because (a) the resulting god-component would itself be a maintenance burden, and (b) most "differences" weren't deliberate — they were drift the implementer would have removed if they'd seen the comparison side-by-side.
- "Stay with two route files; extract just render-path helpers" — the targeted middle path. Rejected by Marko in favour of the full extraction since drift was deemed the root issue, not duplication itself.
- "Skip extraction; live with the duplication for v0.7" — rejected. Slice 3+ of #283 (rectangular hotspots, sphere → flat-patch, marker primitive, scale-bar HUD, traverse polyline) would multiply the drift surface.
Consequences
SurfaceScene.sveltebecomes the canonical home for surface-route rendering. New routes in this family (hypothetical: Mercury, Vesta) would consume the same component with a third config.- Extended to
/earthin #285 Phase 2 (commitcb22bd107, 2026-05-30).SurfacePlanetunion grew from'moon' | 'mars'to'moon' | 'mars' | 'earth'. /earth is a hybrid: orbital mode (default) stays bespoke inEarthOrbitalScene; surface mode (?mode=surface) consumes SurfaceScene withEARTH_LAUNCH_CONFIGto render the 14 launch-site markers. Earth's physical differences (Rayleigh-blue atmosphere shell, 23.4° axial tilt, equirectangular 2D, no tidal-lock) all rode on existing optional knobs — no new config props were added, ADR-072 discipline preserved. - New body-vs-body differences require either (a) a config knob justified by physical/domain reality (atmosphere, tidal-lock, axial tilt, projection mode, lander catalogue, traverse data availability) OR (b) a fresh ADR explaining why the difference isn't drift. Future drift is gated.
- The implementation contract — what's common, what's a config knob, how to add either — lives in
src/lib/surface-scene/README.mdfor code-level reference. PRs touching surface routes should reference it. - Issue #42 (closed) is functionally closed-for-real by this ADR. The "drift risks queued" section of #42 is the work this ADR ships.
- ADR-037's "deferred shared component" line gets a closure footnote — that deferral is now resolved.