fuzzyworld

tower / commit

feat(exterior): the hull reads two ways, and they are different claims

a3f01bd7bbc635a9950344e45d2856c1f76e81de · Pavel Safronov · 2026-09-07 21:59:47 -0700 · parents 769726ead3fb

feat(exterior): the hull reads two ways, and they are different claims

"Show terrain" was a checkbox doing something stronger than a checkbox
admits. From outside, Tower is structure — so terrain on the outside is not
what Tower looks like. It is a diagram of what is inside, drawn on the
outside because the wall is opaque. Useful, and not a view. Two different
claims about the same object deserve a control that names both, so it is now
a Hull switch: True, or X-ray.

The wall is drawn as two meshes rather than one double-sided one. Its faces
now show different things — the outer skin changes with the mode while the
inner surface is always the land — and a single DoubleSide material has no
way to say that. The inner mesh stays emissive for the reason the x-ray
always was: it is lit by a beam this scene does not model, and unlit it
reads as a hole rather than as a world.

This is what made the far cap window worth having from outside. D-045 glazed
it for the interior, where the payoff is the pinwheel. Out here the payoff is
better: the panes are clear, so in True mode you look in through the glass
and the world is really there — mountains, desert and lakes receding 540 km
down the tube behind a grid of mullions. It is the only line of sight into
Tower that is not a projection, and it exists only because the wall got a
window and the hull got two faces in the same week.

The dark backing disc D-045 put behind the glazing is gone. It was there to
stop the panes showing the inside of the tube, which turns out to be exactly
the thing worth showing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
 README.md             | 14 +++++++++----
 decisions.md          | 30 +++++++++++++++++++++++++++
 index.html            |  9 ++++++++
 src/ui/app.ts         | 16 ++++++++++-----
 src/views/exterior.ts | 57 ++++++++++++++++++++++++++++++++++++++-------------
 5 files changed, 103 insertions(+), 23 deletions(-)

diff --git a/README.md b/README.md
index 88c43dd..5700a7c 100644
--- a/README.md
+++ b/README.md
@@ -157,10 +157,16 @@ Any selection updates the URL, so a link is always shareable:
 
 ## Views
 
-**Map** is the unrolled inner surface. **Exterior** is Tower from outside — hull,
-checkered 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. Each
-piece is captioned. The active view is remembered and travels in the permalink, so
+**Map** is the unrolled inner surface. **Exterior** is Tower from outside — a
+kilometre of hull, the checkered sun cap, the glazed far cap, the 244 km collector
+flying ahead and the beam converging into the sun cap. Each piece is captioned.
+
+The hull reads two ways and the **Hull** switch picks one. **True** is the outer
+skin: bare structure, and the far cap a real window — look in through it and the
+land is actually there, lit and receding 540 km down the tube. It is the only line
+of sight into Tower that is not a diagram. **X-ray** wraps the terrain onto the
+outside instead, which is a diagram of what is inside rather than a view of it, and
+useful precisely because the wall is opaque. 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 a842028..00c25e2 100644
--- a/decisions.md
+++ b/decisions.md
@@ -1393,3 +1393,33 @@ having.
 
 The beam's brightness scales with the exposure already, so it banks and goes out on
 its own; neither setting needed the beam shader touched.
+
+## D-047 — The exterior hull reads two ways, and they are different claims
+
+The exterior had a "Show terrain" checkbox that wrapped the raster onto the hull.
+It was doing something stronger than a checkbox admits: from outside Tower is
+structure, so terrain on the outside is not what Tower looks like — it is a diagram
+of what is inside, drawn on the outside because the wall is opaque. Useful, and not
+a view. The two readings are different claims about the same object, so the control
+is now a two-way switch that names both:
+
+- **True** — the outer skin. Bare hull, checkered sun cap, glazed far cap.
+- **X-ray** — the inner surface projected onto the outside.
+
+**The wall is drawn as two meshes now, not one double-sided one.** Its faces show
+different things: the outer skin changes with the mode and the inner surface is
+always the land, and a single `DoubleSide` material has no way to say so. The inner
+mesh is emissive for the same reason the x-ray always was — it is lit by a beam this
+scene does not model, and unlit it reads as a hole rather than a world.
+
+**This is what made the far cap window worth having from outside.** D-045 glazed it
+for the sake of the interior, where the payoff is the pinwheel. From outside the
+payoff is different and better: the panes are clear, so in true mode you look in
+through the glass and the world is really there, mountains and desert and lakes
+receding 540 km down the tube behind a grid of mullions. It is the only line of
+sight into Tower that is not a projection, and it exists only because the wall got a
+window and the hull got two faces in the same week.
+
+The dark backing disc the first version of D-045 put behind the glazing is gone. It
+was there to stop the panes showing the inside of the tube — which turns out to be
+exactly the thing worth showing.
diff --git a/index.html b/index.html
index ab55145..1530efe 100644
--- a/index.html
+++ b/index.html
@@ -43,6 +43,15 @@
           outside.</p>
       </div>
       <div id="exterior-options" class="hidden">
+        <label>Hull
+          <select id="f-ext-view">
+            <option value="true">True — what Tower looks like</option>
+            <option value="xray" selected>X-ray — the inner surface projected out</option>
+          </select></label>
+        <p class="hint">True shows the outer skin: bare hull, checkered sun cap, and
+          the far cap window — look in through it and the land is really there,
+          540 km deep. X-ray wraps the terrain onto the outside instead, which is a
+          diagram of what is inside rather than a view of it.</p>
         <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>
diff --git a/src/ui/app.ts b/src/ui/app.ts
index fe47138..812a1be 100644
--- a/src/ui/app.ts
+++ b/src/ui/app.ts
@@ -221,9 +221,12 @@ export class App {
     $('map-options').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)
+    /**
+     * "Show terrain" is the map's control. The interior has nothing to turn off —
+     * the raster is the ground itself — and the exterior now has its own two-mode
+     * switch instead of a checkbox (D-047), so the row belongs to the map alone.
+     */
+    $('terrain-row').classList.toggle('hidden', !isMap)
     $('spin').classList.toggle('hidden', !isExterior)
     $('ext-labels').classList.toggle('hidden', !isExterior)
     $('standing').classList.toggle('hidden', !isInterior)
@@ -258,7 +261,7 @@ export class App {
       const speed = $<HTMLInputElement>('f-speed')
       this.exterior.spinning = spin.checked
       this.exterior.timeScale = SPEEDS[Number(speed.value)]!
-      this.exterior.showTerrain = $<HTMLInputElement>('f-terrain').checked
+      this.exterior.showTerrain = $<HTMLSelectElement>('f-ext-view').value === 'xray'
     }
     this.exterior.resize()
     this.exterior.setPlaces(this.store.places, this.selectedId)
@@ -533,7 +536,10 @@ export class App {
     box.onchange = () => {
       this.map.showTerrain = box.checked
       this.map.draw()
-      if (this.exterior) this.exterior.showTerrain = box.checked
+    }
+    const ext = $<HTMLSelectElement>('f-ext-view')
+    ext.onchange = () => {
+      if (this.exterior) this.exterior.showTerrain = ext.value === 'xray'
     }
   }
 
diff --git a/src/views/exterior.ts b/src/views/exterior.ts
index 28485ab..89bd686 100644
--- a/src/views/exterior.ts
+++ b/src/views/exterior.ts
@@ -9,6 +9,7 @@
 import type { Texture } from 'three'
 import {
   AdditiveBlending, AmbientLight, BackSide, BufferGeometry, CircleGeometry, Color,
+  FrontSide,
   CylinderGeometry, DirectionalLight, DoubleSide, Float32BufferAttribute, Group, Mesh,
   LineBasicMaterial, LineSegments, MeshBasicMaterial, MeshStandardMaterial,
   PerspectiveCamera, Points, PointsMaterial,
@@ -55,6 +56,7 @@ export class ExteriorView {
   private markers = new Group()
   private hull: Mesh
   private hullMat: MeshStandardMaterial
+  private innerMat!: MeshStandardMaterial
   private terrainMap: Texture | null = null
   private _showTerrain = true
   private raycaster = new Raycaster()
@@ -79,7 +81,9 @@ export class ExteriorView {
   ) {
     this.renderer = new WebGLRenderer({ canvas, antialias: true })
     this.renderer.setClearColor(0x05070d)
-    this.camera = new PerspectiveCamera(42, 1, 8, 30_000)
+    // Near plane at 2 km rather than 8: the wall is only a kilometre thick (D-044),
+    // so anywhere close enough to read it as depth is inside the old near plane.
+    this.camera = new PerspectiveCamera(42, 1, 2, 30_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)
@@ -87,7 +91,10 @@ export class ExteriorView {
     this.controls.target.set(0, 0, (L - COLLECTOR_STANDOFF) / 2)
     this.controls.enableDamping = true
     this.controls.dampingFactor = 0.08
-    this.controls.minDistance = 120
+    // 60 km, down from 120. A kilometre of wall is 1% of the cap's diameter, so it
+    // is a couple of pixels from anywhere the old limit allowed — the depth was
+    // being drawn and could not be got close enough to to see.
+    this.controls.minDistance = 60
     this.controls.maxDistance = 6000
 
     this.hullMat = new MeshStandardMaterial()
@@ -116,6 +123,16 @@ export class ExteriorView {
 
   // ------------------------------------------------------------------ geometry
 
+  /**
+   * Two meshes, not one, because the two sides of Tower's wall show different
+   * things and a single DoubleSide material cannot say so.
+   *
+   * The outer skin is what Tower looks like: bare metal, or — in x-ray mode — the
+   * inner surface projected onto it, which is a diagram and not a view (D-047).
+   * The inner surface is the land itself, and since the far cap became a window
+   * (D-045) it is genuinely visible from outside: look in through the glass and
+   * the far wall of the world is right there, lit and 540 km deep.
+   */
   private buildHull(): Mesh {
     const g = new CylinderGeometry(R, R, L, 160, 1, true)
     g.rotateX(Math.PI / 2)
@@ -123,8 +140,15 @@ export class ExteriorView {
     this.remapHullUVs(g)
 
     this.hullMat = new MeshStandardMaterial({
-      color: 0x8892a3, roughness: 0.62, metalness: 0.28, side: DoubleSide,
+      color: 0x8892a3, roughness: 0.62, metalness: 0.28, side: FrontSide,
     })
+    // The land, seen from inside, through the window. Always the terrain — there
+    // is no mode in which the inside of Tower is bare metal.
+    this.innerMat = new MeshStandardMaterial({
+      color: 0x2a2f38, roughness: 0.95, metalness: 0.0, side: BackSide,
+    })
+    this.tower.add(new Mesh(g.clone(), this.innerMat))
+
     const hull = new Mesh(g, this.hullMat)
     this.loadTerrain()
     return hull
@@ -195,6 +219,18 @@ export class ExteriorView {
     this.hullMat.metalness = on ? 0.06 : 0.28
     this.hullMat.roughness = on ? 0.9 : 0.62
     this.hullMat.needsUpdate = true
+
+    // The inside carries the land in both modes, and always emissive: it is lit by
+    // a beam this scene does not model, and unlit it would read as a black hole
+    // behind the window rather than as a world.
+    if (this.terrainMap) {
+      this.innerMat.map = this.terrainMap
+      this.innerMat.emissiveMap = this.terrainMap
+      this.innerMat.emissive.set(0xffffff)
+      this.innerMat.emissiveIntensity = 0.5
+      this.innerMat.color.set(0xffffff)
+      this.innerMat.needsUpdate = true
+    }
   }
 
   set showTerrain(v: boolean) {
@@ -244,18 +280,11 @@ export class ExteriorView {
     this.tower.add(aperture)
 
     /**
-     * The far cap is a window (D-045). From outside it is dark glass in a light
-     * frame: the panes look into a hazed interior that is not lit from this side,
-     * so the honest reading is nearly black, with the mullions catching the only
-     * light there is. The backing disc stops the panes showing the inside of the
-     * tube, which would be a view through Tower rather than into it.
+     * The far cap is a window (D-045), and from outside it is a window in the
+     * ordinary sense: the mullions are structure and the panes are clear, so what
+     * is behind them is the inside of Tower. That is the whole of the "true" view's
+     * payoff — the only line of sight into the world that is not a diagram.
      */
-    const glass = new Mesh(
-      new CircleGeometry(R, 96),
-      new MeshStandardMaterial({ color: 0x0a0f18, roughness: 0.18, metalness: 0.5 }))
-    glass.position.z = L + HULL_D
-    this.tower.add(glass)
-
     const glazing = capGlazingTexture()
     const far = new Mesh(
       new CircleGeometry(R, 96),