fuzzyworld

tower / commit

feat(exterior): wrap the terrain onto the hull

11a13dbf3b7ec4d03cdb085f60a98b4f902b7625 · Pavel Safronov · 2026-09-06 20:58:08 -0700 · parents aa55ae67fc2b

feat(exterior): wrap the terrain onto the hull

The exterior showed coloured dots on a bare shell, which said where things
were without saying what they were. The rendered inner surface is now
painted onto the hull: an x-ray, since from outside Tower is structure and
what the texture shows is what is inside. It is the frankest of the view's
three reading aids, and it earns its place because the hull is opaque —
this is the only view that shows the whole world at once.

The UVs are rewritten from the original attribute rather than derived from
vertex positions. Three's cylinder runs u around and v along the axis while
the raster is x = z and y = theta, and recovering the angle with atan2 is
wrong: the seam vertices are duplicated, so both get the same angle and one
column of triangles smears across the entire image.

Alignment was checked by sampling the raster at each place's computed
position — four of four mountains on rock, both cap seas on water, the
desert on desert. By eye this is unjudgeable; the map is bands of green and
tan and a half-world error looks much like a correct one.

Three things the terrain forced. Markers gained a dark outline, because
small bright dots read against a bare hull and vanish into forest. They are
scaled per frame to a constant screen size, because 4 km is sub-pixel at the
1,200 km the default view sits at. And the hull needed an emissive map:
light runs along the axis, so most of it faces away from every lamp in the
scene.

Selecting a place from the list also moves the camera now. Only permalinks
called focusOn before, so clicking a row updated the panel and left the view
where it was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
 README.md             |   2 +-
 decisions.md          |  39 +++++++++++++++-
 design.md             |  12 +++++
 index.html            |   6 ++-
 src/ui/app.ts         |   3 ++
 src/views/exterior.ts | 121 +++++++++++++++++++++++++++++++++++++++++++++-----
 6 files changed, 169 insertions(+), 14 deletions(-)

diff --git a/README.md b/README.md
index 3c46370..959b256 100644
--- a/README.md
+++ b/README.md
@@ -90,7 +90,7 @@ 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. 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. 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 48c61ac..47ba90d 100644
--- a/decisions.md
+++ b/decisions.md
@@ -719,7 +719,7 @@ spin phases 34° apart and confirming the reported θ moved by 0.588 rad against
 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
+**Three departures from physics, all 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
@@ -735,3 +735,40 @@ 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.
+
+---
+
+## D-024 — The terrain is wrapped onto the hull as an x-ray
+
+**Implemented at the author's request.** The exterior view paints the rendered inner
+surface onto the outside of the cylinder, alongside the place markers.
+
+**It is a projection, and the code says so.** From outside, Tower is structure; the
+texture shows what is *inside*. That makes it the frankest of the view's three
+reading aids — nobody could mistake a forest on the outer hull for a physical claim,
+where the fill lighting and the visible beam are subtler about it. It earns its
+place because the hull is opaque: this is the only view that shows the whole world
+at once.
+
+**The UVs are rewritten, not derived.** Three's cylinder runs u around the
+circumference and v along the axis; the raster is the other way round, x = z and
+y = theta. The obvious approach — recovering the angle from each vertex position —
+is wrong, because the seam vertices are duplicated and `atan2` gives both the same
+angle, smearing one column of triangles across the entire image. Remapping the
+original uv attribute stays linear and lets RepeatWrapping absorb the offset.
+
+**Alignment was verified, not eyeballed.** The raster was sampled at each place's
+computed position and the colour classified: four of four mountains landed on rock,
+both cap seas on water, the desert on desert. Judging this by eye would have been
+hopeless — the whole map is bands of green and tan, and a half-world error looks
+much like a correct one from most angles.
+
+**Two fixes the terrain forced.** Small bright dots read fine against a bare hull
+and vanish into forest and desert, so markers gained a dark outline shell; and they
+are now scaled per frame to a roughly constant screen size, because 4 km is
+sub-pixel at the 1,200 km the default view sits at. The terrain also needed an
+emissive map: sunlight runs along the axis, so most of the hull faces away from
+every light and the map would otherwise be legible only on a sliver.
+
+**Also fixed here:** selecting a place from the list moved the panel but not the
+camera — only permalinks called `focusOn`. Both paths now do.
diff --git a/design.md b/design.md
index 0b09105..1d26203 100644
--- a/design.md
+++ b/design.md
@@ -565,6 +565,18 @@ map's hillshade:
 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.
 
+**The terrain raster is wrapped onto the hull** — a third reading aid, and the
+frankest of the three. From outside Tower is structure; what the texture shows is
+what is *inside*. It is an x-ray, and it is useful precisely because the hull is
+opaque. Three's cylinder runs u around and v along, while the raster is the other
+way round, so the UVs are rewritten from the original attribute rather than derived
+from vertex positions — the seam vertices are duplicated, and deriving an angle from
+them smears one column of triangles across the whole image.
+
+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.
+
 ### 2.4 The interior view
 
 Per the chosen scope: **real geometry, simple ground.**
diff --git a/index.html b/index.html
index 30b4deb..0e270bc 100644
--- a/index.html
+++ b/index.html
@@ -76,10 +76,12 @@
 
     <section id="view-section">
       <h2>View</h2>
+      <label class="row"><input id="f-terrain" type="checkbox" checked /> Show terrain</label>
       <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>
+          <code>content/geography.json</code>. On the exterior it is wrapped onto the
+          hull — an x-ray of the inner surface, not what Tower looks like from
+          outside.</p>
       </div>
       <div id="exterior-options" class="hidden">
         <label class="row"><input id="f-spin" type="checkbox" checked /> Spin</label>
diff --git a/src/ui/app.ts b/src/ui/app.ts
index 25bcac5..941bdb3 100644
--- a/src/ui/app.ts
+++ b/src/ui/app.ts
@@ -156,6 +156,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.resize()
     this.exterior.setPlaces(this.store.places, this.selectedId)
@@ -381,6 +382,7 @@ export class App {
     box.onchange = () => {
       this.map.showTerrain = box.checked
       this.map.draw()
+      if (this.exterior) this.exterior.showTerrain = box.checked
     }
   }
 
@@ -536,6 +538,7 @@ export class App {
       li.className = (p.id === this.selectedId ? 'sel ' : '') + (p.public ? '' : 'private')
       li.onclick = () => {
         this.selectedId = p.id
+        this.focusOn(p.id)
         this.render()
       }
       const sw = document.createElement('span')
diff --git a/src/views/exterior.ts b/src/views/exterior.ts
index ba3e797..6b63a1c 100644
--- a/src/views/exterior.ts
+++ b/src/views/exterior.ts
@@ -6,18 +6,19 @@
  * 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 type { Texture } from 'three'
 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,
+  Raycaster, RepeatWrapping, RingGeometry, SRGBColorSpace, Scene, SphereGeometry,
+  TextureLoader, 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 SurfacePoint, mapThetaOrigin, normalizeTheta } from '../canon/geometry.js'
 import type { Place } from '../world/schema.js'
 import { KIND_COLOR } from './palette.js'
 
@@ -43,6 +44,10 @@ export class ExteriorView {
   private tower = new Group()
   private markers = new Group()
   private hull: Mesh
+  private hullMat: MeshStandardMaterial
+  private ribs: Mesh[] = []
+  private terrainMap: Texture | null = null
+  private _showTerrain = true
   private raycaster = new Raycaster()
   private running = false
   private lastFrame = 0
@@ -57,7 +62,7 @@ export class ExteriorView {
   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)
+    this.camera = new PerspectiveCamera(42, 1, 8, 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)
@@ -68,6 +73,7 @@ export class ExteriorView {
     this.controls.minDistance = 120
     this.controls.maxDistance = 6000
 
+    this.hullMat = new MeshStandardMaterial()
     this.hull = this.buildHull()
     this.tower.add(this.hull, this.markers)
     this.scene.add(this.tower)
@@ -85,19 +91,99 @@ export class ExteriorView {
     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({
+    this.remapHullUVs(g)
+
+    this.hullMat = 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 hull = new Mesh(g, this.hullMat)
+
+    // Structural ribs, to give the eye a sense of the 540 km. They read as
+    // gridlines once the terrain is on, which is useful rather than not.
     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)
+      const rib = new Mesh(new TorusGeometry(R * 1.004, 0.4, 6, 96), ribMat)
       rib.position.z = z
       hull.add(rib)
+      this.ribs.push(rib)
     }
+    this.loadTerrain()
     return hull
   }
 
+  /**
+   * Rewrite the cylinder's UVs so the terrain raster lands where the map says it
+   * should. Three's cylinder runs u around the circumference and v along the axis;
+   * the raster is the other way round, with x = z and y = theta.
+   *
+   * Deriving the angle from vertex positions would be simpler and wrong: the seam
+   * vertices are duplicated, so atan2 gives both the same angle and smears one
+   * column of triangles across the whole image. Working from the original u keeps
+   * it linear, and RepeatWrapping absorbs the offset.
+   *
+   * 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, and the map is
+   * cut at mapThetaOrigin — hence the constant.
+   */
+  private remapHullUVs(g: CylinderGeometry): void {
+    const uv = g.attributes['uv']!
+    const shift = 0.25 + mapThetaOrigin() / (2 * Math.PI)
+    for (let i = 0; i < uv.count; i++) {
+      const around = uv.getX(i)
+      const along = uv.getY(i)
+      uv.setXY(i, 1 - along, around - shift)
+    }
+    uv.needsUpdate = true
+  }
+
+  /**
+   * Paint the inner surface onto the outer hull. This is a projection, not a view:
+   * from outside, Tower is structure, and what the texture shows is what is
+   * *inside*. It is an x-ray, and useful precisely because the hull is opaque.
+   */
+  private loadTerrain(): void {
+    new TextureLoader().load(
+      `${import.meta.env.BASE_URL}terrain-color.png`,
+      (tex) => {
+        tex.colorSpace = SRGBColorSpace
+        tex.wrapS = RepeatWrapping
+        tex.wrapT = RepeatWrapping
+        // uv.y is computed, not sampled from the file's row order, so no flip.
+        tex.flipY = false
+        tex.anisotropy = this.renderer.capabilities.getMaxAnisotropy()
+        this.terrainMap = tex
+        this.applyTerrain()
+      },
+      undefined,
+      () => { /* no raster yet: the plain hull is a fine fallback */ },
+    )
+  }
+
+  private applyTerrain(): void {
+    const on = this._showTerrain && this.terrainMap
+    this.hullMat.map = on ? this.terrainMap : null
+    /**
+     * Self-illuminate the terrain. Sunlight runs along the axis, so most of the
+     * hull faces away from every light in the scene and the map would be legible
+     * only on the sliver facing the fill. Emissive keeps it readable all the way
+     * round while the directional lights still give the cylinder its form.
+     */
+    this.hullMat.emissiveMap = on ? this.terrainMap : null
+    this.hullMat.emissive.set(on ? 0xffffff : 0x000000)
+    this.hullMat.emissiveIntensity = on ? 0.62 : 0
+    // Bare metal wants a tint; a textured surface wants to be left alone.
+    this.hullMat.color.set(on ? 0xffffff : 0x8892a3)
+    this.hullMat.metalness = on ? 0.06 : 0.28
+    this.hullMat.roughness = on ? 0.9 : 0.62
+    this.hullMat.needsUpdate = true
+    for (const rib of this.ribs) (rib.material as MeshStandardMaterial).opacity = on ? 0.5 : 1
+  }
+
+  set showTerrain(v: boolean) {
+    this._showTerrain = v
+    this.applyTerrain()
+  }
+
   private buildCaps(): void {
     // The sun cap is an annulus: the beam enters through the hole in the middle.
     const sun = new Mesh(
@@ -193,12 +279,20 @@ export class ExteriorView {
         ? { theta: g.theta, z: g.z }
         : { theta: g.points[0]![0], z: g.points[0]![1] }
       const selected = p.id === selectedId
+      const r = selected ? 6.5 : 4.2
       const dot = new Mesh(
-        new SphereGeometry(selected ? 5 : 3, 12, 12),
+        new SphereGeometry(r, 14, 14),
         new MeshBasicMaterial({ color: new Color(KIND_COLOR[p.kind] ?? '#ccc') }))
+      // A dark shell behind each dot. Small bright spheres read fine against a bare
+      // hull and vanish into the terrain once it is painted on; the outline is what
+      // keeps them legible over green forest and tan desert alike.
+      dot.add(new Mesh(
+        new SphereGeometry(r * 1.45, 14, 14),
+        new MeshBasicMaterial({ color: 0x05070d, side: BackSide })))
       dot.position.set(
-        R * 1.01 * Math.cos(at.theta), R * 1.01 * Math.sin(at.theta), at.z / KM)
+        (R + 1.6) * Math.cos(at.theta), (R + 1.6) * Math.sin(at.theta), at.z / KM)
       dot.userData['placeId'] = p.id
+      dot.renderOrder = 2
       this.markers.add(dot)
     }
   }
@@ -262,6 +356,13 @@ export class ExteriorView {
       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
+      /**
+       * Keep markers a roughly constant size on screen. A 4 km dot is legible from
+       * 200 km away and sub-pixel from 1,200 km, which is where the default view
+       * sits — so at world scale the places would simply not be there.
+       */
+      const scale = Math.max(0.8, Math.min(2.5, this.camera.position.distanceTo(this.controls.target) / 700))
+      for (const m of this.markers.children) m.scale.setScalar(scale)
       this.controls.update()
       this.renderer.render(this.scene, this.camera)
       requestAnimationFrame(tick)