fuzzyworld

tower / commit

fix(exterior): the beam pointed the wrong way; label and persist the view

8b6287ba3d22b85eb0c2d19e685314ef56ce9a5e · Pavel Safronov · 2026-09-06 21:21:33 -0700 · parents 04d6779e9a2b

fix(exterior): the beam pointed the wrong way; label and persist the view

Asked what the brown cone and the white circle were, which is its own
answer: the two most important objects in the view were unlabelled and one
of them was drawn backwards.

CylinderGeometry takes (radiusTop, radiusBottom) and rotateX(-pi/2) sends +Y
to -Z, so the first radius lands at the collector. Written in the order the
eye reads them, the cone came out wide at the sun cap and narrow at the
mirror: light converging on the collector instead of into the aperture, with
a 122 km mouth swallowing a 50 km hull. The flat brown it rendered as hid
it — a uniform additive layer at 0.085 over black sums to RGB(43,39,30),
which reads as a solid object rather than as light.

Brightening it exposed bright dashes across the collector's rim. Bisection:
removing the cone cleared the disc, removing the stars did not, shortening
the cone moved the dashes with its mouth. At the mouth the shell is edge-on,
its triangles project to slivers, and the additive blend stacks them. The
beam is now rays from the mirror to the aperture — no silhouette to alias,
no alpha ramp needed to fake convergence, and it reads as light. Collector,
beam and both caps now carry screen-space labels.

Also in here, because it lands in the same files: the active view persists
across reloads, stored in localStorage and carried in the permalink hash so
a link can say "look at this from outside". That surfaced a boot-order bug —
restoring the exterior un-hides its canvas and builds the renderer in the
same task, before layout, so resize() measured a zero box and left the
default 300x150 buffer stretched across the canvas. Clicking in had always
worked because layout had settled by then. A ResizeObserver fixes it without
another guess about timing.

Recorded as D-028 and D-029.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
 README.md             |  4 ++-
 decisions.md          | 49 ++++++++++++++++++++++++++
 design.md             |  8 +++--
 index.html            |  1 +
 src/style.css         |  8 +++++
 src/ui/app.ts         | 27 ++++++++++++++-
 src/views/exterior.ts | 96 +++++++++++++++++++++++++++++++++++++++++++++------
 7 files changed, 178 insertions(+), 15 deletions(-)

diff --git a/README.md b/README.md
index 959b256..30e87c6 100644
--- a/README.md
+++ b/README.md
@@ -90,7 +90,9 @@ Any selection updates the URL, so a link is always shareable:
 
 **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, with the terrain wrapped onto the hull as an x-ray of what is inside. Drag to orbit, scroll to zoom, click the hull to drop a point; the spin runs at
+cap, with the terrain wrapped onto the hull as an x-ray of what is inside. Each
+piece is captioned. The active view is remembered and travels in the permalink, so
+`#view=exterior&place=the-narrows` opens where you left off. 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.
 
diff --git a/decisions.md b/decisions.md
index e0126d5..57d3429 100644
--- a/decisions.md
+++ b/decisions.md
@@ -843,3 +843,52 @@ the editor reports it as *rings the world, z 148.0 km to 218.0 km (70.0 km deep)
 
 Tests assert the four ringing regions survive as bands through parse and public
 export, and that a band with unusable bounds is rejected like any other geometry.
+
+---
+
+## D-028 — The beam was inverted, and cones fringe
+
+**Found because the author asked what the brown cone and the white circle were.**
+That question is its own answer: the two most important objects in the exterior view
+were unlabelled and one of them was drawn backwards.
+
+**The beam pointed the wrong way.** `CylinderGeometry(radiusTop, radiusBottom)`
+combined with `rotateX(-pi/2)` — which sends +Y to −Z — put the *first* radius at
+the collector. Writing the pair in the order the eye reads them gave a cone wide at
+the sun cap and narrow at the mirror: light converging **on** the collector instead
+of into the aperture, with a 122 km mouth swallowing a 50 km hull. It had been that
+way since the view was built, and the flat brown wash it rendered as hid it.
+
+**The brown was a second bug.** A uniform additive layer at 0.085 opacity over a
+black sky sums to about RGB(43,39,30) — a dull brown that reads as a solid object,
+which is the opposite of a beam.
+
+**Cones fringe, so the beam is rays now.** Brightening it exposed bright dashes
+scattered across the collector's rim. Isolated by bisection: removing the cone
+cleared the disc, removing the stars did not, and shortening the cone moved the
+dashes with its mouth. At the mouth the cone's shell is edge-on, so its triangles
+project to slivers and the additive blend stacks them. Rays from the mirror to the
+aperture have no silhouette to alias, need no alpha ramp to fake convergence, and
+read more like light than a translucent solid ever did.
+
+**Everything is captioned now.** Collector, beam, sun cap, far cap, as screen-space
+labels. If the person who commissioned the world cannot identify an object in it, a
+reader has no chance.
+
+---
+
+## D-029 — The view persists, which surfaced a boot-order bug
+
+**Implemented at the author's request.** Map or exterior is remembered in
+`localStorage` and carried in the permalink hash, so a shared link can say *look at
+this from outside*. The hash wins; storage is the fallback for a bare URL.
+
+**And it immediately broke the exterior on load.** Restoring that view un-hides the
+canvas and constructs the renderer in the same task, before the browser has laid the
+element out — so `resize()` measured a zero-sized box, bailed out, and the renderer
+kept its default 300×150 buffer stretched across a full-size canvas. Clicking into
+the view had always worked because layout had long since settled by then; only
+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.
diff --git a/design.md b/design.md
index 1d26203..f969f2b 100644
--- a/design.md
+++ b/design.md
@@ -559,8 +559,12 @@ map's hillshade:
   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.
+  scatter light, so the converging beam would not be visible at all. It is drawn
+  as rays because it is the thing the view is about.
+
+Everything in the scene is captioned — collector, beam, both caps — with screen-space
+labels. A pale disc and a cone of light are not self-explanatory, and the collector
+is the least intuitive object in Tower: it is larger than the world it feeds.
 
 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.
diff --git a/index.html b/index.html
index eeffe22..d974971 100644
--- a/index.html
+++ b/index.html
@@ -15,6 +15,7 @@
       <button data-view="map" class="active">Map</button>
       <button data-view="exterior">Exterior</button>
     </div>
+    <div id="ext-labels" class="labels3d hidden"></div>
     <div id="spin" class="overlay hidden"></div>
     <div id="cursor" class="overlay hidden"></div>
     <div id="sightline" class="overlay hidden"></div>
diff --git a/src/style.css b/src/style.css
index 5de6b4e..cac81d3 100644
--- a/src/style.css
+++ b/src/style.css
@@ -26,6 +26,14 @@ canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block
 }
 .views button.active { background: #1d2942; color: var(--accent); }
 #spin { left: auto; right: 12px; bottom: auto; top: 12px; }
+.labels3d { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
+.labels3d span {
+  position: absolute; transform: translate(-50%, -50%); white-space: nowrap;
+  font: 11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
+  color: rgba(226,236,250,0.9); text-shadow: 0 0 4px #05070d, 0 0 8px #05070d;
+  letter-spacing: 0.04em;
+}
+.labels3d span small { display: block; color: var(--dim); letter-spacing: 0; }
 
 .overlay {
   position: absolute; bottom: 12px; left: 12px;
diff --git a/src/ui/app.ts b/src/ui/app.ts
index 9a1bd39..f9bb10e 100644
--- a/src/ui/app.ts
+++ b/src/ui/app.ts
@@ -20,6 +20,8 @@ import { SPIN_PERIOD_S } from '../canon/constants.js'
 
 type Tool = 'select' | PlaceKind
 type ViewName = 'map' | 'exterior'
+const VIEW_KEY = 'tower.view'
+const isView = (v: string | null): v is ViewName => v === 'map' || v === 'exterior'
 
 /** Time multipliers for the exterior spin, slowest first. */
 const SPEEDS = [1, 10, 60, 300, 1200]
@@ -91,10 +93,26 @@ export class App {
     this.map.fit()
     this.readHash()
     this.render()
+    void this.setView(this.initialView())
   }
 
   // ----------------------------------------------------------------- views
 
+  /**
+   * Which view to open on. The hash wins so a shared link can carry the view along
+   * with the place; otherwise the last one used, so a reload does not throw the
+   * author back to the map every time.
+   */
+  private initialView(): ViewName {
+    const fromHash = new URLSearchParams(location.hash.slice(1)).get('view')
+    if (isView(fromHash)) return fromHash
+    try {
+      const stored = localStorage.getItem(VIEW_KEY)
+      if (isView(stored)) return stored
+    } catch { /* unreadable storage is not an error here */ }
+    return 'map'
+  }
+
   private buildViews(): void {
     for (const b of document.querySelectorAll<HTMLButtonElement>('#views button')) {
       b.onclick = () => { void this.setView(b.dataset['view'] as ViewName) }
@@ -122,12 +140,18 @@ export class App {
    */
   private async setView(view: ViewName): Promise<void> {
     this.view = view
+    try {
+      localStorage.setItem(VIEW_KEY, view)
+    } catch {
+      // 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)
     $('map-options').classList.toggle('hidden', !isMap)
     $('exterior-options').classList.toggle('hidden', isMap)
     $('spin').classList.toggle('hidden', isMap)
+    $('ext-labels').classList.toggle('hidden', isMap)
     for (const b of document.querySelectorAll<HTMLButtonElement>('#views button')) {
       b.classList.toggle('active', b.dataset['view'] === view)
     }
@@ -151,7 +175,7 @@ export class App {
           this.selectedId = id
           this.render()
         },
-      })
+      }, $('ext-labels'))
       const spin = $<HTMLInputElement>('f-spin')
       const speed = $<HTMLInputElement>('f-speed')
       this.exterior.spinning = spin.checked
@@ -184,6 +208,7 @@ export class App {
    */
   private writeHash(): void {
     const params = new URLSearchParams()
+    if (this.view !== 'map') params.set('view', this.view)
     if (this.selectedId) params.set('place', this.selectedId)
     else if (this.map.probe?.from) {
       const p = this.map.probe.from
diff --git a/src/views/exterior.ts b/src/views/exterior.ts
index e9a7c2e..c67ac78 100644
--- a/src/views/exterior.ts
+++ b/src/views/exterior.ts
@@ -10,7 +10,8 @@ import type { Texture } from 'three'
 import {
   AdditiveBlending, AmbientLight, BackSide, BufferGeometry, CircleGeometry, Color,
   CylinderGeometry, DirectionalLight, DoubleSide, Float32BufferAttribute, Group, Mesh,
-  MeshBasicMaterial, MeshStandardMaterial, PerspectiveCamera, Points, PointsMaterial,
+  LineBasicMaterial, LineSegments, MeshBasicMaterial, MeshStandardMaterial,
+  PerspectiveCamera, Points, PointsMaterial,
   Raycaster, RepeatWrapping, RingGeometry, SRGBColorSpace, Scene, SphereGeometry,
   TextureLoader, Vector2, Vector3, WebGLRenderer,
 } from 'three'
@@ -58,7 +59,14 @@ export class ExteriorView {
   timeScale = 60
   spinning = true
 
-  constructor(private canvas: HTMLCanvasElement, private cb: ExteriorCallbacks) {
+  /** Screen-space captions, so nothing in the scene is left unexplained. */
+  private labels: { at: Vector3; el: HTMLSpanElement }[] = []
+
+  constructor(
+    private canvas: HTMLCanvasElement,
+    private cb: ExteriorCallbacks,
+    private labelHost?: HTMLElement,
+  ) {
     this.renderer = new WebGLRenderer({ canvas, antialias: true })
     this.renderer.setClearColor(0x05070d)
     this.camera = new PerspectiveCamera(42, 1, 8, 30_000)
@@ -80,8 +88,18 @@ export class ExteriorView {
     this.buildSunSystem()
     this.buildStars()
     this.buildLights()
+    this.buildLabels()
     this.attach()
     this.resize()
+    /**
+     * Watch the canvas box rather than only the window. Restoring this view on load
+     * un-hides the canvas and constructs the renderer in the same task, before the
+     * browser has laid the element out — so `resize()` sees a zero-sized box, bails,
+     * and the renderer keeps its default 300x150 buffer stretched across the canvas.
+     * Clicking the view later worked because layout had long since settled, which is
+     * why this only appeared once the view began persisting across reloads.
+     */
+    new ResizeObserver(() => this.resize()).observe(canvas)
   }
 
   // ------------------------------------------------------------------ geometry
@@ -204,16 +222,38 @@ export class ExteriorView {
     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.
+     * The converging beam, drawn as rays from the mirror's rim to the aperture.
+     *
+     * A translucent cone is the obvious construction and it fringes: at the mouth
+     * the shell is edge-on, so its triangles project to slivers and the additive
+     * blend stacks them into bright dashes across the collector. Isolated by
+     * bisection — removing the cone cleared the disc, removing the stars did not,
+     * and shortening the cone moved the dashes with its mouth. Rays have no
+     * silhouette to alias, and read more like light besides.
+     *
+     * In vacuum none of this would be visible; there is nothing out here to
+     * scatter it. 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,
+    const RAYS = 220
+    const verts = new Float32Array(RAYS * 6)
+    const cols = new Float32Array(RAYS * 8)
+    for (let i = 0; i < RAYS; i++) {
+      const a = (i / RAYS) * Math.PI * 2
+      // Spread the starts across the mirror rather than only round its rim, so the
+      // beam has a body instead of being a hollow funnel.
+      const r = COLLECTOR_R * Math.sqrt(0.15 + 0.85 * ((i * 0.6180339887) % 1))
+      verts.set([
+        r * Math.cos(a), r * Math.sin(a), -COLLECTOR_STANDOFF,
+        BEAM_R * Math.cos(a), BEAM_R * Math.sin(a), 0,
+      ], i * 6)
+      cols.set([1, 0.95, 0.8, 0.0, 1, 0.97, 0.86, 0.38], i * 8)
+    }
+    const beam = new BufferGeometry()
+    beam.setAttribute('position', new Float32BufferAttribute(verts, 3))
+    beam.setAttribute('color', new Float32BufferAttribute(cols, 4))
+    this.scene.add(new LineSegments(beam, new LineBasicMaterial({
+      vertexColors: true, transparent: true,
+      blending: AdditiveBlending, depthWrite: false,
     })))
   }
 
@@ -235,6 +275,39 @@ export class ExteriorView {
     this.scene.add(glow)
   }
 
+  /**
+   * A pale disc and a glowing cone are not self-explanatory, and the collector is
+   * the least intuitive object in Tower: it is bigger than the world it feeds.
+   */
+  private buildLabels(): void {
+    if (!this.labelHost) return
+    const add = (at: Vector3, title: string, sub: string) => {
+      const el = document.createElement('span')
+      el.innerHTML = `${title}<small>${sub}</small>`
+      this.labelHost!.append(el)
+      this.labels.push({ at, el })
+    }
+    add(new Vector3(0, COLLECTOR_R * 0.82, -COLLECTOR_STANDOFF), 'COLLECTOR', '244 km across')
+    add(new Vector3(0, 96, -COLLECTOR_STANDOFF * 0.5), 'BEAM', '54.3 TW into a 3.2 km hole')
+    add(new Vector3(0, R + 26, 0), 'SUN CAP', 'faces the sun')
+    add(new Vector3(0, R + 26, L), 'FAR CAP', '540 km away')
+  }
+
+  private updateLabels(): void {
+    if (!this.labels.length) return
+    const { width, height } = this.canvas.getBoundingClientRect()
+    const v = new Vector3()
+    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
+      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`
+    }
+  }
+
   private buildLights(): void {
     // The real light runs along the axis, from beyond the collector.
     const sun = new DirectionalLight(0xfff3dd, 2.2)
@@ -356,6 +429,7 @@ export class ExteriorView {
       for (const m of this.markers.children) m.scale.setScalar(scale)
       this.controls.update()
       this.renderer.render(this.scene, this.camera)
+      this.updateLabels()
       requestAnimationFrame(tick)
     }
     requestAnimationFrame(tick)