Skip to content

RFC-017 — Surface Hotspots · LOD architecture, texture pipeline, ground-view skybox, and hand-authored hardware models

Orrery · Open RFC · v0.1 · May 2026

Status: Closed (v0.7) · closed by ADR-059 / ADR-060 / ADR-061 / ADR-062 Author: product Closes into: ADR-059 (LOD architecture + per-frame dispatcher), ADR-060 (orbital-imagery texture pipeline + provenance discipline), ADR-061 (ground-view panorama + skybox framework), ADR-062 (hand-authored hardware model authoring contract). Slice gate: v0.7 Why this is an RFC: PRD-014 promises a four-tier progressive disclosure on lunar + Mars landing sites (silhouette → 3D model → orbital-mosaic patch → ground-level panorama), tracked by camera distance, georeferenced to within 50 m, lazy-loaded for mobile, fully attributed, and parity across 14 locales. The technical questions cluster five ways: LOD orchestration (Three.js LOD primitive vs. manual swap; how to detect "close enough" — distance vs. screen-projected size; what happens during the transition); texture pipeline (LROC NAC / HiRISE source URLs, crop + georegistration, JPEG re-encode, alpha-feathering at patch edge); ground-view skybox (panorama equirectangular projection, camera FOV at ground level, return-to-orbit transition, mobile prefetch policy); model authoring (per-mission Three.js builders mirroring earth-satellite-models.ts; coordinate conventions; how the lander sits on Tier-2 terrain at the right scale + orientation); test + perf strategy (Playwright assertions at each tier, per-frame budget on a Pixel 6 baseline, asset-eviction policy for memory pressure).

This is the single highest-ambition feature in the v0.6→v1.0 arc. Getting the architecture right at v0.7 is what makes V2 panoramas and V3 full-inventory tractable. Getting it wrong forces a rewrite at v0.8.


Context

Today /moon and /mars render a sphere mesh with a texture (LRO mosaic for the Moon, MOLA-derived shaded-relief for Mars), parented to which are surface markers (THREE.Group instances anchored via the existing latLonToUnitSphere helper from moon-projection / mars-projection). Each marker is a small Three.js primitive composition (octahedron + cylinder, or — for /mars Soviet petal landers — a stylised petal-capsule) with an inline buildLabel() group and a halo ring (#119). The camera is an orbital camera with camR ∈ [planet_radius × 1.5, planet_radius × 30].

There is no LOD system today. Every marker renders identically regardless of camera distance. There is no patch-of-high-resolution-texture-laid-locally pattern anywhere in the codebase. There is no skybox / panorama mode. The closest existing pattern is the texture field on the Sun/Earth mesh — a single texture per body, no progressive disclosure.

The data we'd ingest:

SourceResolutionCoverageLicenceNotes
LROC NAC (Lunar Reconnaissance Orbiter Camera, Narrow-Angle)0.5 m/pixelEvery Apollo + Luna + Surveyor + Chang'e site imaged ≥ 1×PD-NASA (+ optional ASU LROC team attribution)Released as PDS-formatted IMG + processed mosaics
HiRISE (Mars Reconnaissance Orbiter)25 cm/pixelEvery active + many failed Mars landers imagedPD-NASAReleased as JPEG2000 + GeoTIFF
Apollo Hasselblad panoramasHigh-res, equirectangular conversions existEvery EVA coveredPD-NASALunar Surface Journal hosts the stitched panoramas
Curiosity / Perseverance Mastcam-Z panoramas5K+ × 2K equirectangularHundreds available across solsPD-NASA / JPL-Caltech / MSSSNASA APOD + Mars Trek host the stitched products

PRD-014 commits to 5 Showcase + 7 Standard V1 hotspots — 12 total. The architecture must scale to V3's ~60 without re-design.


Open Questions

OQ-1 — LOD orchestration: THREE.LOD primitive vs. manual per-marker dispatcher?

Three options:

OptionHow it worksProsCons
A. THREE.LOD primitiveBuilt-in scene-graph node that swaps children based on camera.position distance. Add 3 LOD levels per marker; Three.js does the dispatch every frame.Zero custom code. Standard pattern. Renderer-loop integrated.LOD distance is a scalar; we want screen-projected pixel-size (a tilted patch viewed at glancing angle reads smaller than head-on). Doesn't natively cross-fade between levels.
B. Manual per-frame dispatcherEach marker stores a tier: 0|1|2|3 state. A function in the existing render loop reads camera.position and each marker's world position, derives the tier per-marker, calls setVisible(group, child, tier).Full control. Easy to add screen-size criterion. Easy to add cross-fade. Easy to add per-marker overrides (Tier-3 sticky once entered).More code than A. More risk of leaks if not carefully written.
C. HybridUse THREE.LOD as the data container for Tier 0–2 (the Three-managed common case), wrap with a small dispatcher for Tier 3 (ground-view) which is fundamentally a different camera mode and doesn't fit LOD semantics.Best of both: stock pattern for visual tiers, custom for the "leave-orbit" tier.Two systems to maintain.

Recommendation: B (manual per-frame dispatcher). The screen-size criterion matters a lot for /mars (Curiosity at Gale Crater vs. Viking 1 at Chryse Planitia — same lat/lon range, but the camera-to-marker chord length differs when the Mars sphere occludes one of them). Cross-fade between Tier 1 and Tier 2 is mandatory per PRD-014 §UX-detail; THREE.LOD has no native cross-fade and forces a hard pop. Manual dispatcher gives both, with ~25 LOC of bookkeeping per marker. The per-frame cost is negligible — 60 markers × 3 distance comparisons = 180 ops/frame.

The dispatcher contract — locked into ADR-059:

ts
// In each marker's render-state struct:
type HotspotMarker = {
  position: THREE.Vector3;        // world-space
  tierGroup: Record<0 | 1 | 2, THREE.Group>;  // per-tier children
  currentTier: 0 | 1 | 2;
  fadeProgress: number;           // 0..1 between currentTier and nextTier
};

// Per-frame:
for (const m of hotspotMarkers) {
  const screenSize = projectedRadius(m.position, camera);  // px
  const targetTier =
    screenSize > 120 ? 2 :
    screenSize >  20 ? 1 : 0;
  if (targetTier !== m.currentTier) startFade(m, targetTier);
  if (m.fadeProgress < 1) advanceFade(m, dt);
  applyTierVisibility(m);
}

projectedRadius is a cheap math op (no actual projection): 1 / (cameraDistance / focalLength)-ish, accurate to a few percent for the LOD threshold purpose.


OQ-2 — Tier-2 texture patch: how do we lay a 2048×2048 JPEG of LROC NAC onto a curved moon sphere at the right place?

The moon mesh is a THREE.SphereGeometry with a single LROC global mosaic texture. We can't replace the texture per-site (that would re-upload 80 MB to GPU per site visit). We need a patch approach: a small disc-or-quad mesh laid on top of the sphere at the site's lat/lon, with its own per-site texture.

Options:

OptionApproachProsCons
A. Decal-projection (Three.js DecalGeometry)Project a planar texture onto the underlying sphere mesh, clipped to a small regionNative to Three.js. Per-vertex follows the sphere curvature. Alpha-feathering at edge works.Complex API. The decal geometry must be regenerated when the moon mesh rotates (which it does — Moon is tidally locked, slow rotation).
B. Surface-patch quad with custom shaderSmall disc-mesh tangent to the sphere at the site, with a fragment shader that masks pixels outside the patch radius and feathers the alpha.Simple. Stable. Doesn't depend on moon rotation. The patch is parented to moonMesh so it rotates with it.Slight geometric mismatch at the edge (the planar disc doesn't curve with the sphere). At 1 km patch radius on a 1737 km-radius Moon, the curvature mismatch is ~0.3 m at the edge — invisible.
C. Re-render the moon texture with the patch compositedCompose a CPU canvas with the patch laid into the global mosaic; upload as a new texture per-visit.Pixel-perfect.Texture upload latency (≥ 30 ms even at 4K). Memory pressure if multiple patches at once.

Recommendation: B (surface-patch quad). The simplicity wins. Patch size = 1 km surface diameter × pixel-density-of-MoonRadius-on-screen-at-Tier-2 ≈ 200×200 px at Tier 2 zoom. At a 2048×2048 source texture that's massive oversampling — sharp at every reasonable zoom. The curvature mismatch is sub-mm. Parented to the moonMesh so the patch corotates with the Moon's surface tide-lock rotation. No re-render of the moon texture.

Implementation:

ts
function makeHotspotPatch(opts: {
  lat: number;
  lon: number;
  radiusKm: number;
  surfaceRadiusKm: number;
  texturePath: string;
  alphaFeatherFrac: number;  // 0.05 = soft 5% edge fade
}): THREE.Mesh {
  // Disc geometry: planar, normal at the lat/lon point pointing radially out.
  const surfacePoint = latLonToUnitSphere(opts.lat, opts.lon)
    .multiplyScalar(opts.surfaceRadiusKm + 0.001);  // hair-elevated to avoid z-fighting
  // Tangent-plane disc, oriented so its normal points outward.
  // ...
}

The + 0.001 km elevation (1 metre above the sphere surface) avoids z-fighting with the base mesh while remaining sub-pixel from any reasonable zoom.

Locked into ADR-059.


OQ-3 — Georegistration: how do we ensure the LROC patch lands at the published Apollo 11 lat/lon to ±50 m?

The site lat/lon from the existing mission data are accurate to published-coordinate precision (Apollo 11: 0.6741°N, 23.4730°E from the Lunar Surface Journal, ~10 m of historical-claim precision). The challenge is that we're laying a finite-area image (1 km × 1 km) over a curved surface, and we need the centre of the image to lie at that lat/lon, with the image's "up" aligned to lunar north.

Three steps:

  1. Authoring: when fetching a patch image, the curator crops the LROC mosaic such that the published lat/lon falls at pixel (1024, 1024) of a 2048 × 2048 crop. The patch covers 1 km × 1 km — i.e., ~0.5 m/px native LROC NAC. The image rotates so lunar north is up. This is a manual / curated step per site, scripted in scripts/fetch-hotspot-imagery.ts.
  2. Manifest: each hotspot's JSON manifest records { lat, lon, radius_km, image_orientation_deg }. The orientation defaults to 0 (lunar north up) for V1; non-zero allowed for V3+ if a stitched-from-non-polar mosaic needs it.
  3. Render: the surface-patch quad is positioned at latLonToUnitSphere(lat, lon) × surfaceRadiusKm, sized to cover radius_km, oriented so its texture's +V axis points toward latLonToUnitSphere(lat + 0.001, lon) (i.e., one tiny step toward lunar north).

Total registration uncertainty:

  • Published lat/lon: ±5–30 m site-dependent.
  • LROC NAC product georeferencing: ±5 m on flat ground.
  • Three.js single-precision float at lunar scale: ~1 m positioning precision.
  • Patch quad's planar approximation of sphere: ±0.3 m at edge.

Realistic delivered georegistration: ±20 m at site centre, ±50 m at patch edge. The PRD-committed ±50 m is met with margin. Sites that exceed this (rare) carry a georegistration_note string surfaced in the panel.

ADR-060 captures the contract.


OQ-4 — Texture asset pipeline: which sources, how to ingest, where to host?

Sources have different ingestion paths:

SourceDirect URL patternCrop formatRe-encodeProvenance
LROC NAC products (Moon detail Tier 2)NASA PDS direct, JP2 per product (e.g. M168000580LE)JPEG2000 → 2048² JPEG→ JPEG quality 88PD-NASA + "NASA/GSFC/Arizona State University"
HiRISE RDR products (Mars detail Tier 2)NASA UAhirise.org PDS, JP2 per productJPEG2000 → 2048² JPEG→ JPEG quality 88PD-NASA + "NASA/JPL-Caltech/UAhirise"
Murray Lab Global CTX Mosaic V01 (Mars regional Tier 2a, v0.7.x)Caltech Murray Lab tiles https://murray-lab.caltech.edu/CTX/V01/tiles/MurrayLab_GlobalCTXMosaic_V01_E{lon}_N{lat}.zip (4° grid, ~1.7 GB ZIP / ~17 GB extracted GeoTIFF each)GeoTIFF → 2048² JPEG→ JPEG quality 88License CC-BY-Murray-Lab (cite Dickson et al. 2024, DOI 10.1029/2024EA003555) + derived from NASA/JPL/MSSS CTX
Chang'e 2 Global Lunar Mosaic (Moon regional Tier 2a, v0.7.x)CNSA / NAOC portal at moon.bao.ac.cn/ce5web/searchOrder-ce2En.do (Chang'E-2 CCD Stereo Camera Level 2C, free since April 2018). No REST/tile API — JS-heavy interactive ordering only; operator-manual fetch only. 746 mosaic sheets at 7 m/px globally.TIFF → 2048² JPEG→ JPEG quality 88License CC-BY-CNSA (CNSA-EDU allowlist precedent from #PD-mars Zhurong covers this)
Apollo panoramas (Moon Tier 3)NASA Lunar Surface Journal hosts stitched 30K × 5K equirectangular JPGsalready JPEG→ JPEG quality 85, 4096 × 2048PD-NASA + photographer (Aldrin, Armstrong, Cernan, Schmitt…)
Mars rover panoramas (Mars Tier 3)NASA Mars 2020 + MSL raw image archives + JPL APODalready JPEG→ JPEG quality 85, 4096 × 2048PD-NASA + camera (Mastcam-Z, Pancam)
Multi-agency photo-gallery stills (v0.7.x)Per-agency archives (NASA Image Library, CNSA mission pages, Roscosmos, ESA, ISRO, JAXA, SpaceIL); curated per sitealready JPEG→ JPEG quality 88, 1920px-longsidePer-photo license_short (PD-NASA / CC-BY-CNSA / CC-BY-Roscosmos / CC-BY-ESA / CC-BY-ISRO / CC-BY-JAXA / CC-BY-SpaceIL); license-allowlist updates per agency

Pipeline contract (ADR-060):

static/data/hotspots/
  moon/
    apollo-11.json         { lat, lon, radius_km, model_id, annotations, source_urls }
    apollo-17.json
    ...
  mars/
    curiosity.json
    perseverance.json
    ...
static/images/hotspots/
  moon/
    apollo-11/
      patch.jpg              # 2048x2048 LROC NAC, georegistered
      panorama-1.jpg         # 4096x2048 equirectangular (V2+)
  mars/
    curiosity/
      patch.jpg              # 2048x2048 HiRISE
      panorama-1.jpg         # 4096x2048 Mastcam-Z (V2+)

scripts/fetch-hotspot-imagery.ts
  - Per V1 hotspot: query UAHiRISE RDR catalog, point-in-polygon
    filter against frame corner coords, fail-fast pixel sample to
    skip frames with no actual data at target, GDAL crop, JPEG q88.
  - Operator override: hotspot_tier2_force_product_id pins a
    specific HiRISE product when auto-pick is editorially wrong.
  - --missing-only flag for incremental retry against the unresolved
    subset.
  - For panoramas: download from source URL, sanity-check
    equirectangular ratio is 2:1, re-encode.

scripts/build-image-provenance.ts
  - New section: walk static/images/hotspots/**, register each with its agency + license + source URL from the manifest.
  - Falls back to "buildWikimediaEntry" for Commons-hosted sources, "buildNasaEntry" for NASA-direct.

scripts/validate-data.ts
  - Fail-closed: every manifest must reference an existing tier-2 JPEG.
  - Fail-closed: every annotation must have a name string in en-US overlay.
  - Fail-closed: every annotation's coordinates must be within the patch radius.

Locked into ADR-060.

The v0.7.x implementation went deeper than this sketch — the fail-fast 32×32 pixel sample, candidate iteration with typed CropError codes, download retry-with-backoff, centroid-distance ranking, polite inter-site pause, --missing-only incremental retry flag, and a manual correction to GDAL's Equirectangular-Mars projection (GDAL inverts the convention HiRISE rasters were authored in — applies latitude_of_origin shift to Y but skips cos(latitude_of_origin) scaling on X) are all v0.7-era refinements hard-won against the realities of the UAHiRISE PDS catalog (corner polygons describe the projected raster bbox, not the actual image footprint) and the PDS server (mid-flight stream terminations on 30-50% of large downloads). Two further bugs were found and fixed in the 2026-05-21 audit: (1) HiRISE bands are UInt16 but the crop pipeline was reinterpreting the buffer as UInt8, interleaving each pixel's low and high bytes and producing mottled-noise output; (2) the manual Equirectangular projection correction was applying unconditionally to Polar_Stereographic products (Phoenix and any future polar sites), pushing pixel coords out of bounds and silently rejecting every candidate. Both fixes are in scripts/hotspots/gdal-crop.ts — see GH issue #248 for the retrospective.

Final state: 13 of 13 Mars sites resolved end-to-end. The Mars- specific operator playbook (per-site sidecar fields, override pins, fail-fast thresholds, validation runbook, failure-mode → fix matrix, the audit story) lives in docs/guides/mars-hotspot-imagery.md. The cross-platform overview (also relevant once Moon Tier B lands under #PC) stays in docs/guides/image-pipeline-v2.md §"Surface Hotspots Tier 2 patches — auto-fetch".


OQ-5 — Ground-view (Tier 3) skybox: equirectangular projection or cube-map?

Mars and Apollo panoramas are stitched as equirectangular (2:1 aspect, longitude × latitude). Three.js supports both equirectangular textures (on a large inverted sphere) and cube-maps (on a CubeTexture).

OptionProsCons
A. Equirectangular on inverted sphereSource assets are already equirectangular — no conversion. Single 4096 × 2048 JPEG ≈ 5–8 MB. Sphere inverted, camera at centre.Slight pole-distortion in the texture (longitudinal stretching near the up/down poles). Doesn't matter for Apollo panoramas (the photographer never looked straight up or down).
B. Cube-mapNo pole distortion. 6 textures per face = sharper at corners.Authoring requires equirectangular → cube-map conversion (equirectangularToCubemap() shader). Larger total file size (6 faces × similar resolution = ~30 MB). Each face must be re-encoded.

Recommendation: A (equirectangular). Source assets are already in the format. File size is the smaller one. Pole distortion is invisible for landing-site panoramas (no one pans straight up to inspect the sky — the action is on the horizon).

Implementation:

ts
function enterGroundView(hotspotId: string, panoramaIndex: number) {
  // Load panorama texture (lazy).
  const texture = await new THREE.TextureLoader().loadAsync(panoramaPath);
  texture.colorSpace = THREE.SRGBColorSpace;
  texture.mapping = THREE.EquirectangularReflectionMapping;
  
  // Inverted sphere — camera inside, sees the texture.
  const skyGeo = new THREE.SphereGeometry(1000, 64, 32);
  const skyMat = new THREE.MeshBasicMaterial({ map: texture, side: THREE.BackSide });
  const sky = new THREE.Mesh(skyGeo, skyMat);
  scene.add(sky);
  
  // Animate camera from current Tier-2 position to ground level.
  // ...
  
  // Disable orbit controls; enable look-around (no panning, no zooming).
  controls.enableZoom = false;
  controls.enablePan = false;
  controls.minDistance = 0.001;
  controls.maxDistance = 0.001;  // camera fixed at centre
}

The panorama's "up" axis must be aligned with the lunar local-up at that site — meaning the texture's vertical centreline corresponds to looking north (or whatever the original photographer's frame was). The manifest records the panorama's reference azimuth so the texture rotates correctly.

ADR-061 captures the contract.

v0.7.x #PD-mars decisions on top of OQ-5 (Tier 3 composition, 2026-05-22):

The original OQ-5 recommendation (Option A — equirectangular sphere) survived contact with reality, but the production pipeline added several composition decisions worth noting for #PC (Moon Tier 3) and beyond:

  • Cylindrical → equirectangular padding. NASA mission panoramas are typically published as cylindrical projections (360° horizontal, ~50-90° vertical — the camera's tilt range), not equirectangular (180° vertical). A pre-render padding pass (scripts/hotspots/panorama-padder.ts) fills the missing top/bottom rows with a Mars sky-gradient (rgb(120,80,55) zenith → rgb(200,165,130) horizon — Mars sky has no blue) and regolith fill (rgb(120,70,50)). Partial-360 sources (Viking 1's ~342° azimuth, Zhurong's ~120°) also get an azimuth-gap fill.
  • Recolour-black source-data holes. NASA's cylindrical pads use literal black where data doesn't exist (rover-deck cutouts at nadir, edge bars at corners). Pasting those into the output preserves the holes verbatim. The padder's recolourBlackThreshold (default 60 = R+G+B) replaces near-black pixels with the per-row palette colour so cutouts blend into the surrounding sky / regolith instead of reading as wedges of void.
  • Tilt clamp ±20° from horizon at runtime. The frontend (src/routes/mars/+page.svelte) clamps the camera's pitch in panorama mode to roughly ±20°. Looser clamps revealed the padder's sky / regolith fill regions — not actual imagery — and made the panorama feel broken. ±20° is the intersection of all 10 shipped sources' vertical coverage. Future per-site overrides could be stored in the sidecar if a particular site's source has unusually wide coverage and we want to expose more of it.
  • Multi-agency from launch. PRD-014 originally scoped Tier 3 panoramas as "5 NASA showcase sites" for v0.7.x. The actually-shipped slice covers 10 sites across NASA + CNSA including Zhurong (via Wikimedia CC-BY-4.0) — establishing the precedent that immersive-experience features ship multi-agency from their first release rather than being NASA-only with non-NASA expansions deferred. The CNSA-EDU license-allowlist entry (PRD-014 license table) is the supporting infrastructure; future #PC (Moon Tier 3 with Chang'e) reuses the same allowlist.
  • Entry pose reset. Entering panorama mode resets camP to π/2 (horizon centred) and camT to 0 (deterministic azimuth) regardless of the user's prior orbital pose. The orbital pose is saved and restored on exit. Without the reset, the panorama opened at whatever pitch/yaw the user had been at while looking at the planet — typically an oblique angle that looked broken on first impression.

The docs/guides/mars-hotspot-imagery.md Tier 3 section documents the runbook + failure-mode matrix.


OQ-6 — 3D model authoring: hand-coded Three.js builders vs. glTF imports?

PRD-014 commits to engineering-correct hand-authored models. Two paths:

OptionPathProsCons
A. Hand-coded TS builders (extend the earth-satellite-models.ts pattern)A new mars-lander-models.ts and moon-lander-models.ts, each exporting a BUILDERS: Record<id, (color: string) => THREE.Group> map. Each lander/rover is 30–80 lines of BoxGeometry + CylinderGeometry + CapsuleGeometry.Same pattern as the existing /earth pin work. Tiny scene-mass. No glTF loader dependency. Build-step-free. Fully reviewable in PRs.Engineering-blueprint visual style, not photo-real. The user said WIRED-level, we deliver schematic-elegant — clearly attribution-grade-NASA-engineering-drawing-style, not Disneyland-render.
B. glTF imports (Three.js GLTFLoader)Per-mission .glb files (~500 KB each compressed) authored in Blender, imported on demand.Photo-real possible.New loader dependency (three/examples/jsm/loaders/GLTFLoader.js is ~50 KB). New asset class (.glb), new provenance discipline (3D-model licensing). 60 models × 500 KB = 30 MB scene mass. Author or source per model. Where do we source open-licence engineering-correct .glb files for every Apollo / Mars lander? NASA has some (Curiosity yes, Apollo LM no). The licensing review for the rest dominates.

Recommendation: A (hand-coded TS builders). The /earth pattern proves it works at WIRED quality — the Hubble, ISS, Starlink builders read as engineering-blueprint-elegant, not toy. We extend that style to landers/rovers. Each builder is ~50 lines of code, fully readable in PR review, fully attributable (cite the engineering reference for each dimension), and scales to V3 (60 models) at < 300 KB total scene mass. The "no compromise" PRD-014 promise is not "photo-real" — it's visual + dimensional accuracy at the right tier + the photo-real comes from the texture pipeline (Tier 2 mosaic + Tier 3 panorama). The models are the supporting cast.

The authoring contract — ADR-062:

ts
// In static/data/hotspots/{planet}/{id}.json:
{
  "id": "apollo-11",
  "model_id": "apollo-lm-eagle",
  "model_scale_m": 7.0,
  ...
}

// In src/lib/moon-lander-models.ts:
export const BUILDERS: Record<string, (color: string) => THREE.Group> = {
  'apollo-lm-eagle': buildApolloLM,
  'apollo-lm-falcon': buildApolloLM_JmissionStretchedLegs,
  // ...
  'soviet-luna-9': buildLuna9_Capsule,
  'chang-e-5-lander': buildChangeLander,
  // ...
};

function buildApolloLM(color: string): THREE.Group {
  // Descent stage — octagonal aluminum + Inconel tube.
  // Per NASA TN D-7700: 3.23 m wide across the 8 faces, 2.18 m tall.
  // ...
  // Ascent stage — 4.04 m wide × 3.76 m tall.
  // ...
  // 4 splayed legs — 4.27 m to footpad centre.
  // ...
  // Ladder, US flag, S-band, MESA, descent engine bell, foil texture.
}

Each builder must reference its engineering source in a code comment (ADR-062 §1.3). Public dimensions for every flown lander/rover exist in NASA TN reports, JPL drawings, ASU/MIT thesis dimensions. Failure modes (Beagle 2, Schiaparelli) get reconstructed-from-published-data builders too.

V1 builders required (12): apollo-lm (covers Apollo 11, 12, 14), apollo-lm-extended (covers 15, 16, 17), luna-sample-return, change-lander, viking-tripod, curiosity-class-rover, perseverance-rover, sojourner, phoenix-class-lander, soviet-mars-petal-lander, mars-3-petal (variant), chandrayaan-3-vikram.


OQ-7 — Memory eviction policy: what happens when 12 hotspots have all been visited and the user keeps exploring?

Tier 2 patch = 3–4 MB JPEG → 4–6 MB GPU texture (decoded). Tier 3 panorama = 5–8 MB JPEG → 8–12 MB GPU texture. Visit all 12 V1 hotspots at both tiers = 144 MB GPU resident. A Pixel 6 GPU memory budget is ~512 MB shared with the OS. Other GPU-resident textures (moon mosaic = 80 MB, mars mosaic = 80 MB) eat half the budget already.

Eviction policy (ADR-059):

ts
type LoadedTier = { hotspotId: string; tier: 2 | 3; texture: THREE.Texture; lastVisit: number };
const loadedTiers: LoadedTier[] = [];
const MAX_LOADED = 6;  // 6 hotspots × ~20 MB = ~120 MB ceiling

function getOrLoad(hotspotId: string, tier: 2 | 3): Promise<THREE.Texture> {
  const existing = loadedTiers.find(x => x.hotspotId === hotspotId && x.tier === tier);
  if (existing) {
    existing.lastVisit = performance.now();
    return Promise.resolve(existing.texture);
  }
  if (loadedTiers.length >= MAX_LOADED) {
    // Evict LRU: dispose() texture, splice out.
    loadedTiers.sort((a, b) => a.lastVisit - b.lastVisit);
    const evict = loadedTiers.shift()!;
    evict.texture.dispose();
  }
  // Load new...
}

LRU based on visit time. 6-slot LRU is enough for "user explores 6 sites in a row, comes back to the first" without re-fetching. Loaded tiers persist across same-route navigation; cleared on route change away from /moon or /mars (the textures are route-specific anyway).


OQ-8 — Mobile + saveData: what's the prefetch policy?

Three connection states matter:

Browser API statePolicy
navigator.connection.saveData === trueNever prefetch. Tier 2 patches load only on explicit Visit click. Tier 3 panoramas load only on explicit Stand at site click.
connection.effectiveType === '2g' || '3g'Defer prefetch by 2 s; if camera is approaching Tier 2 (projectedRadius > 100 px) and stays there ≥ 1 s, prefetch the Tier 2 patch. Don't prefetch Tier 3.
Default ('4g', '5g', desktop, unknown)Prefetch Tier 2 patch when camera approaches a marker's > 80 px projected size. Prefetch Tier 3 panorama on Tier-2 hover (user dwells on the site for ≥ 2 s).

A "tap to load" affordance appears in the Tier 2 patch's loading state when prefetch is suppressed (saveData true). The Tier 2 patch is replaced with a small placeholder: Tap to load high-resolution imagery (4 MB).

Locked into ADR-061.


OQ-9 — Test strategy: how do we e2e the Tier transitions without flake?

Each Tier transition is a camera animation over 300–600 ms. Asserting "the user is now at Tier 2" purely from DOM is impossible (Three.js renders to canvas, no DOM nodes per marker). The existing pattern (data-attribute mirroring on a hidden <div data-testid="render-state"> per ADR-030 §Layer 2) extends naturally.

ts
<div
  data-testid="hotspot-render-state"
  data-active-hotspot-id={activeHotspotId}
  data-active-tier={activeTier}
  data-tier-fade-progress={fadeProgress.toFixed(3)}
  data-camera-distance-km={cameraDistanceKm.toFixed(1)}
  aria-hidden="true"
/>

Per-hotspot smoke test:

ts
test('apollo-11 hotspot tiers up cleanly', async ({ page }) => {
  await page.goto('/moon');
  await page.click('[data-marker-id="apollo-11"]');
  await page.click('text=Visit landing site');
  // Wait for Tier 2 transition to settle.
  const renderState = page.locator('[data-testid="hotspot-render-state"]');
  await expect(renderState).toHaveAttribute('data-active-tier', '2', { timeout: 3_000 });
  // Patch texture loaded.
  await expect(renderState).toHaveAttribute('data-active-hotspot-id', 'apollo-11');
  // Annotations rendered — assert at least 3 are visible.
  const annotations = page.locator('.hotspot-annotation');
  await expect(annotations).toHaveCount(/* per-site */, { timeout: 2_000 });
  expect(consoleErrors).toEqual([]);
});

V1 commits to one smoke test per of the 12 Showcase + Standard hotspots, both desktop + mobile-chromium. e2e budget: 24 tests × ~3 s each = 72 s added to the suite (already at ~6:30 local, ~23 min CI). Within the 40-min CI ceiling per the recent ci(e2e) timeout bump.


OQ-10 — How do hotspots interact with the existing layer-filter chips (LAYERS · SOI · GRAVITY · APSIDES …)?

The layer chips are designed to hide or reveal layers of the rendered scene. Hotspots are a new scene-layer family. Two integration paths:

OptionApproachProsCons
A. Hotspots get a new chip: HOTSPOTS · ON / OFFSingle chip turns the whole progressive system on/off. Off = today's behaviour.Familiar. Minimal new UX.Coarse — a power user who wants Tier 0 only can't get it without turning off everything.
B. New 3-state chip: HOTSPOTS · AUTO / LOW / HIGHAUTO = progressive (default), LOW = pin Tier 0 everywhere, HIGH = pin Tier 2 on selected marker.Power users get the control.Adds a control surface. Educational labelling needed.
C. No chip; hotspots are always on, only the existing chips remainDefault-on, no opt-out.Simplest.Reduced-motion users / low-power devices have no way to dial back.

Recommendation: B. Per PRD-014 §UX-detail. AUTO is the headline experience; LOW is for low-end devices that can't sustain 30 fps on the transition; HIGH is for "I want the close-up right now" power-user mode. The chip lives in the existing .ctrl-row.chips cluster on /moon + /mars. State is URL-shareable via ?hotspots=low query param.

ADR-059 captures.


OQ-11 — Two-layer Tier 2 composition (regional + detail)

PRD-014 §v0.7.x adds a regional context layer underneath the existing HiRISE / LROC NAC detail patch. How do the two compose into a single dispatcher-tracked Tier 2 unit without introducing a new tier number or breaking the existing cross-fade?

OptionProsCons
A. New Tier 4 between current Tier 1 and Tier 2Cross-fade between layers happens automatically via dispatcher. Clean LOD progression.Renumbers Tier 3 / Tier 4 / panorama. Conflicts with existing hotspot_tier_max: 3 sidecar values. Tier 3 was "panorama mode" — moving it disturbs PRD-014 promise.
B. Single Tier 2 with two child meshesZero dispatcher changes. Existing cross-fade works as-is (the whole Tier 2 group fades). Both layers visible whenever Tier 2 is, layered by geometry size + polygonOffset.No cross-fade between regional → detail (both visible at once). Some visual stacking ambiguity but in practice the detail patch is small enough that it sits clearly on top.

Recommendation: B (single Tier 2, two child meshes). Detail disc (radius 0.3u) renders above regional disc (radius 0.75u) via stronger polygonOffset (-2 vs -1). When the camera is at Tier-2 range but far enough that the detail disc is small in screen- space, the wider regional context dominates. As camera zooms in, the detail disc fills the view naturally without any tier-change event firing. Sites without a regional layer (no hotspot_tier2_regional_source in sidecar) render only the detail disc — backward compatible with the pre-v0.7.x Mars hotspots.

Source attribution for v0.7.x:

Mars regional  Caltech Murray Lab Global CTX Mosaic V01 (5 m/px,
               cite Dickson et al. 2024, license CC-BY-Murray-Lab)
Mars detail    NASA / JPL-Caltech / UAhirise — HiRISE RDR JP2,
               25 cm/px (license PD-NASA via PDS)
Moon regional  CNSA Chang'e 2 lunar mosaic (7 m/px,
               license CC-BY-CNSA; license-allowlist update under
               ADR-047 for the new agency tag)
Moon detail    NASA / GSFC / Arizona State University — LROC NAC,
               50 cm/px (license PD-NASA via PDS)

OQ-12 — Source-attribution info card UX

In-context "what am I looking at + where did the imagery come from" overlay that fades in alongside Tier 2 (PRD-014 §v0.7.x). Required behaviour:

  1. Visible only when the dispatcher reports currentTier ≥ 2 for any hotspot on the camera-facing hemisphere; hidden in view === '2d' and panorama mode.
  2. Content reflects the dominant visible sub-layer (regional vs detail) — switches as the camera zooms in past the detail threshold.
  3. Sourced from image-provenance.json joined to the sidecar site entry. Authoritative source-of-truth: provenance entries already on disk; no UI authoring needed.
  4. Product ID is a link to the canonical agency page (UAhirise.org for HiRISE; Caltech Murray Lab for CTX; ASU LROC for LROC NAC; CNSA for Chang'e). Opens in a new tab.
  5. Bottom-left placement; pairs with the existing altitude indicator (bottom-right).
  6. Cross-fades using the same 600 ms timing as the tier transitions so the overlay feels like part of the same gesture.

ADR-061 reach extended for in-context attribution surfacing.

PRD-014 §v0.7.x photo gallery is a flat side-panel grid of 3-5 curated stills per site, agency-mixed. Decisions:

AspectChoice
Data shapeNew hotspot_photo_gallery: PhotoEntry[] field on the sidecar entry, where PhotoEntry = { id, url, caption, credit, license_short, source_url, agency }.
Where it rendersA new tab "PHOTOS · N" inside the existing site detail Panel — sibling to the existing tabs. Visible only when the array is non-empty.
Provenance integrationEach photo entry registered in image-provenance.json like any other image. Validate-data fails closed on missing provenance per ADR-047.
Per-image licensePer-photo license_short field allows multi-agency mixing (PD-NASA + CC-BY-CNSA + CC-BY-Roscosmos in the same gallery).
Lightbox UXClick thumbnail → fullscreen overlay with the image + caption + credit string + license badge. ESC or click-outside to dismiss. Keyboard nav left/right between photos.
Source curation effort~3-5 photos × 31 sites = ~120 stills total. Operator-curated per site; not auto-fetched. Lives in static/data/surface-hotspots.json as plain JSON; images at static/images/hotspots/<body>/<site>/gallery/*.jpg.
Cost$0 LLM (we're not scoring these via Image Pipeline v2 in v0.7 — curated set, fixed). Bandwidth: ~5-15 MB per site gallery × 31 = ~300 MB total static cost on initial site load… deferred behind the Panel-open trigger so it's lazy.

ADR-061 reach extended (panorama + gallery sibling).


Closes into

ADRTitleDecision focus
ADR-059Hotspot LOD architecture + per-frame dispatcherManual-dispatcher pattern (OQ-1), surface-patch quads (OQ-2), eviction policy (OQ-7), chip integration (OQ-10), two-layer Tier 2 composition (OQ-11)
ADR-060Orbital-imagery texture pipeline + georegistration disciplineSource URLs per body (OQ-4), georegistration tolerance ±50 m (OQ-3), provenance contract per asset, multi-agency CC-BY sources (OQ-13)
ADR-061Ground-view skybox + panorama policyEquirectangular projection (OQ-5), prefetch policy (OQ-8), reduced-motion + accessibility, source-attribution info card (OQ-12), photo gallery side panel (OQ-13)
ADR-062Hand-authored hardware model authoring contractTS-builder pattern with engineering-citation comments (OQ-6), V1 model list, naming convention

Slice plan (matching the V1 PRD scope)

SliceScopeShips
S1Hotspot manifest schema + 1 demo site (Apollo 11) + LOD dispatcherTier 0 + 1 transition for Apollo 11 only. Cross-fade working.
S2Surface-patch texture pipeline + apollo-11 patch.jpg fetched + georegistered + provenance rowTier 2 patch for Apollo 11. Click marker → Visit → patch fades in.
S35 more Moon hotspots (Apollo 17, Chang'e 5, Luna 9, Apollo 12, Apollo 15)Tier 0–2 for 6 moon hotspots.
S46 Mars hotspots (Curiosity, Perseverance, Viking 1, Phoenix, InSight, Sojourner)Tier 0–2 for all V1 mars sites.
S5Annotations system + 3–6 per Showcase siteTier 2 annotations visible + tappable.
S6i18n × 13 wave-2/3 locales for hotspot strings13/13 parity.
S7HOTSPOTS · AUTO / LOW / HIGH chip + URL stateUX surface complete.
S8e2e × 12 hotspots × desktop + mobile-chromiumTest coverage; CI green.
S9Preflight + Cmd-K verify + ship gateV1 deploy.

V2 is a separate slice plan (ground-view skybox + panorama prefetch + 5 Showcase sites lit up).

V1 estimate: 2 person-weeks at Marko-supervised cadence.


RFC-017 · v0.1 draft · May 2026 · Marko Dragoljevic


§close — v0.7.0 (2026-05-24)

All architectural commitments from this RFC shipped through the #PA–#PF wave. The 4-tier zoom-aware progressive disclosure (Tier 0 silhouette / Tier 1 3D model / Tier 2 LROC NAC or HiRISE patch / Tier 3 equirectangular skybox) is live on every Mars + Moon landing site (49 sites total). Open questions resolved:

  • OQ-1 (Tier-3 panorama bytes-on-disk vs runtime fetch): chose bytes-on-disk for the 4-9 MB panoramas — bundled into static/images, cached aggressively.
  • OQ-2 (Tier-2 patch projection per body): equirectangular for equatorial sites + polar-stereographic for high-latitude — wired through cropRemoteRasterToLatLon.
  • OQ-3 (sidecar hotspot_* field shape): chosen + frozen; surface-hotspots.json is the canonical source.
  • OQ-4 (Chang'e 2 regional layer ingest path): research closed — CNSA portal (moon.bao.ac.cn) requires JS-heavy interactive ordering, no programmatic API. Operator-manual ingest path documented in docs/guides/moon-hotspot-imagery.md. Frontend already handles two-layer composition; per-site JPEG + hotspot_tier2_regional_source sidecar field activate it.

v0.7.0 additions beyond original RFC scope:

  • Vision-scoring pipeline (PRD-018 / RFC-022, epic #148) generates pre-cropped 1:1 / 4:3 / 16:9 variants for every patch + panorama; MOBILE=1 selector serves the smaller variant.
  • Image-mime contract (GH #251 fix): all .jpg writes round-trip through sharp.
  • License-allowlist expanded for non-NASA imagery: CNSA-EDU, ISRO-EDU, JAXA-OPEN, SpaceIL-EDU, PD-Russia.
  • Trans-Mars trajectory overlay on /fly via src/lib/historical-mars-arcs.ts — historical-mission heliocentric arcs alongside the active simulation.

Open follow-ups (intentionally deferred):

  • GH #107CLOSED 2026-05-28. Full corpus rollout shipped: phase markers + science chips + scrubber on every events-populated mission (21 Moon + 14 Mars + 4 outer-system at tier_1_5_hybrid). Tier 2 (real NASA TND state vectors) reserved for v0.8+ as GH #262. See ADR-058 third amendment + docs/guides/mission-trajectories.md.
  • GH #255 — Trans-Mars overlay extension: agency filter UI, /mars route rendering.
  • Chang'e 2 per-site regional patches — operator-manual fetch path (no automation).

Orrery — architecture documentation · MIT · No tracking