fuzzyworld

tower / commit

feat(exterior): persist the camera, and make the caps focusable places

15d933c9013acfbcc548315e26a4199e3aa68a9a · Pavel Safronov · 2026-09-06 21:34:53 -0700 · parents 8b6287ba3d22

feat(exterior): persist the camera, and make the caps focusable places

The camera position and target are stored per browser and restored on load,
so a reload returns to the vantage you left. Fields are validated before use
— three finite numbers each, distance inside the controls' limits — because
a bad entry would leave the view pointing at nothing with no way back, and
silently-bad persisted state has cost this project once already. Saves are
debounced; OrbitControls fires 'change' every frame of a drag.

Both end caps are now features in geography.json, so they can be focused
like anywhere else. Two things needed special-casing, for the same reason:
a cap is not a patch of ground. Its marker belongs on the axis, because on
the hull at theta = 0 it sat on the rim and rode round the edge as Tower
turned. And its framing is different: from the side a cap is a line, while
standing off along the axis looks down the barrel and foreshortens all
540 km behind an unlit disc. The default view's oblique angle slid onto the
cap works, with a light from deepward so the far cap's outer face is not a
black hole exactly when you focus on it.

Screen-space labels also drop on collision now — close to a cap all four
projected into one corner and stacked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
 content/geography.json |  24 +++++++++++
 decisions.md           |  34 ++++++++++++++++
 design.md              |   6 +++
 src/views/exterior.ts  | 106 ++++++++++++++++++++++++++++++++++++++++++++++---
 static/world.json      |  22 ++++++++++
 5 files changed, 187 insertions(+), 5 deletions(-)

diff --git a/content/geography.json b/content/geography.json
index 4dcf263..681c2b4 100644
--- a/content/geography.json
+++ b/content/geography.json
@@ -131,6 +131,30 @@
       },
       "notes": "The same at the far cap, and the larger of the two. A 314 km shoreline with a 50 km cliff behind it, and the only windows in the world at its foot (§1.4)."
     },
+    {
+      "id": "sun-cap",
+      "name": "The Sun Cap",
+      "provisionalName": true,
+      "kind": "landmark",
+      "geometry": {
+        "type": "point",
+        "theta": 0.0,
+        "z": 0
+      },
+      "notes": "The sunward end of the world, and one of only two views out (§1.4). A cap is a wall, not a floor: gravity there points radially outward, tangent to its surface, so nothing rests on it. Fifty kilometres of vertical cliff with its windows at the base, and the beam entering through a 3.2 km aperture at its centre."
+    },
+    {
+      "id": "far-cap",
+      "name": "The Far Cap",
+      "provisionalName": true,
+      "kind": "landmark",
+      "geometry": {
+        "type": "point",
+        "theta": 0.0,
+        "z": 540000
+      },
+      "notes": "The deepward end, 540 km away, standing over The Deepward Sea. Its windows look along the spin axis — which is the celestial pole — so the stars do not sweep past but turn about a fixed centre, one revolution every 7 min 29 s. The only moving sky anyone in Tower ever sees."
+    },
     {
       "id": "snowcrown",
       "name": "Snowcrown",
diff --git a/decisions.md b/decisions.md
index 57d3429..f478b73 100644
--- a/decisions.md
+++ b/decisions.md
@@ -892,3 +892,37 @@ restoring it on boot hit the window.
 
 Fixed with a `ResizeObserver` on the canvas rather than another guess about timing,
 which also covers the sidebar and window resizing for free.
+
+---
+
+## D-030 — The camera persists, and the caps are places
+
+**Both at the author's request.**
+
+**Camera.** Position and target are stored per browser and restored on load, so a
+reload returns to the vantage you left rather than the default framing. Every field
+is validated before use — three finite numbers each, and a distance inside the
+controls' own limits — because a corrupt entry here would leave the view pointing at
+nothing with no obvious way back, and silently-bad persisted state has already cost
+this project once (D-025). Saves are debounced: OrbitControls fires `change` every
+frame of a drag.
+
+**Caps.** The sun cap and far cap are now features in `content/geography.json`, so
+they appear in the place list and can be focused like anywhere else. Two things had
+to be special-cased, both for the same underlying reason — *a cap is not a patch of
+ground*:
+
+- **Its marker belongs on the axis.** Placed on the hull at theta = 0 like every
+  other marker, it sat on the cap's rim and rode round the edge as Tower turned.
+  A cap's location is its centre.
+- **Its framing is different.** The usual from-the-side shot shows an end cap edge-on
+  as a line. Standing off along the axis instead is worse — it looks down the barrel
+  and foreshortens all 540 km behind an unlit disc. What works is the default view's
+  oblique angle slid onto the cap, and a light from deepward so the far cap's outer
+  face is not a black hole exactly when you focus on it.
+
+**A note on the debugging.** Verifying camera persistence took several wrong turns
+because the test kept invalidating itself: `location.reload()` fires `pagehide`,
+which saved the live camera over the value the test had just planted. The
+persistence was working the whole time. Worth remembering that a `pagehide` save
+makes "set state, reload, check state" an invalid experiment.
diff --git a/design.md b/design.md
index f969f2b..ad07aee 100644
--- a/design.md
+++ b/design.md
@@ -577,6 +577,12 @@ way round, so the UVs are rewritten from the original attribute rather than deri
 from vertex positions — the seam vertices are duplicated, and deriving an angle from
 them smears one column of triangles across the whole image.
 
+**The camera position persists** across reloads, alongside the active view, so
+returning to the page returns to the vantage you left. Both end caps are places in
+their own right, so the view can frame them: a cap's marker sits on the **axis**,
+because a cap is a disc across the end and a point on its rim would ride round the
+edge as Tower turns.
+
 Markers are scaled per frame to hold a roughly constant size on screen. A 4 km dot
 is legible from 200 km and sub-pixel from the 1,200 km of the default view, which is
 to say the places would simply not be there.
diff --git a/src/views/exterior.ts b/src/views/exterior.ts
index c67ac78..9497aa6 100644
--- a/src/views/exterior.ts
+++ b/src/views/exterior.ts
@@ -30,6 +30,12 @@ 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
+/** How close to an end, in km, counts as being on a cap rather than on the wall. */
+const CAP_TOLERANCE = 8
+const isCapZ = (zKm: number): boolean => zKm < CAP_TOLERANCE || zKm > L - CAP_TOLERANCE
+
+const CAMERA_KEY = 'tower.exteriorCamera'
+const CAMERA_SAVE_MS = 400
 
 export interface ExteriorCallbacks {
   onPickPoint: (p: SurfacePoint) => void
@@ -52,6 +58,7 @@ export class ExteriorView {
   private running = false
   private lastFrame = 0
   private moved = false
+  private cameraSaveTimer: ReturnType<typeof setTimeout> | null = null
 
   /** Spin phase in radians, shared with the rest of the app. */
   phase = 0
@@ -88,6 +95,7 @@ export class ExteriorView {
     this.buildSunSystem()
     this.buildStars()
     this.buildLights()
+    this.restoreCamera()
     this.buildLabels()
     this.attach()
     this.resize()
@@ -297,14 +305,23 @@ export class ExteriorView {
     if (!this.labels.length) return
     const { width, height } = this.canvas.getBoundingClientRect()
     const v = new Vector3()
+    const placed: { x: number; y: number }[] = []
     for (const { at, el } of this.labels) {
       v.copy(at).project(this.camera)
       // z > 1 means the point is behind the camera; project wraps it to the far side.
-      const visible = v.z < 1 && Math.abs(v.x) < 1.6 && Math.abs(v.y) < 1.6
+      let visible = v.z < 1 && Math.abs(v.x) < 1.6 && Math.abs(v.y) < 1.6
+      const x = ((v.x + 1) / 2) * width
+      const y = ((1 - v.y) / 2) * height
+      // Drop captions that would land on one already placed. Close in on a cap,
+      // all four project into the same corner and become an unreadable stack.
+      if (visible && placed.some((p) => Math.abs(p.x - x) < 170 && Math.abs(p.y - y) < 36)) {
+        visible = false
+      }
       el.style.display = visible ? 'block' : 'none'
       if (!visible) continue
-      el.style.left = `${((v.x + 1) / 2) * width}px`
-      el.style.top = `${((1 - v.y) / 2) * height}px`
+      placed.push({ x, y })
+      el.style.left = `${x}px`
+      el.style.top = `${y}px`
     }
   }
 
@@ -326,6 +343,11 @@ export class ExteriorView {
     const rim = new DirectionalLight(0x8fa8d8, 0.7)
     rim.position.set(1.2, 0.9, -0.4)
     this.scene.add(rim)
+    // The far cap's outer face points away from the sun and from both fills, so it
+    // renders as a black hole exactly when you focus on it.
+    const behind = new DirectionalLight(0x9fb2d4, 0.55)
+    behind.position.set(-0.5, -0.8, 1)
+    this.scene.add(behind)
     this.scene.add(new AmbientLight(0x3a4356, 1.6))
   }
 
@@ -353,17 +375,75 @@ export class ExteriorView {
       dot.add(new Mesh(
         new SphereGeometry(r * 1.45, 14, 14),
         new MeshBasicMaterial({ color: 0x05070d, side: BackSide })))
-      dot.position.set(
-        (R + 1.6) * Math.cos(at.theta), (R + 1.6) * Math.sin(at.theta), at.z / KM)
+      const zKm = at.z / KM
+      if (isCapZ(zKm)) {
+        // A cap is a disc across the end, so its place is the axis — not a point on
+        // the rim, which would ride round the edge as Tower turns.
+        dot.position.set(0, 0, zKm < L / 2 ? -5 : L + 5)
+      } else {
+        dot.position.set(
+          (R + 1.6) * Math.cos(at.theta), (R + 1.6) * Math.sin(at.theta), zKm)
+      }
       dot.userData['placeId'] = p.id
       dot.renderOrder = 2
       this.markers.add(dot)
     }
   }
 
+  // ------------------------------------------------------------------- camera
+
+  /**
+   * Restore where the camera was left. Every field is checked before use: a
+   * corrupt or stale entry here would leave the view pointing at nothing with no
+   * obvious way back, and silently-bad persisted state has bitten this app once
+   * already (D-025).
+   */
+  private restoreCamera(): void {
+    try {
+      const raw = localStorage.getItem(CAMERA_KEY)
+      if (!raw) return
+      const s = JSON.parse(raw) as { p?: unknown; t?: unknown }
+      const ok = (v: unknown): v is number[] =>
+        Array.isArray(v) && v.length === 3 && v.every((n) => typeof n === 'number' && Number.isFinite(n))
+      if (!ok(s.p) || !ok(s.t)) return
+      const pos = new Vector3(s.p[0], s.p[1], s.p[2])
+      const target = new Vector3(s.t[0], s.t[1], s.t[2])
+      const d = pos.distanceTo(target)
+      if (d < this.controls.minDistance || d > this.controls.maxDistance) return
+      this.camera.position.copy(pos)
+      this.controls.target.copy(target)
+      this.controls.update()
+    } catch {
+      // Unreadable storage just means the default framing.
+    }
+  }
+
+  private saveCamera(): void {
+    if (this.cameraSaveTimer) clearTimeout(this.cameraSaveTimer)
+    this.cameraSaveTimer = null
+    try {
+      const p = this.camera.position
+      const t = this.controls.target
+      localStorage.setItem(CAMERA_KEY, JSON.stringify({
+        p: [p.x, p.y, p.z].map((n) => Math.round(n * 100) / 100),
+        t: [t.x, t.y, t.z].map((n) => Math.round(n * 100) / 100),
+      }))
+    } catch {
+      // Not persisting the camera is survivable.
+    }
+  }
+
   // --------------------------------------------------------------- interaction
 
   private attach(): void {
+    // OrbitControls fires 'change' per frame while dragging, so debounce rather
+    // than writing to storage on every mouse move.
+    this.controls.addEventListener('change', () => {
+      if (this.cameraSaveTimer) clearTimeout(this.cameraSaveTimer)
+      this.cameraSaveTimer = setTimeout(() => this.saveCamera(), CAMERA_SAVE_MS)
+    })
+    window.addEventListener('pagehide', () => this.saveCamera())
+
     this.canvas.addEventListener('pointerdown', () => { this.moved = false })
     this.canvas.addEventListener('pointermove', () => { this.moved = true })
     this.canvas.addEventListener('pointerup', (e) => {
@@ -401,6 +481,22 @@ export class ExteriorView {
   lookAt(p: SurfacePoint): void {
     const worldTheta = p.theta + this.phase
     const z = p.z / KM
+
+    /**
+     * A cap is a wall across the end, not a patch of ground, so the usual
+     * from-the-side framing shows only an edge. Keep the default view's oblique
+     * angle and slide it onto the cap: standing off along the axis instead looks
+     * down the barrel and foreshortens the whole 540 km behind an unlit disc.
+     */
+    if (isCapZ(z)) {
+      const atSun = z < L / 2
+      const face = atSun ? 0 : L
+      const dir = atSun ? -1 : 1
+      this.controls.target.set(0, 0, face - dir * 40)
+      this.camera.position.set(R * 3, -R * 12, face + dir * 150)
+      this.controls.update()
+      return
+    }
     this.controls.target.set(R * 0.4 * Math.cos(worldTheta), R * 0.4 * Math.sin(worldTheta), z)
     const d = 260
     this.camera.position.set(
diff --git a/static/world.json b/static/world.json
index 9ee8dc0..f5e1438 100644
--- a/static/world.json
+++ b/static/world.json
@@ -23,6 +23,28 @@
       },
       "blurb": ""
     },
+    {
+      "id": "sun-cap",
+      "name": "The Sun Cap",
+      "kind": "landmark",
+      "geometry": {
+        "type": "point",
+        "theta": 0,
+        "z": 0
+      },
+      "blurb": ""
+    },
+    {
+      "id": "far-cap",
+      "name": "The Far Cap",
+      "kind": "landmark",
+      "geometry": {
+        "type": "point",
+        "theta": 0,
+        "z": 540000
+      },
+      "blurb": ""
+    },
     {
       "id": "snowcrown",
       "name": "Snowcrown",