fuzzyworld

tower / commit

feat(exterior): Tower from outside, with hull picking

5f38efd0d4ca537cfcefdf74f6984450f44a4cc2 · Pavel Safronov · 2026-09-06 20:31:12 -0700 · parents 624586728d68

feat(exterior): Tower from outside, with hull picking

Phase 3. Hull, both caps, the 244 km collector flying 320 km ahead, the beam
converging into the sun cap aperture, and the spin at 1x to 1200x real time.

Scene units are kilometres and the axis lies along Z with the sun cap at
z = 0, so a canon surface point is literally (R cos theta, R sin theta, z)
and there is no conversion layer to get wrong. Picking resolves the ray hit
back through worldToLocal, because the hull turns and canon theta is fixed
to the hull rather than to the world — clicking the same pixel 34 degrees of
spin apart moves the reported theta by the same 34 degrees and leaves z
untouched.

Two departures from physics, both stated in the code. Sunlight runs parallel
to the axis, so the flank is edge-on to it and renders almost black, which
is accurate and useless; fill lights make it readable. And vacuum has
nothing to scatter the beam, so it would not be visible at all; it is drawn
faintly because it is what the view is about.

Three.js loads on first use of the view rather than at startup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
 README.md             |   8 ++
 decisions.md          |  35 +++++++
 design.md             |  30 +++++-
 index.html            |  21 +++-
 package-lock.json     |  75 ++++++++++++++
 package.json          |   6 +-
 src/style.css         |  16 ++-
 src/ui/app.ts         |  98 +++++++++++++++++-
 src/views/exterior.ts | 275 ++++++++++++++++++++++++++++++++++++++++++++++++++
 9 files changed, 554 insertions(+), 10 deletions(-)

diff --git a/README.md b/README.md
index fc9cc94..3c46370 100644
--- a/README.md
+++ b/README.md
@@ -86,6 +86,14 @@ Any selection updates the URL, so a link is always shareable:
 | `#place=the-narrows` | select and centre a place |
 | `#at=0.03,170000` | drop a sightline endpoint at (θ, z) |
 
+## Views
+
+**Map** is the unrolled inner surface. **Exterior** is Tower from outside — hull,
+end caps, the 244 km collector flying ahead and the beam converging into the sun
+cap. Drag to orbit, scroll to zoom, click the hull to drop a point; the spin runs at
+1x to 1200x real time, and a point picked off the turning hull still comes back in
+canon coordinates.
+
 ## Working in the map
 
 Pick a tool, click to place; **Lake**, **Forest**, **Desert** and **Region** trace a
diff --git a/decisions.md b/decisions.md
index d1a9359..48c61ac 100644
--- a/decisions.md
+++ b/decisions.md
@@ -700,3 +700,38 @@ a permalink is worthless if the id churns.
 **Labels are placed by priority and skipped on collision.** Settlements outrank
 regions; overlapping labels are dropped rather than drawn on top of each other. With
 22 seeded places the mountain stations were an unreadable smear.
+
+---
+
+## D-023 — The exterior view, and what it is honest about
+
+**Implemented.** Phase 3: Tower from outside, with the hull, both caps, the
+collector, the converging beam, the spin, and picking on the hull.
+
+**Canon numbers are the code's numbers.** Scene units are kilometres and the axis
+lies along Z with the sun cap at z = 0, so a canon point is literally
+`(R cos θ, R sin θ, z)`. No conversion layer to get wrong.
+
+**Picking has to undo the spin.** The hull turns, so a ray hit is in world space
+while the answer wanted is canon θ, which is fixed to the hull. Resolving the hit
+through `worldToLocal` handles it. Verified by clicking the same screen pixel at two
+spin phases 34° apart and confirming the reported θ moved by 0.588 rad against a
+0.593 rad phase change, with z identical — the pixel really is landing on different
+ground as Tower turns beneath it.
+
+**Two departures from physics, both stated in the code.** Sunlight runs parallel to
+the axis, so the flank is edge-on and correctly renders almost black; fill lights
+make it readable. And in vacuum the beam between collector and cap has nothing to
+scatter off, so it would be invisible; it is drawn faintly because it is the point
+of the view. Same licence as the map's hillshade, and worth naming rather than
+letting a reader mistake either for a physical claim.
+
+**Three.js loads on first use of the view.** It is by far the largest dependency and
+starts an animation loop; a reader who only opens the map should not pay for it.
+
+**Earth is not drawn.** It is ~90° off the axis and hundreds of thousands of km
+away; at true scale it is invisible, and at any visible scale it is a lie about
+distance. Given how much of this world came out of taking distances seriously, a
+compressed Earth would be the wrong kind of shortcut. The sunward glow is honest —
+the sun really is that way — and D-017's point stands: from inside, Earth is never
+visible at all.
diff --git a/design.md b/design.md
index 99430ee..0b09105 100644
--- a/design.md
+++ b/design.md
@@ -508,7 +508,7 @@ src/
   state/
     store.ts        selected point, time-of-day, spin phase, view mode, writer flag
   views/
-    exterior.ts     Three.js: hull, slats, collector, beam, orbit, point picker
+    exterior.ts     Three.js: hull, caps, collector, beam, spin, point picker
     map.ts          Canvas 2D: unrolled surface, slats, terminator, point picker
     interior.ts     Three.js: shell geometry, beam, diffusers, ground
     sky.ts          the atmospheric shader of §1.5
@@ -543,6 +543,28 @@ type TowerState = {
 The exterior and map pickers both write `point`; the interior view reads it. That
 is the entire interaction loop.
 
+### 2.4 The exterior view
+
+Scene units are kilometres, so the code reads in canon numbers. Tower's axis lies
+along world Z with the sun cap at z = 0, which puts a canon surface point at
+`(R cos θ, R sin θ, z)` in the hull's own frame. The hull spins about Z and carries
+that frame with it, so **picking resolves the ray hit back through
+`worldToLocal`** — otherwise every point picked off a turning hull would be wrong by
+the spin phase.
+
+Two deliberate departures from physics, both reading aids of the same kind as the
+map's hillshade:
+
+- **Fill lighting.** Real sunlight here runs parallel to the axis, so Tower's flank
+  is edge-on to it and renders very nearly black — accurate and useless. Two fill
+  lights lift the hull enough to read its shape.
+- **A visible beam.** In vacuum there is nothing between collector and cap to
+  scatter light, so the converging cone would not be visible at all. It is drawn
+  faintly because it is the thing the view is about.
+
+The default camera frames the whole system rather than Tower alone: the collector
+is 244 km across and flies 320 km ahead, and it is the larger object.
+
 ### 2.4 The interior view
 
 Per the chosen scope: **real geometry, simple ground.**
@@ -660,7 +682,7 @@ a moment. Reading the world, not editing it.
 
 1. **Design** — this document and `decisions.md`. ← current
 2. **Scaffold** — Vite/TS/Three, CI to Pages, `canon/` implemented and unit-tested.
-3. **Exterior** — hull, collector, beam, slats, orbit; hull point picking.
+3. **Exterior** — ✅ hull, caps, collector, beam, spin, and hull point picking.
 4. **Map** — unrolled surface, slat rendering, picking, sync with exterior.
 5. **Interior** — shell geometry, sky shader, beam, stars, time-of-day.
 6. **Writer mode** — ✅ *built ahead of 3–5, at the author's request, because the
@@ -672,8 +694,8 @@ a moment. Reading the world, not editing it.
    writer modes (D-011); permalinks (D-010).
 8. **Polish** — transitions, mobile, performance.
 
-Phases 3 (exterior) and 5 (interior) are still ahead; phase 2's `canon/` landed
-with phase 6 since the map could not be drawn without it.
+Phase 5 (interior) is the last major piece. Phase 2's `canon/` landed with phase 6,
+since the map could not be drawn without it.
 
 **Shareable permalinks are implemented** (phase 7) — the app has
 readers, and "stand here and look at this" is the whole reason it exists as a
diff --git a/index.html b/index.html
index ffbb7ac..30b4deb 100644
--- a/index.html
+++ b/index.html
@@ -10,6 +10,12 @@
 <div id="app">
   <main>
     <canvas id="map"></canvas>
+    <canvas id="exterior" class="hidden"></canvas>
+    <div id="views" class="views">
+      <button data-view="map" class="active">Map</button>
+      <button data-view="exterior">Exterior</button>
+    </div>
+    <div id="spin" class="overlay hidden"></div>
     <div id="cursor" class="overlay hidden"></div>
     <div id="sightline" class="overlay hidden"></div>
   </main>
@@ -70,9 +76,18 @@
 
     <section id="view-section">
       <h2>View</h2>
-      <label class="row"><input id="f-terrain" type="checkbox" checked /> Show terrain</label>
-      <p class="hint">Rendered by <code>npm run terrain</code> from
-        <code>content/geography.json</code>.</p>
+      <div id="map-options">
+        <label class="row"><input id="f-terrain" type="checkbox" checked /> Show terrain</label>
+        <p class="hint">Rendered by <code>npm run terrain</code> from
+          <code>content/geography.json</code>.</p>
+      </div>
+      <div id="exterior-options" class="hidden">
+        <label class="row"><input id="f-spin" type="checkbox" checked /> Spin</label>
+        <label>Speed <span id="speed-value" class="dim"></span>
+          <input id="f-speed" type="range" min="0" max="4" step="1" value="2" /></label>
+        <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>
     </section>
 
     <section id="data-section">
diff --git a/package-lock.json b/package-lock.json
index 40a3564..e2d5f3d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7,13 +7,24 @@
     "": {
       "name": "tower",
       "version": "0.1.0",
+      "dependencies": {
+        "three": "^0.180.0"
+      },
       "devDependencies": {
         "@types/node": "^26.4.1",
+        "@types/three": "^0.180.0",
         "typescript": "^7.0.2",
         "vite": "^8.2.2",
         "vitest": "^5.0.0"
       }
     },
+    "node_modules/@dimforge/rapier3d-compat": {
+      "version": "0.12.0",
+      "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
+      "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
+      "dev": true,
+      "license": "Apache-2.0"
+    },
     "node_modules/@jridgewell/resolve-uri": {
       "version": "3.1.2",
       "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -314,6 +325,13 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/@tweenjs/tween.js": {
+      "version": "23.1.3",
+      "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
+      "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/@types/chai": {
       "version": "5.2.3",
       "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -349,6 +367,36 @@
         "undici-types": "~8.3.0"
       }
     },
+    "node_modules/@types/stats.js": {
+      "version": "0.17.4",
+      "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
+      "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/three": {
+      "version": "0.180.0",
+      "resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
+      "integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@dimforge/rapier3d-compat": "~0.12.0",
+        "@tweenjs/tween.js": "~23.1.3",
+        "@types/stats.js": "*",
+        "@types/webxr": "*",
+        "@webgpu/types": "*",
+        "fflate": "~0.8.2",
+        "meshoptimizer": "~0.22.0"
+      }
+    },
+    "node_modules/@types/webxr": {
+      "version": "0.5.24",
+      "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
+      "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/@typescript/typescript-aix-ppc64": {
       "version": "7.0.2",
       "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
@@ -727,6 +775,13 @@
         "url": "https://opencollective.com/vitest"
       }
     },
+    "node_modules/@webgpu/types": {
+      "version": "0.1.72",
+      "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.72.tgz",
+      "integrity": "sha512-0cF7RFM2edNoiIS1ODJp0/Gzv4/xSXhwoR0YCza+OWpJWtn4wmo9DvK91aLlH9+uUnwIriP7ZiC3WitmyhuzBw==",
+      "dev": true,
+      "license": "BSD-3-Clause"
+    },
     "node_modules/assertion-error": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -802,6 +857,13 @@
         }
       }
     },
+    "node_modules/fflate": {
+      "version": "0.8.3",
+      "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
+      "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/fsevents": {
       "version": "2.3.3",
       "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1088,6 +1150,13 @@
         "@jridgewell/sourcemap-codec": "^1.5.5"
       }
     },
+    "node_modules/meshoptimizer": {
+      "version": "0.22.0",
+      "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz",
+      "integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/nanoid": {
       "version": "3.3.18",
       "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
@@ -1235,6 +1304,12 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/three": {
+      "version": "0.180.0",
+      "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
+      "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
+      "license": "MIT"
+    },
     "node_modules/tinybench": {
       "version": "6.1.4",
       "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz",
diff --git a/package.json b/package.json
index fd9caab..9233358 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
   },
   "devDependencies": {
     "@types/node": "^26.4.1",
+    "@types/three": "^0.180.0",
     "typescript": "^7.0.2",
     "vite": "^8.2.2",
     "vitest": "^5.0.0"
@@ -22,5 +23,8 @@
     "type": "git",
     "url": "https://gitlab.com/PavelSafronov/tower.git"
   },
-  "homepage": "https://pavelsafronov.gitlab.io/tower/"
+  "homepage": "https://pavelsafronov.gitlab.io/tower/",
+  "dependencies": {
+    "three": "^0.180.0"
+  }
 }
diff --git a/src/style.css b/src/style.css
index 1c5d33e..5de6b4e 100644
--- a/src/style.css
+++ b/src/style.css
@@ -11,7 +11,21 @@ body {
 }
 #app { display: grid; grid-template-columns: 1fr 340px; height: 100%; }
 main { position: relative; overflow: hidden; }
-canvas { width: 100%; height: 100%; display: block; cursor: crosshair; touch-action: none; }
+canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block;
+         cursor: crosshair; touch-action: none; }
+#exterior { cursor: grab; }
+#exterior:active { cursor: grabbing; }
+.views {
+  position: absolute; top: 12px; left: 12px; display: flex; gap: 0;
+  border: 1px solid var(--line); border-radius: 6px; overflow: hidden;
+  background: rgba(10,13,20,0.86);
+}
+.views button {
+  width: auto; margin: 0; border: none; border-radius: 0; padding: 6px 14px;
+  background: transparent; font-size: 12px;
+}
+.views button.active { background: #1d2942; color: var(--accent); }
+#spin { left: auto; right: 12px; bottom: auto; top: 12px; }
 
 .overlay {
   position: absolute; bottom: 12px; left: 12px;
diff --git a/src/ui/app.ts b/src/ui/app.ts
index 3bf5ffa..7d89b85 100644
--- a/src/ui/app.ts
+++ b/src/ui/app.ts
@@ -15,8 +15,14 @@ import {
 import { KIND_COLOR } from '../views/palette.js'
 import { WorldStore, backupFilename, download } from '../world/store.js'
 import { TowerMap } from '../views/map.js'
+import { ExteriorView } from '../views/exterior.js'
+import { SPIN_PERIOD_S } from '../canon/constants.js'
 
 type Tool = 'select' | PlaceKind
+type ViewName = 'map' | 'exterior'
+
+/** Time multipliers for the exterior spin, slowest first. */
+const SPEEDS = [1, 10, 60, 300, 1200]
 
 const $ = <T extends HTMLElement>(id: string): T => {
   const el = document.getElementById(id)
@@ -37,9 +43,13 @@ const pathLength = (pts: readonly [number, number][]): number => {
   return total
 }
 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`
 
 export class App {
   private map: TowerMap
+  private exterior: ExteriorView | null = null
+  private view: ViewName = 'map'
   /**
    * Writer mode is a UI mode, not a security boundary (D-011). It is guessable
    * either way, and that is fine: with no backend an editor can only change the
@@ -61,6 +71,7 @@ export class App {
       onDragPlace: (id, p) => this.onDragPlace(id, p),
       onHover: (p) => this.onHover(p),
     })
+    this.buildViews()
     this.buildTools()
     this.buildKindSelect()
     this.wireEditor()
@@ -69,7 +80,10 @@ export class App {
     this.wireData()
     this.applyMode()
     window.addEventListener('hashchange', () => this.readHash())
-    window.addEventListener('resize', () => this.map.resize())
+    window.addEventListener('resize', () => {
+      this.map.resize()
+      this.exterior?.resize()
+    })
     window.addEventListener('keydown', (e) => this.onKey(e))
     // A close mid-debounce must not lose the last edit.
     window.addEventListener('pagehide', () => this.store.flush())
@@ -79,6 +93,72 @@ export class App {
     this.render()
   }
 
+  // ----------------------------------------------------------------- views
+
+  private buildViews(): void {
+    for (const b of document.querySelectorAll<HTMLButtonElement>('#views button')) {
+      b.onclick = () => this.setView(b.dataset['view'] as ViewName)
+    }
+    const spin = $<HTMLInputElement>('f-spin')
+    const speed = $<HTMLInputElement>('f-speed')
+    const apply = () => {
+      const scale = SPEEDS[Number(speed.value)]!
+      // Set the label first: it must update whether or not the view exists yet.
+      $('speed-value').textContent =
+        `— ${scale}x, one turn in ${fmtDuration(SPIN_PERIOD_S / scale)}`
+      if (!this.exterior) return
+      this.exterior.spinning = spin.checked
+      this.exterior.timeScale = scale
+    }
+    spin.onchange = apply
+    speed.oninput = apply
+    apply()
+  }
+
+  /**
+   * The exterior renderer is built on first use. It pulls in Three.js and starts an
+   * animation loop, neither of which a reader who only ever opens the map should pay
+   * for.
+   */
+  private setView(view: ViewName): void {
+    this.view = view
+    const isMap = view === 'map'
+    $('map').classList.toggle('hidden', isMap ? false : true)
+    $('exterior').classList.toggle('hidden', isMap)
+    $('map-options').classList.toggle('hidden', !isMap)
+    $('exterior-options').classList.toggle('hidden', isMap)
+    $('spin').classList.toggle('hidden', isMap)
+    for (const b of document.querySelectorAll<HTMLButtonElement>('#views button')) {
+      b.classList.toggle('active', b.dataset['view'] === view)
+    }
+    if (isMap) {
+      this.exterior?.stop()
+      this.map.resize()
+      return
+    }
+    if (!this.exterior) {
+      this.exterior = new ExteriorView($<HTMLCanvasElement>('exterior'), {
+        onPickPoint: (p) => {
+          this.map.probe = { from: p, to: null }
+          this.selectedId = null
+          this.render()
+        },
+        onPickPlace: (id) => {
+          this.selectedId = id
+          this.render()
+        },
+      })
+      const spin = $<HTMLInputElement>('f-spin')
+      const speed = $<HTMLInputElement>('f-speed')
+      this.exterior.spinning = spin.checked
+      this.exterior.timeScale = SPEEDS[Number(speed.value)]!
+    }
+    this.exterior.resize()
+    this.exterior.setPlaces(this.store.places, this.selectedId)
+    this.exterior.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'
@@ -136,6 +216,7 @@ export class App {
       ? { theta: g.theta, z: g.z }
       : { theta: g.points[0]![0], z: g.points[0]![1] }
     this.map.centreOn(point)
+    if (this.view === 'exterior') this.exterior?.lookAt(point)
   }
 
   // ------------------------------------------------------------------- toolbar
@@ -426,6 +507,10 @@ export class App {
     this.map.places = this.store.places
     this.map.selectedId = this.selectedId
     this.map.dragEnabled = this.tool === 'select'
+    if (this.view === 'exterior') {
+      this.exterior?.setPlaces(this.store.places, this.selectedId)
+      this.renderSpinReadout()
+    }
     this.map.draw()
 
     const total = this.store.places.length
@@ -516,6 +601,17 @@ export class App {
     return `${g.points.length} vertices`
   }
 
+  /** Where the spin has got to, since nothing in Tower's own sky would tell you. */
+  private renderSpinReadout(): void {
+    const e = this.exterior
+    if (!e) return
+    $('spin').textContent = [
+      `spin phase   ${((e.phase * 180) / Math.PI).toFixed(0).padStart(3)}°`,
+      `period       ${fmtDuration(SPIN_PERIOD_S)}`,
+      `rim speed    700.2 m/s`,
+    ].join('\n')
+  }
+
   private renderSave(): void {
     const el = $('save')
     el.classList.toggle('error', this.store.saveState === 'error')
diff --git a/src/views/exterior.ts b/src/views/exterior.ts
new file mode 100644
index 0000000..ba3e797
--- /dev/null
+++ b/src/views/exterior.ts
@@ -0,0 +1,275 @@
+/**
+ * The exterior view: Tower seen from outside. See design.md §1.3, §1.4, §1.6.
+ *
+ * Scene units are kilometres, so the numbers in the code are the numbers in canon.
+ * Tower's axis lies along world Z with the sun cap at z = 0, which makes a canon
+ * surface point (theta, z) sit at (R cos theta, R sin theta, z) in the hull's own
+ * frame — the hull then spins about Z and the frame carries the point with it.
+ */
+import {
+  AdditiveBlending, AmbientLight, BackSide, BufferGeometry, CircleGeometry, Color,
+  CylinderGeometry, DirectionalLight, DoubleSide, Float32BufferAttribute, Group, Mesh,
+  MeshBasicMaterial, MeshStandardMaterial, PerspectiveCamera, Points, PointsMaterial,
+  Raycaster, RingGeometry, Scene, SphereGeometry, TorusGeometry, Vector2, Vector3,
+  WebGLRenderer,
+} from 'three'
+import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
+import {
+  BEAM_RADIUS_M, COLLECTOR_DIAMETER_M, LENGTH_M, OMEGA_RAD_S, RADIUS_M,
+} from '../canon/constants.js'
+import { type SurfacePoint, normalizeTheta } from '../canon/geometry.js'
+import type { Place } from '../world/schema.js'
+import { KIND_COLOR } from './palette.js'
+
+const KM = 1000
+const R = RADIUS_M / KM
+const L = LENGTH_M / KM
+const BEAM_R = BEAM_RADIUS_M / KM
+const COLLECTOR_R = COLLECTOR_DIAMETER_M / KM / 2
+/** How far ahead of the sun cap the collector flies. Not fixed by canon. */
+const COLLECTOR_STANDOFF = 320
+
+export interface ExteriorCallbacks {
+  onPickPoint: (p: SurfacePoint) => void
+  onPickPlace: (id: string) => void
+}
+
+export class ExteriorView {
+  private renderer: WebGLRenderer
+  private scene = new Scene()
+  private camera: PerspectiveCamera
+  private controls: OrbitControls
+  /** Everything that co-rotates with the hull, so markers stay put on the surface. */
+  private tower = new Group()
+  private markers = new Group()
+  private hull: Mesh
+  private raycaster = new Raycaster()
+  private running = false
+  private lastFrame = 0
+  private moved = false
+
+  /** Spin phase in radians, shared with the rest of the app. */
+  phase = 0
+  /** Multiple of real time. Tower turns once per 7.5 min, which is slow to watch. */
+  timeScale = 60
+  spinning = true
+
+  constructor(private canvas: HTMLCanvasElement, private cb: ExteriorCallbacks) {
+    this.renderer = new WebGLRenderer({ canvas, antialias: true })
+    this.renderer.setClearColor(0x05070d)
+    this.camera = new PerspectiveCamera(42, 1, 1, 40_000)
+    // Framed to hold the whole system: the collector is 244 km across and 320 km
+    // ahead of the sun cap, so Tower alone is not the subject.
+    this.camera.position.set(260, -1180, 300)
+    this.controls = new OrbitControls(this.camera, canvas)
+    this.controls.target.set(0, 0, (L - COLLECTOR_STANDOFF) / 2)
+    this.controls.enableDamping = true
+    this.controls.dampingFactor = 0.08
+    this.controls.minDistance = 120
+    this.controls.maxDistance = 6000
+
+    this.hull = this.buildHull()
+    this.tower.add(this.hull, this.markers)
+    this.scene.add(this.tower)
+    this.buildCaps()
+    this.buildSunSystem()
+    this.buildStars()
+    this.buildLights()
+    this.attach()
+    this.resize()
+  }
+
+  // ------------------------------------------------------------------ geometry
+
+  private buildHull(): Mesh {
+    const g = new CylinderGeometry(R, R, L, 160, 1, true)
+    g.rotateX(Math.PI / 2)
+    g.translate(0, 0, L / 2)
+    const hull = new Mesh(g, new MeshStandardMaterial({
+      color: 0x8892a3, roughness: 0.62, metalness: 0.28, side: DoubleSide,
+    }))
+    // Structural ribs, purely to give the eye a sense of the 540 km.
+    const ribMat = new MeshStandardMaterial({ color: 0xaab3c2, roughness: 0.45, metalness: 0.35 })
+    for (let z = 20; z < L; z += 20) {
+      const rib = new Mesh(new TorusGeometry(R * 1.004, 0.55, 6, 96), ribMat)
+      rib.position.z = z
+      hull.add(rib)
+    }
+    return hull
+  }
+
+  private buildCaps(): void {
+    // The sun cap is an annulus: the beam enters through the hole in the middle.
+    const sun = new Mesh(
+      new RingGeometry(BEAM_R, R, 96),
+      new MeshStandardMaterial({ color: 0xb3bccb, roughness: 0.5, metalness: 0.3, side: DoubleSide }))
+    this.tower.add(sun)
+
+    const aperture = new Mesh(
+      new CircleGeometry(BEAM_R, 48),
+      new MeshBasicMaterial({ color: 0xfff0cf }))
+    aperture.position.z = 0.4
+    this.tower.add(aperture)
+
+    const far = new Mesh(
+      new CircleGeometry(R, 96),
+      new MeshStandardMaterial({ color: 0x6c7583, roughness: 0.72, metalness: 0.25, side: DoubleSide }))
+    far.position.z = L
+    this.tower.add(far)
+  }
+
+  private buildSunSystem(): void {
+    // The collector flies ahead of the sun cap: 244 km across, wider than Tower.
+    const collector = new Mesh(
+      new CircleGeometry(COLLECTOR_R, 128),
+      new MeshStandardMaterial({
+        color: 0xe6edf8, roughness: 0.2, metalness: 0.5, side: DoubleSide,
+        emissive: new Color(0x93a2be), emissiveIntensity: 1.15,
+      }))
+    collector.position.z = -COLLECTOR_STANDOFF
+    this.scene.add(collector)
+
+    /**
+     * The converging beam. In vacuum this would not actually be visible — there is
+     * nothing out here to scatter it — so it is drawn faintly as a reading aid,
+     * the same licence as the hillshade on the map.
+     */
+    const cone = new CylinderGeometry(BEAM_R, COLLECTOR_R, COLLECTOR_STANDOFF, 96, 1, true)
+    cone.rotateX(-Math.PI / 2)
+    cone.translate(0, 0, -COLLECTOR_STANDOFF / 2)
+    this.scene.add(new Mesh(cone, new MeshBasicMaterial({
+      color: 0xffe6b0, transparent: true, opacity: 0.085,
+      blending: AdditiveBlending, depthWrite: false, side: DoubleSide,
+    })))
+  }
+
+  private buildStars(): void {
+    const n = 3500
+    const pos = new Float32Array(n * 3)
+    for (let i = 0; i < n; i++) {
+      const v = new Vector3().randomDirection().multiplyScalar(18_000)
+      pos.set([v.x, v.y, v.z], i * 3)
+    }
+    const g = new BufferGeometry()
+    g.setAttribute('position', new Float32BufferAttribute(pos, 3))
+    this.scene.add(new Points(g, new PointsMaterial({ color: 0xdfe8ff, size: 26, sizeAttenuation: true })))
+
+    const glow = new Mesh(
+      new SphereGeometry(300, 24, 24),
+      new MeshBasicMaterial({ color: 0xffe9bd, transparent: true, opacity: 0.5, side: BackSide }))
+    glow.position.z = -14_000
+    this.scene.add(glow)
+  }
+
+  private buildLights(): void {
+    // The real light runs along the axis, from beyond the collector.
+    const sun = new DirectionalLight(0xfff3dd, 2.2)
+    sun.position.set(0, 0, -8000)
+    this.scene.add(sun)
+
+    /**
+     * Fill light, and a reading aid rather than physics. Sunlight here is parallel
+     * to the axis, so Tower's flank is edge-on to it and correctly renders almost
+     * black — accurate and useless. These two lift the hull enough to read its
+     * shape, the same licence as the hillshade on the map.
+     */
+    const key = new DirectionalLight(0xc8d6f0, 1.5)
+    key.position.set(-1, -1.4, 0.35)
+    this.scene.add(key)
+    const rim = new DirectionalLight(0x8fa8d8, 0.7)
+    rim.position.set(1.2, 0.9, -0.4)
+    this.scene.add(rim)
+    this.scene.add(new AmbientLight(0x3a4356, 1.6))
+  }
+
+  // ------------------------------------------------------------------- markers
+
+  /** Put a dot on the hull for every place, at its canon (theta, z). */
+  setPlaces(places: readonly Place[], selectedId: string | null): void {
+    this.markers.clear()
+    for (const p of places) {
+      const g = p.geometry
+      const at: SurfacePoint = g.type === 'point'
+        ? { theta: g.theta, z: g.z }
+        : { theta: g.points[0]![0], z: g.points[0]![1] }
+      const selected = p.id === selectedId
+      const dot = new Mesh(
+        new SphereGeometry(selected ? 5 : 3, 12, 12),
+        new MeshBasicMaterial({ color: new Color(KIND_COLOR[p.kind] ?? '#ccc') }))
+      dot.position.set(
+        R * 1.01 * Math.cos(at.theta), R * 1.01 * Math.sin(at.theta), at.z / KM)
+      dot.userData['placeId'] = p.id
+      this.markers.add(dot)
+    }
+  }
+
+  // --------------------------------------------------------------- interaction
+
+  private attach(): void {
+    this.canvas.addEventListener('pointerdown', () => { this.moved = false })
+    this.canvas.addEventListener('pointermove', () => { this.moved = true })
+    this.canvas.addEventListener('pointerup', (e) => {
+      if (this.moved) return
+      const r = this.canvas.getBoundingClientRect()
+      const ndc = new Vector2(
+        ((e.clientX - r.left) / r.width) * 2 - 1,
+        -((e.clientY - r.top) / r.height) * 2 + 1)
+      this.raycaster.setFromCamera(ndc, this.camera)
+
+      const onMarker = this.raycaster.intersectObjects(this.markers.children, false)[0]
+      if (onMarker) return this.cb.onPickPlace(onMarker.object.userData['placeId'] as string)
+
+      const onHull = this.raycaster.intersectObject(this.hull, false)[0]
+      if (!onHull) return
+      // Into the hull's own frame, so the spin is undone and theta is canon.
+      const local = this.tower.worldToLocal(onHull.point.clone())
+      this.cb.onPickPoint({
+        theta: normalizeTheta(Math.atan2(local.y, local.x)),
+        z: Math.max(0, Math.min(LENGTH_M, local.z * KM)),
+      })
+    })
+  }
+
+  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()
+  }
+
+  /** Frame a surface point: swing the camera round to look at it from outside. */
+  lookAt(p: SurfacePoint): void {
+    const worldTheta = p.theta + this.phase
+    const z = p.z / KM
+    this.controls.target.set(R * 0.4 * Math.cos(worldTheta), R * 0.4 * Math.sin(worldTheta), z)
+    const d = 260
+    this.camera.position.set(
+      (R + d) * Math.cos(worldTheta), (R + d) * Math.sin(worldTheta), z + 90)
+    this.controls.update()
+  }
+
+  // -------------------------------------------------------------------- loop
+
+  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.spinning) this.phase = (this.phase + OMEGA_RAD_S * dt * this.timeScale) % (2 * Math.PI)
+      this.tower.rotation.z = this.phase
+      this.controls.update()
+      this.renderer.render(this.scene, this.camera)
+      requestAnimationFrame(tick)
+    }
+    requestAnimationFrame(tick)
+  }
+
+  stop(): void {
+    this.running = false
+  }
+}