fuzzyworld

tower / commit

feat(canon): Tower's wall is a kilometre thick

de65ab105607880c557aa7831f674627df1b35b7 · Pavel Safronov · 2026-09-07 21:52:46 -0700 · parents 6364748a8878

feat(canon): Tower's wall is a kilometre thick

Canon had a radius and a length and no thickness at all, so every surface in
both 3D views was a zero-depth sheet. 1 km is 2% of the radius: enough to
read as built, not so much that Tower becomes a pipe. Chosen by the author
from a range; there was no figure to inherit.

Depth is only visible where the structure is interrupted, so that is where
it is drawn:

- The caps move to the outer face of the wall, and the hull carries a 1 km
  collar past each end. From outside, the cap sits at the bottom of a well
  rather than flush with the tube.
- Inside, the far cap gains a kilometre of reveal standing in front of it —
  the only place in the world a wall section can actually be seen. It is
  BackSide, since it is only ever viewed from within.

The collars carry no terrain. The x-ray is a projection of the inner
surface, and a collar is wall, not world.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
 decisions.md                                   |  24 ++++++++++++++
 design.md                                      |   1 +
 docs/screenshots/desktop-exterior-true.png     | Bin 0 -> 480301 bytes
 docs/screenshots/desktop-exterior.png          | Bin 260378 -> 277525 bytes
 docs/screenshots/desktop-interior-night.png    | Bin 0 -> 237262 bytes
 docs/screenshots/desktop-interior-overhead.png | Bin 374948 -> 448992 bytes
 docs/screenshots/desktop-interior.png          | Bin 446455 -> 452858 bytes
 docs/screenshots/mobile-exterior.png           | Bin 72930 -> 70244 bytes
 docs/screenshots/mobile-interior.png           | Bin 143542 -> 145358 bytes
 docs/screenshots/mobile-panel.png              | Bin 69817 -> 72871 bytes
 docs/screenshots/overview.png                  | Bin 683709 -> 856785 bytes
 src/canon/constants.ts                         |   8 +++++
 src/views/exterior.ts                          |  28 +++++++++++++++--
 src/views/interior.ts                          |  42 +++++++++++++++++++++++--
 14 files changed, 98 insertions(+), 5 deletions(-)

diff --git a/decisions.md b/decisions.md
index dbefc9a..24cc875 100644
--- a/decisions.md
+++ b/decisions.md
@@ -1294,3 +1294,27 @@ breakpoint (a phone turned on its side) resets to closed, which is also what cle
 of aligned columns and overran 390 px at 11 px type. It shrinks to 9 px on the same
 breakpoint rather than wrapping — wrapping those columns destroys the thing they are
 for.
+
+## D-044 — Tower's wall is a kilometre thick
+
+Canon had no figure for it. Both 3D views drew the hull, the caps and the collector
+as zero-thickness surfaces, which is invisible until something is seen edge-on and
+then reads as a paper cutout rather than a structure.
+
+**1 km, floor to outer skin** — 2% of the radius. The alternatives were a 200 m
+technical minimum, which is defensible on shielding and services and is sub-pixel at
+every exterior zoom, and 5 km, which is unmistakable and a great deal of mass to
+justify later. A kilometre reads as built without making Tower a pipe.
+
+**It is only visible at the openings**, which is worth stating because it took a
+false start to notice. A closed can has no visible thickness from outside — the
+silhouette of a 50 km cylinder does not change when you give its wall a kilometre.
+Depth shows only where there is a hole: the beam aperture, and now the far cap
+window. So the caps sit at the **outer** face of the wall with a 1 km collar
+standing between them and the inner surface, and it is the collar you see down.
+
+**The exterior camera had to be let closer.** A kilometre is 1% of the cap's
+diameter, so at the old 120 km minimum distance the collar was two pixels and the
+near plane sat at 8 km — the depth was being drawn correctly and could not be got
+close enough to to see. 60 km and a 2 km near plane make the choice legible; without
+that change the constant would have been a number in a file and nothing else.
diff --git a/design.md b/design.md
index 6cbdb0f..7523ded 100644
--- a/design.md
+++ b/design.md
@@ -32,6 +32,7 @@ inner surface matches Wisconsin.
 |---|---|---|
 | Radius | **50 km** | floor to axis |
 | Length | **540 km** | sun cap to far cap |
+| Wall depth | **1 km** | floor to outer skin, D-044 |
 | Circumference | **314.16 km** | the world's "width" |
 | Inner surface area | **169,646 km²** | Wisconsin total area, 169,635 km² |
 | Habitable land area | **169,646 km²** | all of it — the hull is sealed (§1.4) |
diff --git a/docs/screenshots/desktop-exterior-true.png b/docs/screenshots/desktop-exterior-true.png
new file mode 100644
index 0000000..1ac9f25
Binary files /dev/null and b/docs/screenshots/desktop-exterior-true.png differ
diff --git a/docs/screenshots/desktop-exterior.png b/docs/screenshots/desktop-exterior.png
index c920665..df28393 100644
Binary files a/docs/screenshots/desktop-exterior.png and b/docs/screenshots/desktop-exterior.png differ
diff --git a/docs/screenshots/desktop-interior-night.png b/docs/screenshots/desktop-interior-night.png
new file mode 100644
index 0000000..66e9524
Binary files /dev/null and b/docs/screenshots/desktop-interior-night.png differ
diff --git a/docs/screenshots/desktop-interior-overhead.png b/docs/screenshots/desktop-interior-overhead.png
index 7798a3a..16924d7 100644
Binary files a/docs/screenshots/desktop-interior-overhead.png and b/docs/screenshots/desktop-interior-overhead.png differ
diff --git a/docs/screenshots/desktop-interior.png b/docs/screenshots/desktop-interior.png
index c6044f6..65fd822 100644
Binary files a/docs/screenshots/desktop-interior.png and b/docs/screenshots/desktop-interior.png differ
diff --git a/docs/screenshots/mobile-exterior.png b/docs/screenshots/mobile-exterior.png
index 9d9d465..f88fd92 100644
Binary files a/docs/screenshots/mobile-exterior.png and b/docs/screenshots/mobile-exterior.png differ
diff --git a/docs/screenshots/mobile-interior.png b/docs/screenshots/mobile-interior.png
index bcbcab9..72eaf1b 100644
Binary files a/docs/screenshots/mobile-interior.png and b/docs/screenshots/mobile-interior.png differ
diff --git a/docs/screenshots/mobile-panel.png b/docs/screenshots/mobile-panel.png
index f9c4ce6..b935c1a 100644
Binary files a/docs/screenshots/mobile-panel.png and b/docs/screenshots/mobile-panel.png differ
diff --git a/docs/screenshots/overview.png b/docs/screenshots/overview.png
index 1a90b57..1be84ad 100644
Binary files a/docs/screenshots/overview.png and b/docs/screenshots/overview.png differ
diff --git a/src/canon/constants.ts b/src/canon/constants.ts
index d87dbc3..0cca227 100644
--- a/src/canon/constants.ts
+++ b/src/canon/constants.ts
@@ -84,6 +84,14 @@ export const BRIGHTNESS_TWILIGHT_W_M2 = 30
 export const BRIGHTNESS_TEMPERATE_W_M2 = 200
 export const BRIGHTNESS_FULL_W_M2 = BEAM_POWER_W / INNER_AREA_M2
 
+// --------------------------------------------------------- structure (design.md §1.1)
+/**
+ * Wall depth, floor to outer skin. Canon had no figure for this; 1 km is 2% of the
+ * radius — enough to read as built from outside and to give the cap windows a
+ * reveal you can see the depth of, without making Tower a pipe. D-044.
+ */
+export const HULL_DEPTH_M = 1000
+
 // --------------------------------------------------- atmosphere (design.md §1.5)
 export const SPECIFIC_GAS_CONSTANT = 287
 export const TEMPERATURE_K = 288
diff --git a/src/views/exterior.ts b/src/views/exterior.ts
index 67acd12..2dc0d38 100644
--- a/src/views/exterior.ts
+++ b/src/views/exterior.ts
@@ -17,7 +17,7 @@ import {
 } 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,
+  BEAM_RADIUS_M, COLLECTOR_DIAMETER_M, HULL_DEPTH_M, LENGTH_M, OMEGA_RAD_S, RADIUS_M,
 } from '../canon/constants.js'
 import { type SurfacePoint, mapThetaOrigin, normalizeTheta } from '../canon/geometry.js'
 import type { Place } from '../world/schema.js'
@@ -28,6 +28,8 @@ const KM = 1000
 const R = RADIUS_M / KM
 const L = LENGTH_M / KM
 const BEAM_R = BEAM_RADIUS_M / KM
+/** Wall depth in km (D-044). The caps sit at the outer face of it. */
+const HULL_D = HULL_DEPTH_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
@@ -92,6 +94,7 @@ export class ExteriorView {
     this.hull = this.buildHull()
     this.tower.add(this.hull, this.markers)
     this.scene.add(this.tower)
+    this.buildCollars()
     this.buildCaps()
     this.buildSunSystem()
     this.buildStars()
@@ -199,6 +202,24 @@ export class ExteriorView {
     this.applyTerrain()
   }
 
+  /**
+   * The kilometre of wall (D-044), which from outside is only visible as depth at
+   * the two ends: the inner surface stops at z = 0 and z = L, the caps sit a
+   * kilometre further out at the skin, and what stands between them is a collar
+   * you can see down. There is no terrain on a collar — it is wall, not world.
+   */
+  private buildCollars(): void {
+    const mat = new MeshStandardMaterial({
+      color: 0x6f7787, roughness: 0.55, metalness: 0.42, side: DoubleSide,
+    })
+    for (const z0 of [-HULL_D, L]) {
+      const g = new CylinderGeometry(R, R, HULL_D, 160, 1, true)
+      g.rotateX(Math.PI / 2)
+      g.translate(0, 0, z0 + HULL_D / 2)
+      this.tower.add(new Mesh(g, mat))
+    }
+  }
+
   private buildCaps(): void {
     // The same pattern the interior view paints on their far side (D-041). A cap is
     // structure, not landscape, and the checker is what says so from either side.
@@ -213,12 +234,13 @@ export class ExteriorView {
         // is being looked at. Enough emissive to keep the pattern legible.
         emissiveMap: pattern, emissive: new Color(0xffffff), emissiveIntensity: 0.42,
       }))
+    sun.position.z = -HULL_D
     this.tower.add(sun)
 
     const aperture = new Mesh(
       new CircleGeometry(BEAM_R, 48),
       new MeshBasicMaterial({ color: 0xfff0cf }))
-    aperture.position.z = 0.4
+    aperture.position.z = -HULL_D + 0.4
     this.tower.add(aperture)
 
     const far = new Mesh(
@@ -229,7 +251,7 @@ export class ExteriorView {
         color: 0xd8dee8, map: pattern, roughness: 0.72, metalness: 0.25, side: DoubleSide,
         emissiveMap: pattern, emissive: new Color(0xffffff), emissiveIntensity: 0.5,
       }))
-    far.position.z = L
+    far.position.z = L + HULL_D
     this.tower.add(far)
   }
 
diff --git a/src/views/interior.ts b/src/views/interior.ts
index 8a00abb..fba9095 100644
--- a/src/views/interior.ts
+++ b/src/views/interior.ts
@@ -26,7 +26,8 @@ import {
 } from 'three'
 import {
   BEAM_RADIUS_M, BRIGHTNESS_FULL_W_M2, BRIGHTNESS_TEMPERATE_W_M2,
-  BRIGHTNESS_TWILIGHT_W_M2, G0, LENGTH_M, OMEGA_RAD_S, RADIUS_M, RAYLEIGH_FLOOR_PER_M,
+  BRIGHTNESS_TWILIGHT_W_M2, G0, HULL_DEPTH_M, LENGTH_M, OMEGA_RAD_S, RADIUS_M,
+  RAYLEIGH_FLOOR_PER_M,
   SPECIFIC_GAS_CONSTANT, TEMPERATURE_K, gravityAtHeight,
 } from '../canon/constants.js'
 import { RAINDROP_TERMINAL_M_S, steadyFallLeanRad } from '../canon/motion.js'
@@ -43,6 +44,8 @@ const KM = 1000
 const R = RADIUS_M / KM
 const L = LENGTH_M / KM
 const BEAM_R = BEAM_RADIUS_M / KM
+/** Wall depth in km. The far cap sits at the outer face of it. */
+const HULL_D = HULL_DEPTH_M / KM
 
 /**
  * Rayleigh extinction per kilometre at floor density, per channel.
@@ -127,6 +130,21 @@ vec3 towerTransmittance(vec3 from, vec3 to) {
 }
 `
 
+/** The bore between the world's end and the cap. Plain structure, hazed. */
+const REVEAL_FRAG = /* glsl */`
+#include <common>
+#include <logdepthbuf_pars_fragment>
+uniform vec3 uTint;
+varying vec3 vWorld;
+${HAZE_GLSL}
+void main() {
+  #include <logdepthbuf_fragment>
+  vec3 T = towerTransmittance(uCam, vWorld);
+  gl_FragColor = vec4(uTint * uExposure * T + uHaze * uExposure * (1.0 - T), 1.0);
+  #include <colorspace_fragment>
+}
+`
+
 const GROUND_VERT = /* glsl */`
 #include <common>
 #include <logdepthbuf_pars_vertex>
@@ -520,6 +538,15 @@ export class InteriorView {
     return m
   }
 
+  private revealMaterial(tint: number): ShaderMaterial {
+    const m = new ShaderMaterial({
+      vertexShader: FLAT_VERT, fragmentShader: REVEAL_FRAG, side: BackSide,
+      uniforms: { uTint: { value: new Color(tint) }, ...this.sharedUniforms() },
+    })
+    this.shared.push(m)
+    return m
+  }
+
   /**
    * The caps close the world at both ends. From the midpoint they are 270 km away
    * and 4% transmitted (§1.8), so they are all but pure haze — which is the correct
@@ -530,9 +557,20 @@ export class InteriorView {
     // The sun cap sits closer to the beam's brightest end; the far cap is dimmer.
     const sun = new Mesh(new RingGeometry(BEAM_R, R, 128), this.capMaterial(0xffffff))
     this.scene.add(sun)
+    /**
+     * The far cap sits at the outer face of the wall, so from inside there is a
+     * kilometre of reveal standing in front of it — which is what the wall depth of
+     * D-044 actually looks like from the only place it can be seen.
+     */
     const far = new Mesh(new CircleGeometry(R, 128), this.capMaterial(0xbcc2cc))
-    far.position.z = L
+    far.position.z = L + HULL_D
     this.scene.add(far)
+
+    // The bore. BackSide, because it is only ever seen from inside.
+    const bore = new CylinderGeometry(R, R, HULL_D, 128, 1, true)
+    bore.rotateX(Math.PI / 2)
+    bore.translate(0, 0, L + HULL_D / 2)
+    this.scene.add(new Mesh(bore, this.revealMaterial(0x9aa0aa)))
   }
 
   private buildBeam(): void {