fuzzyworld

tower / commit

feat(interior): standing inside Tower

7cc177f6bf5646aff2547f40628306b6f100a6d5 · Pavel Safronov · 2026-09-06 23:29:06 -0700 · parents c44860b9ba2c

feat(interior): standing inside Tower

Phase 5, and the piece design.md §2.4 said would carry the illusion. The scope
held: real geometry, simple ground. The shell is the actual cylinder, displaced by
terrain-height.png and painted with terrain-color.png; no scenery, no buildings.

Haze is integrated per fragment rather than faked — 24 samples through the
rotational density profile, extinguished per channel, since Rayleigh goes as the
fourth power of wavelength and that is what makes distance blue rather than grey.
From the floor it reproduces §1.8 from the inside: 78% straight up against 31% for
the same 100 km along the ground, printed in the readout so the claim can be
checked rather than believed.

The airlight is neutral, deliberately. Tinting it sky blue is the obvious move and
it is wrong: blue already scatters 2.4x harder in the per-channel extinction, so a
blue source colours the haze twice and every distant thing turns mauve regardless
of what it is made of — the desert is how that was spotted.

Nothing in the scene rotates, because the observer, the ground and the air all
co-rotate. Inside a sealed cylinder the spin is genuinely invisible, which is why
the rain is here at all.

Two traps, both found by looking rather than by testing:

The observer was buried. The camera stood 1.7 m above the *undisplaced* cylinder,
which is underground everywhere the land is higher than 1.7 m. From inside the rock
the ground is invisible — its underside is a front face and the shell is drawn
back-side only — so the view showed sky below the horizon and nothing else, and
looked perfect at 366 m of eye height. The GPU cannot be asked where the ground is,
so the height raster is read back and sampled against the mesh's own cell corners,
with half the spread across a cell as clearance: underground is much worse than
hovering.

And Three inserts its colour-space handling into its own materials and never into
a custom one, so the first build sampled the raster as if it were linear and wrote
linear output to an sRGB canvas. Adding colorspace_pars_fragment to fix it fails to
link, because Three already puts that chunk in the prefix it builds for every
material — with no console error, only INVALID_OPERATION once a frame.

Selecting a place walks the observer there rather than turning them toward it: the
haze horizon closes at 100-150 km, so facing a place usually just points at haze.
Places with extent also get a heading, so standing at the head of a river looks
down it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
 index.html            |  20 +-
 src/style.css         |   8 +-
 src/ui/app.ts         | 159 ++++++++-
 src/ui/scales.ts      |  20 ++
 src/views/interior.ts | 883 ++++++++++++++++++++++++++++++++++++++++++++++++++
 5 files changed, 1071 insertions(+), 19 deletions(-)

diff --git a/index.html b/index.html
index 4a06663..eb7aed2 100644
--- a/index.html
+++ b/index.html
@@ -11,12 +11,16 @@
   <main>
     <canvas id="map"></canvas>
     <canvas id="exterior" class="hidden"></canvas>
+    <canvas id="interior" class="hidden"></canvas>
     <div id="views" class="views">
       <button data-view="map" class="active">Map</button>
       <button data-view="exterior">Exterior</button>
+      <button data-view="interior">Interior</button>
     </div>
     <div id="ext-labels" class="labels3d hidden"></div>
+    <div id="int-labels" class="labels3d hidden"></div>
     <div id="spin" class="overlay hidden"></div>
+    <div id="standing" class="overlay hidden"></div>
     <div id="cursor" class="overlay hidden"></div>
     <div id="sightline" class="overlay hidden"></div>
   </main>
@@ -28,7 +32,7 @@
 
     <details class="sec" id="view-section" open>
       <summary>View</summary>
-      <label class="row"><input id="f-terrain" type="checkbox" checked /> Show terrain</label>
+      <label class="row" id="terrain-row"><input id="f-terrain" type="checkbox" checked /> Show terrain</label>
       <div id="map-options">
         <p class="hint">Rendered by <code>npm run terrain</code> from
           <code>content/geography.json</code>. On the exterior it is wrapped onto the
@@ -42,6 +46,20 @@
         <p class="hint">Tower turns once every 7 min 29 s. Drag to orbit, scroll to
           zoom, click the hull to drop a point.</p>
       </div>
+      <div id="interior-options" class="hidden">
+        <label>Eye height <span id="eye-value" class="dim"></span>
+          <input id="f-eye" type="range" min="0" max="1000" step="1" value="0" /></label>
+        <label>Light
+          <select id="f-light">
+            <option value="twilight">Twilight — 30 W/m²</option>
+            <option value="temperate" selected>Temperate — 200 W/m²</option>
+            <option value="full">Full — 319 W/m²</option>
+          </select></label>
+        <label class="row"><input id="f-rain" type="checkbox" /> Rain</label>
+        <p class="hint">Drag to look, scroll to zoom in like a pair of binoculars.
+          Selecting a place walks you there. Nothing here rotates: you, the ground
+          and the air all turn together, so the spin only shows in the rain.</p>
+      </div>
     </details>
 
     <details class="sec" id="tools-section">
diff --git a/src/style.css b/src/style.css
index 7c13f40..5e2e264 100644
--- a/src/style.css
+++ b/src/style.css
@@ -13,8 +13,8 @@ body {
 main { position: relative; overflow: hidden; }
 canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block;
          cursor: crosshair; touch-action: none; }
-#exterior { cursor: grab; }
-#exterior:active { cursor: grabbing; }
+#exterior, #interior { cursor: grab; }
+#exterior:active, #interior:active { cursor: grabbing; }
 .views {
   position: absolute; top: 12px; left: 12px; display: flex; gap: 0;
   border: 1px solid var(--line); border-radius: 6px; overflow: hidden;
@@ -34,6 +34,7 @@ canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block
   letter-spacing: 0.04em;
 }
 .labels3d span small { display: block; color: var(--dim); letter-spacing: 0; }
+#int-labels span:hover { color: var(--accent); }
 
 .overlay {
   position: absolute; bottom: 12px; left: 12px;
@@ -83,6 +84,8 @@ button {
   font: inherit; font-size: 12px; cursor: pointer;
 }
 button:hover { background: #222a3d; }
+button:disabled { opacity: 0.45; cursor: default; }
+button:disabled:hover { background: #1b2130; }
 button.active { border-color: var(--accent); color: var(--accent); background: #16233a; }
 button.danger { border-color: #4a2a2a; color: var(--danger); }
 button.danger:hover { background: #2a1a1a; }
@@ -98,7 +101,6 @@ button.danger:hover { background: #2a1a1a; }
 .list li.private .nm { opacity: 0.6; font-style: italic; }
 
 #detail p { margin: 0 0 8px; }
-#slat-canon { text-transform: none; letter-spacing: 0.04em; }
 #d-name { font-size: 14px; font-weight: 600; }
 label { display: block; margin-bottom: 9px; color: var(--dim); font-size: 11px; }
 label.row { display: flex; align-items: center; gap: 7px; color: var(--fg); font-size: 12px; }
diff --git a/src/ui/app.ts b/src/ui/app.ts
index 4d74a49..826a714 100644
--- a/src/ui/app.ts
+++ b/src/ui/app.ts
@@ -15,12 +15,15 @@ import { KIND_COLOR } from '../views/palette.js'
 import { WorldStore, backupFilename, download } from '../world/store.js'
 import { TowerMap } from '../views/map.js'
 import type { ExteriorView } from '../views/exterior.js'
-import { SPIN_PERIOD_S } from '../canon/constants.js'
+import type { InteriorView, Lighting } from '../views/interior.js'
+import { G0, LENGTH_M, SPIN_PERIOD_S, gravityAtHeight } from '../canon/constants.js'
+import { eyeHeightFromSlider, sliderFromEyeHeight } from './scales.js'
 
 type Tool = 'select' | PlaceKind
-type ViewName = 'map' | 'exterior'
+type ViewName = 'map' | 'exterior' | 'interior'
 const VIEW_KEY = 'tower.view'
-const isView = (v: string | null): v is ViewName => v === 'map' || v === 'exterior'
+const isView = (v: string | null): v is ViewName =>
+  v === 'map' || v === 'exterior' || v === 'interior'
 
 /** Time multipliers for the exterior spin, slowest first. */
 const SPEEDS = [1, 10, 60, 300, 1200]
@@ -43,6 +46,28 @@ const pathLength = (pts: readonly [number, number][]): number => {
   }
   return total
 }
+/** A single representative point for a place, whatever shape it is. */
+const pointOf = (p: Place): SurfacePoint => {
+  const g = p.geometry
+  if (g.type === 'point') return { theta: g.theta, z: g.z }
+  if (g.type === 'band') return { theta: 0, z: (g.zFrom + g.zTo) / 2 }
+  return { theta: g.points[0]![0], z: g.points[0]![1] }
+}
+
+/**
+ * Where to look on arrival. A place with extent gets its far end, so standing at
+ * the head of a river looks down it; a point has no direction of its own, so the
+ * heading is left alone.
+ */
+const facingOf = (p: Place): SurfacePoint | null => {
+  const g = p.geometry
+  if (g.type !== 'polyline' && g.type !== 'polygon') return null
+  const n = g.points.length
+  let theta = 0, z = 0
+  for (const [t, pz] of g.points) { theta += t; z += pz }
+  return { theta: theta / n, z: z / n }
+}
+
 const pct = (x: number): string => `${(x * 100).toFixed(1)}%`
 const fmtDuration = (s: number): string =>
   s >= 60 ? `${Math.floor(s / 60)} min ${Math.round(s % 60)} s` : `${s.toFixed(1)} s`
@@ -50,6 +75,7 @@ const fmtDuration = (s: number): string =>
 export class App {
   private map: TowerMap
   private exterior: ExteriorView | null = null
+  private interior: InteriorView | null = null
   private view: ViewName = 'map'
   /**
    * Writer mode is a UI mode, not a security boundary (D-011). It is guessable
@@ -83,6 +109,7 @@ export class App {
     window.addEventListener('resize', () => {
       this.map.resize()
       this.exterior?.resize()
+      this.interior?.resize()
     })
     window.addEventListener('keydown', (e) => this.onKey(e))
     // A close mid-debounce must not lose the last edit.
@@ -136,6 +163,33 @@ export class App {
     spin.onchange = apply
     speed.oninput = apply
     apply()
+    this.buildInteriorControls()
+  }
+
+  /**
+   * The interior controls have to work before the view is built, because the view
+   * is built on first use — so each handler sets its label unconditionally and only
+   * then talks to the renderer.
+   */
+  private buildInteriorControls(): void {
+    const eye = $<HTMLInputElement>('f-eye')
+    const showEye = (m: number) => {
+      $('eye-value').textContent =
+        `— ${m < 1000 ? `${m.toFixed(1)} m` : `${(m / 1000).toFixed(1)} km`}, ` +
+        `${(gravityAtHeight(m) / G0).toFixed(3)} g`
+    }
+    eye.oninput = () => {
+      const m = eyeHeightFromSlider(Number(eye.value) / 1000)
+      showEye(m)
+      this.interior?.setEyeHeight(m)
+      this.renderStanding()
+    }
+    showEye(eyeHeightFromSlider(Number(eye.value) / 1000))
+
+    const light = $<HTMLSelectElement>('f-light')
+    light.onchange = () => this.interior?.setLighting(light.value as Lighting)
+    const rain = $<HTMLInputElement>('f-rain')
+    rain.onchange = () => this.interior?.setRain(rain.checked)
   }
 
   /**
@@ -151,20 +205,31 @@ export class App {
       // Private window or blocked site data: the view just will not persist.
     }
     const isMap = view === 'map'
-    $('map').classList.toggle('hidden', isMap ? false : true)
-    $('exterior').classList.toggle('hidden', isMap)
+    const isExterior = view === 'exterior'
+    const isInterior = view === 'interior'
+    $('map').classList.toggle('hidden', !isMap)
+    $('exterior').classList.toggle('hidden', !isExterior)
+    $('interior').classList.toggle('hidden', !isInterior)
     $('map-options').classList.toggle('hidden', !isMap)
-    $('exterior-options').classList.toggle('hidden', isMap)
-    $('spin').classList.toggle('hidden', isMap)
-    $('ext-labels').classList.toggle('hidden', isMap)
+    $('exterior-options').classList.toggle('hidden', !isExterior)
+    $('interior-options').classList.toggle('hidden', !isInterior)
+    // The terrain raster is the ground itself in the interior; there is nothing to
+    // turn off, so the control goes away rather than sitting there doing nothing.
+    $('terrain-row').classList.toggle('hidden', isInterior)
+    $('spin').classList.toggle('hidden', !isExterior)
+    $('ext-labels').classList.toggle('hidden', !isExterior)
+    $('standing').classList.toggle('hidden', !isInterior)
+    $('int-labels').classList.toggle('hidden', !isInterior)
     for (const b of document.querySelectorAll<HTMLButtonElement>('#views button')) {
       b.classList.toggle('active', b.dataset['view'] === view)
     }
+    if (!isExterior) this.exterior?.stop()
+    if (!isInterior) this.interior?.stop()
     if (isMap) {
-      this.exterior?.stop()
       this.map.resize()
       return
     }
+    if (isInterior) return this.openInterior()
     if (!this.exterior) {
       // Genuinely deferred: Three.js is by far the largest dependency, and a static
       // import would bundle it into the entry chunk whether or not this view is
@@ -193,6 +258,31 @@ export class App {
     this.render()
   }
 
+  /** Same deferred-import bargain as the exterior: Three.js is not the map's cost. */
+  private async openInterior(): Promise<void> {
+    if (!this.interior) {
+      const { InteriorView } = await import('../views/interior.js')
+      this.interior = new InteriorView($<HTMLCanvasElement>('interior'), {
+        onPickPlace: (id) => {
+          this.selectedId = id
+          this.focusOn(id)
+          this.render()
+        },
+        onLook: () => this.renderStanding(),
+      }, $('int-labels'))
+      // Adopt whatever was restored from storage, so the panel is not lying.
+      const eye = $<HTMLInputElement>('f-eye')
+      eye.value = String(Math.round(sliderFromEyeHeight(this.interior.eyeM) * 1000))
+      eye.dispatchEvent(new Event('input'))
+      $<HTMLSelectElement>('f-light').value = this.interior.lighting
+      $<HTMLInputElement>('f-rain').checked = this.interior.showRain
+    }
+    this.interior.resize()
+    this.interior.setPlaces(this.store.places.filter((p) => p.geometry.type !== 'band'))
+    this.interior.start()
+    this.render()
+  }
+
   /** Readers get the map, the place list and the sightline probe — nothing else. */
   private applyMode(): void {
     $('mode').textContent = this.writer ? 'writer' : 'reader'
@@ -251,14 +341,16 @@ export class App {
   private focusOn(id: string): void {
     const p = this.store.get(id)
     if (!p) return
-    const g = p.geometry
-    const point = g.type === 'point'
-      ? { theta: g.theta, z: g.z }
-      : g.type === 'band'
-        ? { theta: 0, z: (g.zFrom + g.zTo) / 2 }
-        : { theta: g.points[0]![0], z: g.points[0]![1] }
+    const point = pointOf(p)
     this.map.centreOn(point)
     if (this.view === 'exterior') this.exterior?.lookAt(point)
+    // Each view does what "focus" means for it: the map centres, the exterior
+    // swings round, and the interior walks there — selecting a place is how you
+    // travel, since at ground level most of the world is beyond the haze horizon.
+    if (this.view === 'interior') {
+      this.interior?.visit(point, facingOf(p))
+      this.renderStanding()
+    }
   }
 
   // ------------------------------------------------------------------- toolbar
@@ -532,6 +624,10 @@ export class App {
       this.exterior?.setPlaces(this.store.places, this.selectedId)
       this.renderSpinReadout()
     }
+    if (this.view === 'interior') {
+      this.interior?.setPlaces(this.store.places.filter((p) => p.geometry.type !== 'band'))
+      this.renderStanding()
+    }
     this.map.draw()
 
     const total = this.store.places.length
@@ -639,6 +735,39 @@ export class App {
     ].join('\n')
   }
 
+  /**
+   * Where you are standing and what can be seen from there. The sightline straight
+   * up is the one worth printing: it is the crispest far view in Tower (design.md
+   * §1.8), and it is the fact readers find hardest to believe.
+   */
+  private renderStanding(): void {
+    const v = this.interior
+    if (!v) return
+    const here = v.at
+    const opposite = { theta: here.theta + Math.PI, z: here.z }
+    const across = sightline(here, opposite)
+    /**
+     * The axial comparison, along whichever direction has the most room. Near a cap
+     * there is no 100 km to look down, and silently measuring a shorter line while
+     * still labelling it 100 km made the haze look far kinder than it is.
+     */
+    const deepward = LENGTH_M - here.z >= here.z
+    const span = Math.min(100_000, deepward ? LENGTH_M - here.z : here.z)
+    const along = sightline(here, { theta: here.theta, z: here.z + (deepward ? span : -span) })
+    const rep = v.report()
+    const name = this.store.places.find((p) =>
+      p.geometry.type === 'point'
+      && Math.abs(p.geometry.z - here.z) < 2000
+      && Math.abs(p.geometry.theta - here.theta) < 0.04)?.name
+    $('standing').textContent = [
+      `standing   ${name ?? rep.standing}`,
+      ...(name ? [`           ${rep.standing}`] : []),
+      ...rep.lines,
+      `overhead   ${km(across.directDistanceM)} up · ${pct(across.transmission)} clear`,
+      `${(`${km(span)} on`).padEnd(10)} ${pct(along.transmission)} clear — the ground is the vague part`,
+    ].join('\n')
+  }
+
   private renderSave(): void {
     const el = $('save')
     el.classList.toggle('error', this.store.saveState === 'error')
diff --git a/src/ui/scales.ts b/src/ui/scales.ts
new file mode 100644
index 0000000..9283f88
--- /dev/null
+++ b/src/ui/scales.ts
@@ -0,0 +1,20 @@
+/**
+ * Slider mappings shared by the panel and the views.
+ *
+ * This lives outside views/ on purpose: the panel needs the eye-height scale to
+ * label its slider before the interior view exists, and importing the view for it
+ * would statically pull Three.js into the entry chunk — the exact regression
+ * D-021 fixed for the exterior.
+ */
+
+/** Standing eye height. */
+export const EYE_MIN_M = 1.7
+/** High enough to be a fifth of the way to the axis, and see the world as a tube. */
+export const EYE_MAX_M = 25_000
+
+/** Eye height from a 0..1 slider position. Logarithmic, so 1.7 m is reachable. */
+export const eyeHeightFromSlider = (v: number): number =>
+  EYE_MIN_M * (EYE_MAX_M / EYE_MIN_M) ** Math.min(1, Math.max(0, v))
+
+export const sliderFromEyeHeight = (h: number): number =>
+  Math.log(Math.max(EYE_MIN_M, h) / EYE_MIN_M) / Math.log(EYE_MAX_M / EYE_MIN_M)
diff --git a/src/views/interior.ts b/src/views/interior.ts
new file mode 100644
index 0000000..8a00abb
--- /dev/null
+++ b/src/views/interior.ts
@@ -0,0 +1,883 @@
+/**
+ * The interior view: standing inside Tower. See design.md §1.5, §1.8, §2.4.
+ *
+ * Scene units are kilometres, as in the exterior view, so the numbers in the code
+ * are the numbers in canon. The observer stands on the inner surface with the axis
+ * overhead; nothing in this scene rotates, because the observer, the ground and
+ * the air all co-rotate together. That is not a simplification — it is the point.
+ * Inside a sealed cylinder the spin is genuinely invisible, and the only thing that
+ * gives it away is what falls (src/canon/motion.ts).
+ *
+ * Three things carry the illusion, and all three are computed rather than styled:
+ *
+ *   - the ground curving up on both sides and closing overhead, which is just the
+ *     real cylinder seen from the inside;
+ *   - haze, integrated per fragment through Tower's rotational density profile, so
+ *     the far side reads clearer than the ground at the same distance (§1.8);
+ *   - the beam, a self-luminous shaft 3.65° wide and the only thing in the sky that
+ *     is not land.
+ */
+import type { Texture } from 'three'
+import {
+  AdditiveBlending, BackSide, CircleGeometry, Color, CylinderGeometry, DoubleSide,
+  Float32BufferAttribute, BufferGeometry, Group, LineBasicMaterial, LineSegments,
+  Matrix4, Mesh, PerspectiveCamera, RepeatWrapping, RingGeometry,
+  Scene, ShaderMaterial, TextureLoader, Vector3, WebGLRenderer,
+} from 'three'
+import {
+  BEAM_RADIUS_M, BRIGHTNESS_FULL_W_M2, BRIGHTNESS_TEMPERATE_W_M2,
+  BRIGHTNESS_TWILIGHT_W_M2, G0, LENGTH_M, OMEGA_RAD_S, RADIUS_M, RAYLEIGH_FLOOR_PER_M,
+  SPECIFIC_GAS_CONSTANT, TEMPERATURE_K, gravityAtHeight,
+} from '../canon/constants.js'
+import { RAINDROP_TERMINAL_M_S, steadyFallLeanRad } from '../canon/motion.js'
+import { type SurfacePoint, mapThetaOrigin, normalizeTheta } from '../canon/geometry.js'
+import type { Place } from '../world/schema.js'
+import { EYE_MAX_M, EYE_MIN_M } from '../ui/scales.js'
+import { capPatternTexture } from './cap-pattern.js'
+import {
+  COLOR_RASTER, HEIGHT_RASTER, RASTER_HEIGHT, RASTER_HEIGHT_SPAN_M,
+  RASTER_MIN_HEIGHT_M, RASTER_WIDTH,
+} from './raster.js'
+
+const KM = 1000
+const R = RADIUS_M / KM
+const L = LENGTH_M / KM
+const BEAM_R = BEAM_RADIUS_M / KM
+
+/**
+ * Rayleigh extinction per kilometre at floor density, per channel.
+ *
+ * Canon quotes 550 nm (green). Scattering goes as lambda^-4, so red survives about
+ * twice as far as green and blue about half as far — which is the whole reason
+ * distant land in Tower goes blue rather than merely grey, and it costs nothing to
+ * carry three numbers instead of one.
+ */
+const K_GREEN = RAYLEIGH_FLOOR_PER_M * KM
+const K_RGB: [number, number, number] = [
+  K_GREEN * (550 / 650) ** 4, K_GREEN, K_GREEN * (550 / 440) ** 4,
+]
+/** Density falls as exp(-a(R^2 - s^2)) — the rotational profile, in 1/km^2. */
+const DENSITY_A = ((OMEGA_RAD_S ** 2 / (2 * SPECIFIC_GAS_CONSTANT * TEMPERATURE_K)) * KM ** 2)
+
+/**
+ * Airlight: the radiance the haze itself adds. It is deliberately NEUTRAL.
+ *
+ * The obvious move is to make it sky blue, and it is wrong: blue already scatters
+ * 2.4x harder than green in K_RGB, so a blue source colours the haze twice and
+ * every distant thing turns mauve regardless of what it is made of. With a neutral
+ * source the blue falls out of the physics, and the far side of the world stays
+ * recognisably forest and desert at the 78% canon says it should be.
+ */
+const HAZE_DAY = 0x9aa3ad
+const HAZE_DUSK = 0x8d8792
+
+/** A raindrop is only ever seen close up, so the field only has to be a small box. */
+const RAIN_BOX_KM = 140 / KM
+const RAIN_STREAK_KM = 1.1 / KM
+
+/** Shell tessellation. 0.7 km triangles: distant relief is real, your feet are not. */
+const SEG_AROUND = 512
+const SEG_ALONG = 768
+
+export const LIGHTING = {
+  twilight: BRIGHTNESS_TWILIGHT_W_M2,
+  temperate: BRIGHTNESS_TEMPERATE_W_M2,
+  full: BRIGHTNESS_FULL_W_M2,
+} as const
+export type Lighting = keyof typeof LIGHTING
+
+const STATE_KEY = 'tower.interior'
+const STATE_SAVE_MS = 400
+
+export interface InteriorCallbacks {
+  onPickPlace: (id: string) => void
+  onLook: () => void
+}
+
+// ---------------------------------------------------------------------- shaders
+
+/**
+ * Transmittance from `a` to `b` through Tower's air, per channel.
+ *
+ * The integral has no closed form worth having — the exponent is a(s^2 - R^2) with
+ * s^2 quadratic along the ray — so it is sampled. The integrand is smooth and
+ * monotonic on each side of the ray's closest approach to the axis, so 24 midpoint
+ * samples land within a percent of the 512-sample version the instruments use, and
+ * the two agree on the canon table in design.md §1.8.
+ */
+const HAZE_GLSL = /* glsl */`
+uniform vec3 uCam;
+uniform vec3 uK;
+uniform vec3 uHaze;
+uniform float uExposure;
+const float A = ${DENSITY_A.toExponential(8)};
+const float RR = ${(R * R).toFixed(4)};
+
+vec3 towerTransmittance(vec3 from, vec3 to) {
+  vec3 d = to - from;
+  float len = length(d);
+  const int N = 24;
+  float tau = 0.0;
+  for (int i = 0; i < N; i++) {
+    float t = (float(i) + 0.5) / float(N);
+    vec2 p = from.xy + d.xy * t;
+    tau += exp(-A * (RR - dot(p, p)));
+  }
+  return exp(-uK * (tau * len / float(N)));
+}
+`
+
+const GROUND_VERT = /* glsl */`
+#include <common>
+#include <logdepthbuf_pars_vertex>
+uniform sampler2D uHeight;
+uniform vec2 uTexel;
+uniform float uMppKm;
+varying vec2 vUv;
+varying vec3 vWorld;
+varying vec3 vNormal2;
+
+/** The raster is stretched across a declared working range, not 0..anything. */
+float elevation(vec2 at) {
+  return ${(RASTER_MIN_HEIGHT_M / KM).toFixed(6)}
+       + texture2D(uHeight, at).r * ${(RASTER_HEIGHT_SPAN_M / KM).toFixed(6)};
+}
+
+void main() {
+  vUv = uv;
+  vec3 radial = normalize(vec3(position.xy, 0.0));
+  vec3 up = -radial;
+  // Spinward: z-hat x s-hat. The floor's other tangent is the axis itself.
+  vec3 spinward = vec3(-radial.y, radial.x, 0.0);
+  vec3 deepward = vec3(0.0, 0.0, 1.0);
+
+  float h = elevation(uv);
+  // Central differences on the raster, in km of rise per km of run.
+  float dz = (elevation(uv + vec2(uTexel.x, 0.0)) - elevation(uv - vec2(uTexel.x, 0.0)))
+             / (2.0 * uMppKm);
+  float dt = (elevation(uv + vec2(0.0, uTexel.y)) - elevation(uv - vec2(0.0, uTexel.y)))
+             / (2.0 * uMppKm);
+  vNormal2 = normalize(up - deepward * dz - spinward * dt);
+
+  vec3 p = position + up * h;
+  vWorld = p;
+  gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
+  #include <logdepthbuf_vertex>
+}
+`
+
+const GROUND_FRAG = /* glsl */`
+#include <common>
+#include <logdepthbuf_pars_fragment>
+uniform sampler2D uColor;
+varying vec2 vUv;
+varying vec3 vWorld;
+varying vec3 vNormal2;
+${HAZE_GLSL}
+
+/** Cheap value noise, to keep the ground from being flat colour underfoot. */
+float hash(vec2 p) {
+  return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
+}
+float vnoise(vec2 p) {
+  vec2 i = floor(p), f = fract(p);
+  f = f * f * (3.0 - 2.0 * f);
+  return mix(mix(hash(i), hash(i + vec2(1, 0)), f.x),
+             mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), f.x), f.y);
+}
+
+void main() {
+  #include <logdepthbuf_fragment>
+  // The raster is an sRGB image and every uniform Colour here is already linear,
+  // so the texture is the one thing that has to be brought into the working space.
+  // Three inserts that decode into its own materials and never into a custom one.
+  vec3 base = pow(texture2D(uColor, vUv).rgb, vec3(2.2));
+
+  /**
+   * The raster is 264 m per pixel, so within a few hundred metres of the observer
+   * one texel fills the screen. This is texture, not terrain: it breaks up the
+   * flat block without claiming there is anything there. It fades out past 4 km,
+   * where the raster is carrying real detail again.
+   */
+  float near = 1.0 - smoothstep(0.4, 4.0, distance(uCam, vWorld));
+  // Unrolled surface coordinates. World xyz shears the pattern into radial streaks
+  // exactly where the ground is most grazing, which is most of the view.
+  vec2 surf = vec2(vWorld.z, atan(vWorld.y, vWorld.x) * ${R.toFixed(3)});
+  // Rotated and off-harmonic octaves. Axis-aligned value noise at one scale reads
+  // as a woven diamond lattice, which is worse than the flat colour it replaced.
+  vec2 rot = vec2(surf.x * 0.86 - surf.y * 0.51, surf.x * 0.51 + surf.y * 0.86);
+  float grain = vnoise(surf * 173.0) * 0.55 + vnoise(rot * 611.0) * 0.45;
+  base *= 1.0 + (grain - 0.5) * 0.12 * near;
+
+  // The beam is a line source on the axis, so the light comes from straight up.
+  vec3 radial = normalize(vec3(vWorld.xy, 0.0));
+  float lambert = clamp(dot(vNormal2, -radial), 0.0, 1.0);
+  // The rest of the world is lit land, and lit land is what fills the shadows.
+  vec3 lit = base * (0.34 + 0.80 * lambert) * uExposure;
+
+  vec3 T = towerTransmittance(uCam, vWorld);
+  gl_FragColor = vec4(lit * T + uHaze * uExposure * (1.0 - T), 1.0);
+  #include <colorspace_fragment>
+}
+`
+
+const FLAT_VERT = /* glsl */`
+#include <common>
+#include <logdepthbuf_pars_vertex>
+varying vec3 vWorld;
+void main() {
+  vWorld = (modelMatrix * vec4(position, 1.0)).xyz;
+  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
+  #include <logdepthbuf_vertex>
+}
+`
+
+const CAP_FRAG = /* glsl */`
+#include <common>
+#include <logdepthbuf_pars_fragment>
+uniform vec3 uTint;
+uniform sampler2D uPattern;
+varying vec3 vWorld;
+${HAZE_GLSL}
+void main() {
+  #include <logdepthbuf_fragment>
+  // Planar UV across the cap's bounding box — the same mapping Three gives the
+  // disc geometry, so the pattern lands identically here and in the exterior view.
+  vec2 uv = vWorld.xy / ${(2 * R).toFixed(1)} + 0.5;
+  vec3 face = pow(texture2D(uPattern, uv).rgb, vec3(2.2)) * uTint;
+  vec3 T = towerTransmittance(uCam, vWorld);
+  gl_FragColor = vec4(face * uExposure * T + uHaze * uExposure * (1.0 - T), 1.0);
+  #include <colorspace_fragment>
+}
+`
+
+const BEAM_VERT = /* glsl */`
+#include <common>
+#include <logdepthbuf_pars_vertex>
+varying vec3 vWorld;
+varying vec3 vNormal2;
+void main() {
+  vWorld = (modelMatrix * vec4(position, 1.0)).xyz;
+  vNormal2 = normalize(mat3(modelMatrix) * normal);
+  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
+  #include <logdepthbuf_vertex>
+}
+`
+
+/**
+ * The beam is glowing air, not a surface, so it is brightest where the sightline
+ * passes through the most of it — down the middle — and fades at the limb. A tube
+ * shaded the usual way does the exact opposite and reads as a chrome pipe.
+ */
+const BEAM_FRAG = /* glsl */`
+#include <common>
+#include <logdepthbuf_pars_fragment>
+uniform vec3 uBeam;
+varying vec3 vWorld;
+varying vec3 vNormal2;
+${HAZE_GLSL}
+void main() {
+  #include <logdepthbuf_fragment>
+  vec3 view = normalize(vWorld - uCam);
+  // Additive white clips long before the limb, so the falloff has to start early
+  // or the shaft renders as a hard-edged slab instead of a glowing rod.
+  float through = pow(clamp(abs(dot(vNormal2, view)), 0.0, 1.0), 1.1);
+  vec3 T = towerTransmittance(uCam, vWorld);
+  gl_FragColor = vec4(uBeam * uExposure * through * T, 1.0);
+  #include <colorspace_fragment>
+}
+`
+
+// ------------------------------------------------------------------------- view
+
+export class InteriorView {
+  private renderer: WebGLRenderer
+  private scene = new Scene()
+  private camera: PerspectiveCamera
+  private ground: Mesh
+  private groundMat: ShaderMaterial
+  private shared: ShaderMaterial[] = []
+  private rain: LineSegments
+  private rainGroup = new Group()
+  private rainVel: Float32Array
+  private running = false
+  private lastFrame = 0
+  private dragging = false
+  private lastPointer = { x: 0, y: 0 }
+  private saveTimer: ReturnType<typeof setTimeout> | null = null
+  /** The height raster, read back so the observer can stand on the ground. */
+  private heights: { data: Uint8ClampedArray; w: number; h: number } | null = null
+
+  /** Where the observer stands. Default is the capital, at the centre of the world. */
+  at: SurfacePoint = { theta: 0, z: LENGTH_M / 2 }
+  /** Eye height above the floor, in metres. */
+  eyeM = EYE_MIN_M
+  /** Heading, radians clockwise from deepward (+z). */
+  yaw = 0
+  /** Elevation angle, radians. Clamped just short of straight up and down. */
+  pitch = 0
+  fovDeg = 60
+  lighting: Lighting = 'temperate'
+  showRain = false
+
+  private labels: { place: Place; el: HTMLSpanElement }[] = []
+
+  constructor(
+    private canvas: HTMLCanvasElement,
+    private cb: InteriorCallbacks,
+    private labelHost?: HTMLElement,
+  ) {
+    this.renderer = new WebGLRenderer({ canvas, antialias: true, logarithmicDepthBuffer: true })
+    this.renderer.setClearColor(HAZE_DAY)
+    /**
+     * Half a metre to 1,500 km is a depth range no fixed-point buffer survives, and
+     * both ends are required: the near plane has to clear an eye height of 1.7 m,
+     * and the far plane has to reach the far cap 540 km away. Hence the logarithmic
+     * depth buffer above.
+     */
+    this.camera = new PerspectiveCamera(this.fovDeg, 1, 0.0005, 1500)
+
+    this.groundMat = this.buildGroundMaterial()
+    this.ground = this.buildGround()
+    this.scene.add(this.ground)
+    this.buildCaps()
+    this.buildBeam()
+    this.rainVel = new Float32Array(0)
+    this.rain = this.buildRain()
+    this.scene.add(this.rainGroup)
+
+    this.restoreState()
+    this.applyLighting()
+    this.attach()
+    this.place()
+    this.resize()
+    // The canvas is hidden until this view is opened, so its box is zero-sized at
+    // construction time when the view is restored on load. Same trap as D-029.
+    new ResizeObserver(() => this.resize()).observe(canvas)
+  }
+
+  // ------------------------------------------------------------------ geometry
+
+  private buildGroundMaterial(): ShaderMaterial {
+    const m = new ShaderMaterial({
+      vertexShader: GROUND_VERT,
+      fragmentShader: GROUND_FRAG,
+      side: BackSide,
+      uniforms: {
+        uColor: { value: null }, uHeight: { value: null },
+        uTexel: { value: [1 / RASTER_WIDTH, 1 / RASTER_HEIGHT] },
+        uMppKm: { value: L / RASTER_WIDTH },
+        ...this.sharedUniforms(),
+      },
+    })
+    this.shared.push(m)
+    void this.loadRasters(m)
+    return m
+  }
+
+  private sharedUniforms(): Record<string, { value: unknown }> {
+    return {
+      uCam: { value: new Vector3() },
+      uK: { value: new Vector3(...K_RGB) },
+      uHaze: { value: new Color(HAZE_DAY) },
+      uExposure: { value: 1 },
+    }
+  }
+
+  private buildGround(): Mesh {
+    const g = new CylinderGeometry(R, R, L, SEG_AROUND, SEG_ALONG, true)
+    g.rotateX(Math.PI / 2)
+    g.translate(0, 0, L / 2)
+    /**
+     * The rasters run x = z and y = theta from the map's cut; Three's cylinder runs
+     * u around and v along. Deriving theta from vertex positions instead would smear
+     * a column of triangles across the image, because the seam vertices are
+     * duplicated and atan2 gives both the same angle (D-023).
+     *
+     * Three builds the ring as x = R sin t, z = R cos t, so after rotateX the angle
+     * in the XY plane is t - pi/2; canon theta is measured from +X.
+     */
+    const uv = g.attributes['uv']!
+    const shift = 0.25 + mapThetaOrigin() / (2 * Math.PI)
+    for (let i = 0; i < uv.count; i++) {
+      uv.setXY(i, uv.getY(i), uv.getX(i) - shift)
+    }
+    uv.needsUpdate = true
+    return new Mesh(g, this.groundMat)
+  }
+
+  private async loadRasters(m: ShaderMaterial): Promise<void> {
+    const load = (name: string): Promise<Texture> =>
+      new Promise((resolve, reject) => {
+        new TextureLoader().load(`${import.meta.env.BASE_URL}${name}`, (tex) => {
+          tex.wrapS = RepeatWrapping
+          tex.wrapT = RepeatWrapping
+          // uv.y is computed rather than read from the file's row order, so no flip.
+          tex.flipY = false
+          tex.anisotropy = this.renderer.capabilities.getMaxAnisotropy()
+          resolve(tex)
+        }, undefined, reject)
+      })
+    try {
+      const [color, height] = await Promise.all([load(COLOR_RASTER), load(HEIGHT_RASTER)])
+      m.uniforms['uColor']!.value = color
+      m.uniforms['uHeight']!.value = height
+      m.needsUpdate = true
+      this.readHeights(height)
+      this.place()
+      // The panel's elevation readout was drawn before any of this existed.
+      this.cb.onLook()
+    } catch {
+      // No rasters yet. The shell still renders; it is simply blank.
+    }
+  }
+
+  /**
+   * Read the height raster back onto the CPU.
+   *
+   * The shell is displaced on the GPU, which is fine until somebody has to stand on
+   * it: the camera would otherwise sit 1.7 m above the *undisplaced* cylinder, which
+   * is underground everywhere the land is higher than that — which is everywhere.
+   * From inside the rock the ground is invisible (its underside is a front face, and
+   * the shell is drawn back-side only), so the view showed sky below the horizon and
+   * nothing else. There is no way to ask the GPU where the ground is, so the answer
+   * has to be computed here from the same data the shader displaces by.
+   */
+  private readHeights(tex: Texture): void {
+    const img = tex.image as CanvasImageSource & { width: number; height: number }
+    const canvas = document.createElement('canvas')
+    canvas.width = img.width
+    canvas.height = img.height
+    const ctx = canvas.getContext('2d', { willReadFrequently: false })
+    if (!ctx) return
+    ctx.drawImage(img, 0, 0)
+    try {
+      const { data } = ctx.getImageData(0, 0, img.width, img.height)
+      this.heights = { data, w: img.width, h: img.height }
+    } catch {
+      // A tainted canvas would only mean standing on the reference cylinder again.
+    }
+  }
+
+  /** Bilinear sample of the height raster, in km. Wraps in theta, clamps in z. */
+  private sampleHeightKm(u: number, v: number): number {
+    const src = this.heights
+    if (!src) return 0
+    const x = Math.min(src.w - 1.001, Math.max(0, u * src.w - 0.5))
+    const y = ((v * src.h - 0.5) % src.h + src.h) % src.h
+    const x0 = Math.floor(x), y0 = Math.floor(y)
+    const fx = x - x0, fy = y - y0
+    const at = (px: number, py: number): number =>
+      src.data[((py % src.h) * src.w + Math.min(src.w - 1, px)) * 4]! / 255
+    const top = at(x0, y0) * (1 - fx) + at(x0 + 1, y0) * fx
+    const bot = at(x0, y0 + 1) * (1 - fx) + at(x0 + 1, y0 + 1) * fx
+    return (RASTER_MIN_HEIGHT_M + (top * (1 - fy) + bot * fy) * RASTER_HEIGHT_SPAN_M) / KM
+  }
+
+  /**
+   * Height of the rendered ground under a surface point, in km.
+   *
+   * This has to agree with the *mesh*, not with the raster: the shell's cells are
+   * 0.7 km across and the raster's are 0.26 km, so the surface actually drawn is the
+   * raster sampled at the cell corners and stretched flat between them. Sampling the
+   * raster directly would put the observer's feet a few tens of metres off in rough
+   * country, and underground is a far worse error than hovering — so the four corners
+   * are interpolated, and half the spread across the cell is added as clearance. On
+   * the plains that clearance is nil; on a ridge it lifts you clear of the facet.
+   */
+  private groundKm(p: SurfacePoint): number {
+    if (!this.heights) return 0
+    const shift = 0.25 + mapThetaOrigin() / (2 * Math.PI)
+    const jf = (p.z / LENGTH_M) * SEG_ALONG
+    const iF = (normalizeTheta(p.theta - mapThetaOrigin()) / (2 * Math.PI) + shift) * SEG_AROUND
+    const j0 = Math.max(0, Math.min(SEG_ALONG - 1, Math.floor(jf)))
+    const i0 = Math.floor(iF)
+    const fj = Math.min(1, Math.max(0, jf - j0))
+    const fi = iF - i0
+    const corner = (di: number, dj: number): number =>
+      this.sampleHeightKm((j0 + dj) / SEG_ALONG, ((i0 + di) / SEG_AROUND - shift))
+    const c = [corner(0, 0), corner(1, 0), corner(0, 1), corner(1, 1)]
+    const lo = c[0]! * (1 - fj) + c[2]! * fj
+    const hi = c[1]! * (1 - fj) + c[3]! * fj
+    return lo * (1 - fi) + hi * fi + (Math.max(...c) - Math.min(...c)) / 2
+  }
+
+  /** Ground elevation under the observer, in metres. Reported by the panel. */
+  get elevationM(): number {
+    return this.groundKm(this.at) * KM
+  }
+
+  private capMaterial(tint: number): ShaderMaterial {
+    const m = new ShaderMaterial({
+      vertexShader: FLAT_VERT, fragmentShader: CAP_FRAG, side: DoubleSide,
+      uniforms: {
+        uTint: { value: new Color(tint) },
+        uPattern: { value: capPatternTexture() },
+        ...this.sharedUniforms(),
+      },
+    })
+    this.shared.push(m)
+    return m
+  }
+
+  /**
+   * The caps close the world at both ends. From the midpoint they are 270 km away
+   * and 4% transmitted (§1.8), so they are all but pure haze — which is the correct
+   * and slightly unnerving result, and worth drawing precisely because it is.
+   */
+  private buildCaps(): void {
+    // The tints are now multipliers over the pattern, not the colour of the cap.
+    // The sun cap sits closer to the beam's brightest end; the far cap is dimmer.
+    const sun = new Mesh(new RingGeometry(BEAM_R, R, 128), this.capMaterial(0xffffff))
+    this.scene.add(sun)
+    const far = new Mesh(new CircleGeometry(R, 128), this.capMaterial(0xbcc2cc))
+    far.position.z = L
+    this.scene.add(far)
+  }
+
+  private buildBeam(): void {
+    const g = new CylinderGeometry(BEAM_R, BEAM_R, L, 48, 1, true)
+    g.rotateX(Math.PI / 2)
+    g.translate(0, 0, L / 2)
+    const m = new ShaderMaterial({
+      vertexShader: BEAM_VERT, fragmentShader: BEAM_FRAG,
+      uniforms: { uBeam: { value: new Color(0xfff2d6).multiplyScalar(0.85) }, ...this.sharedUniforms() },
+      transparent: true, blending: AdditiveBlending, depthWrite: false, side: DoubleSide,
+    })
+    this.shared.push(m)
+    this.scene.add(new Mesh(g, m))
+  }
+
+  /**
+   * Rain, because with the hull sealed it is the only moving thing in the sky and
+   * the only evidence Tower turns. It leans antispinward by 1.5° — computed, not
+   * chosen, and much subtler than the stars it replaces. The readout prints the
+   * angle so the smallness is legible rather than looking like a bug.
+   */
+  private buildRain(): LineSegments {
+    const N = 2200
+    const streak = RAIN_STREAK_KM
+    const lean = Math.tan(steadyFallLeanRad(RAINDROP_TERMINAL_M_S))
+    const pos = new Float32Array(N * 6)
+    this.rainVel = new Float32Array(N)
+    for (let i = 0; i < N; i++) {
+      const x = (Math.random() - 0.5) * RAIN_BOX_KM
+      const y = (Math.random() - 0.5) * RAIN_BOX_KM
+      const z = (Math.random() - 0.5) * RAIN_BOX_KM
+      // Local frame: +x spinward, +y up, +z deepward. Falling is -y, leaning -x.
+      pos.set([x, y, z, x + streak * lean, y + streak, z], i * 6)
+      this.rainVel[i] = (0.85 + Math.random() * 0.3) * RAINDROP_TERMINAL_M_S / KM
+    }
+    const g = new BufferGeometry()
+    g.setAttribute('position', new Float32BufferAttribute(pos, 3))
+    const line = new LineSegments(g, new LineBasicMaterial({
+      color: 0xcfe0f2, transparent: true, opacity: 0.42, depthWrite: false,
+    }))
+    line.frustumCulled = false
+    this.rainGroup.add(line)
+    this.rainGroup.visible = false
+    return line
+  }
+
+  /** Fall, and wrap around the box. Only the y of each streak ever changes. */
+  private stepRain(dt: number): void {
+    const attr = this.rain.geometry.attributes['position']!
+    const a = attr.array as Float32Array
+    for (let i = 0; i < this.rainVel.length; i++) {
+      let y = a[i * 6 + 1]! - this.rainVel[i]! * dt
+      if (y < -RAIN_BOX_KM / 2) y += RAIN_BOX_KM
+      a[i * 6 + 1] = y
+      a[i * 6 + 4] = y + RAIN_STREAK_KM
+    }
+    attr.needsUpdate = true
+  }
+
+  // -------------------------------------------------------------------- camera
+
+  /** Basis at the observer: up is toward the axis, deepward is +z, spinward is theta-hat. */
+  private basis(): { up: Vector3; spinward: Vector3; deepward: Vector3; eye: Vector3 } {
+    const { theta } = this.at
+    const radial = new Vector3(Math.cos(theta), Math.sin(theta), 0)
+    // Up is toward the axis, so standing on high ground means a *smaller* radius.
+    const s = R - this.groundKm(this.at) - this.eyeM / KM
+    return {
+      up: radial.clone().negate(),
+      spinward: new Vector3(-Math.sin(theta), Math.cos(theta), 0),
+      deepward: new Vector3(0, 0, 1),
+      eye: new Vector3(radial.x * s, radial.y * s, this.at.z / KM),
+    }
+  }
+
+  /** Put the camera where the observer is, pointing where they are looking. */
+  private place(): void {
+    const { up, spinward, deepward, eye } = this.basis()
+    const dir = deepward.clone().multiplyScalar(Math.cos(this.pitch) * Math.cos(this.yaw))
+      .addScaledVector(spinward, Math.cos(this.pitch) * Math.sin(this.yaw))
+      .addScaledVector(up, Math.sin(this.pitch))
+    this.camera.position.copy(eye)
+    this.camera.up.copy(up)
+    this.camera.lookAt(eye.clone().add(dir))
+    this.camera.fov = this.fovDeg
+    this.camera.updateProjectionMatrix()
+
+    for (const m of this.shared) (m.uniforms['uCam']!.value as Vector3).copy(eye)
+    this.rainGroup.position.copy(eye)
+    this.rainGroup.setRotationFromMatrix(new Matrix4().makeBasis(spinward, up, deepward))
+  }
+
+  /**
+   * Go and stand at a place.
+   *
+   * Selecting something moves the observer rather than turning them toward it: at
+   * ground level almost everything in Tower is over the haze horizon, so turning to
+   * face a place usually just points at haze. `facing` is for places with extent —
+   * stand at the head of a river and look down it — and is null for a point, where
+   * arriving *is* the answer and there is nothing to aim at.
+   */
+  visit(p: SurfacePoint, facing: SurfacePoint | null = null): void {
+    this.at = { theta: normalizeTheta(p.theta), z: Math.min(LENGTH_M, Math.max(0, p.z)) }
+    if (facing) this.yaw = this.headingTo(facing)
+    // Arriving somewhere looking at your feet is never what was wanted.
+    this.pitch = 0
+    this.place()
+    this.queueSave()
+  }
+
+  /** Heading from where the observer stands to a surface point, as a yaw. */
+  private headingTo(p: SurfacePoint): number {
+    const dz = (p.z - this.at.z) / KM
+    const dTheta = normalizeTheta(p.theta - this.at.theta)
+    const around = (dTheta > Math.PI ? dTheta - 2 * Math.PI : dTheta) * R
+    return Math.abs(around) + Math.abs(dz) < 1e-6 ? this.yaw : Math.atan2(around, dz)
+  }
+
+  private applyLighting(): void {
+    /**
+     * Eyes adapt, so twilight is not 6.7x darker than temperate to look at. The
+     * exponent compresses the canon watts into something a screen can show while
+     * keeping the ordering and the ratios honest.
+     */
+    const e = (LIGHTING[this.lighting] / BRIGHTNESS_TEMPERATE_W_M2) ** 0.35
+    for (const m of this.shared) m.uniforms['uExposure']!.value = e
+    const haze = new Color(this.lighting === 'twilight' ? HAZE_DUSK : HAZE_DAY)
+    for (const m of this.shared) (m.uniforms['uHaze']!.value as Color).copy(haze)
+    this.renderer.setClearColor(haze, 1)
+  }
+
+  setLighting(l: Lighting): void {
+    this.lighting = l
+    this.applyLighting()
+    this.queueSave()
+  }
+
+  // ------------------------------------------------------------------- labels
+
+  setPlaces(places: readonly Place[]): void {
+    if (!this.labelHost) return
+    this.labelHost.replaceChildren()
+    this.labels = places.map((place) => {
+      const el = document.createElement('span')
+      el.innerHTML = `${place.name.toUpperCase()}<small>${place.kind}</small>`
+      el.style.pointerEvents = 'auto'
+      el.style.cursor = 'pointer'
+      el.onclick = () => this.cb.onPickPlace(place.id)
+      this.labelHost!.append(el)
+      return { place, el }
+    })
+  }
+
+  private placePoint(p: Place): SurfacePoint | null {
+    const g = p.geometry
+    if (g.type === 'point') return { theta: g.theta, z: g.z }
+    if (g.type === 'band') return null
+    const first = g.points[0]
+    return first ? { theta: first[0], z: first[1] } : null
+  }
+
+  /**
+   * Captions for what is in view. Distant things are dropped once the haze has
+   * eaten them, so the labels agree with what can actually be seen rather than
+   * naming a mountain that is not there.
+   */
+  private updateLabels(): void {
+    if (!this.labels.length) return
+    const { width, height } = this.canvas.getBoundingClientRect()
+    const eye = this.camera.position
+    const v = new Vector3()
+    const placed: { x: number; y: number }[] = []
+    for (const { place, el } of this.labels) {
+      const at = this.placePoint(place)
+      if (!at) { el.style.display = 'none'; continue }
+      const s = R
+      v.set(s * Math.cos(at.theta), s * Math.sin(at.theta), at.z / KM)
+      const dist = v.distanceTo(eye)
+      v.project(this.camera)
+      const x = ((v.x + 1) / 2) * width
+      const y = ((1 - v.y) / 2) * height
+      let visible = v.z < 1 && Math.abs(v.x) < 1 && Math.abs(v.y) < 1 && dist > 0.3
+      // Roughly: green extinction at the mean density along the ray. Exact enough
+      // to decide whether a caption is honest.
+      if (visible && Math.exp(-K_GREEN * dist * 0.55) < 0.06) visible = false
+      if (visible && placed.some((q) => Math.abs(q.x - x) < 150 && Math.abs(q.y - y) < 30)) {
+        visible = false
+      }
+      el.style.display = visible ? 'block' : 'none'
+      if (!visible) continue
+      placed.push({ x, y })
+      el.style.left = `${x}px`
+      el.style.top = `${y}px`
+      el.style.opacity = String(Math.max(0.35, Math.min(1, 1 - dist / 260)))
+    }
+  }
+
+  // --------------------------------------------------------------- interaction
+
+  private attach(): void {
+    this.canvas.addEventListener('pointerdown', (e) => {
+      this.dragging = true
+      this.lastPointer = { x: e.clientX, y: e.clientY }
+      this.canvas.setPointerCapture(e.pointerId)
+    })
+    this.canvas.addEventListener('pointermove', (e) => {
+      if (!this.dragging) return
+      const dx = e.clientX - this.lastPointer.x
+      const dy = e.clientY - this.lastPointer.y
+      this.lastPointer = { x: e.clientX, y: e.clientY }
+      // Scale with the field of view, so zoomed in does not mean flung around.
+      const rate = (this.fovDeg / 60) * 0.0032
+      this.yaw -= dx * rate
+      this.pitch = Math.max(-1.55, Math.min(1.55, this.pitch + dy * rate))
+      this.place()
+      this.cb.onLook()
+      this.queueSave()
+    })
+    const end = (e: PointerEvent) => {
+      this.dragging = false
+      this.canvas.releasePointerCapture?.(e.pointerId)
+    }
+    this.canvas.addEventListener('pointerup', end)
+    this.canvas.addEventListener('pointercancel', end)
+    this.canvas.addEventListener('wheel', (e) => {
+      e.preventDefault()
+      this.fovDeg = Math.max(3, Math.min(90, this.fovDeg * (e.deltaY > 0 ? 1.08 : 1 / 1.08)))
+      this.place()
+      this.cb.onLook()
+      this.queueSave()
+    }, { passive: false })
+    window.addEventListener('pagehide', () => this.save())
+  }
+
+  setEyeHeight(m: number): void {
+    this.eyeM = Math.max(EYE_MIN_M, Math.min(EYE_MAX_M, m))
+    this.place()
+    this.queueSave()
+  }
+
+  setRain(on: boolean): void {
+    this.showRain = on
+    this.rainGroup.visible = on
+    this.queueSave()
+  }
+
+  // -------------------------------------------------------------------- state
+
+  private queueSave(): void {
+    if (this.saveTimer) clearTimeout(this.saveTimer)
+    this.saveTimer = setTimeout(() => this.save(), STATE_SAVE_MS)
+  }
+
+  private save(): void {
+    if (this.saveTimer) clearTimeout(this.saveTimer)
+    this.saveTimer = null
+    try {
+      localStorage.setItem(STATE_KEY, JSON.stringify({
+        theta: this.at.theta, z: this.at.z, eye: this.eyeM,
+        yaw: this.yaw, pitch: this.pitch, fov: this.fovDeg,
+        lighting: this.lighting, rain: this.showRain,
+      }))
+    } catch {
+      // Not persisting where you stood is survivable.
+    }
+  }
+
+  /** Every field is checked: a bad entry here would strand the view (D-030). */
+  private restoreState(): void {
+    try {
+      const raw = localStorage.getItem(STATE_KEY)
+      if (!raw) return
+      const s = JSON.parse(raw) as Record<string, unknown>
+      const num = (v: unknown, lo: number, hi: number): number | null =>
+        typeof v === 'number' && Number.isFinite(v) && v >= lo && v <= hi ? v : null
+      const theta = num(s['theta'], 0, 2 * Math.PI)
+      const z = num(s['z'], 0, LENGTH_M)
+      if (theta !== null && z !== null) this.at = { theta, z }
+      this.eyeM = num(s['eye'], EYE_MIN_M, EYE_MAX_M) ?? this.eyeM
+      this.yaw = num(s['yaw'], -100, 100) ?? this.yaw
+      this.pitch = num(s['pitch'], -1.55, 1.55) ?? this.pitch
+      this.fovDeg = num(s['fov'], 3, 90) ?? this.fovDeg
+      if (s['lighting'] === 'twilight' || s['lighting'] === 'temperate' || s['lighting'] === 'full') {
+        this.lighting = s['lighting']
+      }
+      this.showRain = s['rain'] === true
+      this.rainGroup.visible = this.showRain
+    } catch {
+      // Unreadable storage just means standing in the capital again.
+    }
+  }
+
+  // -------------------------------------------------------------------- readout
+
+  /** What the instruments say from here. Rendered by the app into the overlay. */
+  report(): { standing: string; lines: string[] } {
+    const deg = (r: number) => ((r * 180) / Math.PI)
+    const heading = normalizeTheta(this.yaw)
+    const compass = ['deepward', 'spinward', 'sunward', 'antispinward'][
+      Math.round(deg(heading) / 90) % 4
+    ]!
+    const elevation = this.elevationM
+    const overhead = RADIUS_M - elevation - this.eyeM
+    const beamAngle = 2 * Math.atan(BEAM_RADIUS_M / overhead)
+    const eyeText = this.eyeM < KM
+      ? `${this.eyeM.toFixed(1)} m` : `${(this.eyeM / KM).toFixed(1)} km`
+    return {
+      standing: `θ ${deg(this.at.theta).toFixed(1)}°  z ${(this.at.z / KM).toFixed(1)} km`,
+      lines: [
+        `ground     ${elevation.toFixed(0)} m above the datum`,
+        `eye        ${eyeText} · ${(gravityAtHeight(elevation + this.eyeM) / G0).toFixed(4)} g`,
+        `looking    ${deg(heading).toFixed(0)}° ${compass}, ${deg(this.pitch) >= 0 ? '+' : ''}${deg(this.pitch).toFixed(0)}°`,
+        `field      ${this.fovDeg.toFixed(0)}°`,
+        `beam       ${deg(beamAngle).toFixed(2)}° wide overhead`,
+        `rain leans ${deg(steadyFallLeanRad(RAINDROP_TERMINAL_M_S)).toFixed(1)}° antispinward`,
+      ],
+    }
+  }
+
+  resize(): void {
+    const { width, height } = this.canvas.getBoundingClientRect()
+    if (!width || !height) return
+    this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
+    this.renderer.setSize(width, height, false)
+    this.camera.aspect = width / height
+    this.camera.updateProjectionMatrix()
+  }
+
+  start(): void {
+    if (this.running) return
+    this.running = true
+    this.lastFrame = performance.now()
+    const tick = (now: number) => {
+      if (!this.running) return
+      const dt = Math.min(0.1, (now - this.lastFrame) / 1000)
+      this.lastFrame = now
+      if (this.showRain) this.stepRain(dt)
+      this.renderer.render(this.scene, this.camera)
+      this.updateLabels()
+      requestAnimationFrame(tick)
+    }
+    requestAnimationFrame(tick)
+  }
+
+  stop(): void {
+    this.running = false
+  }
+}