fuzzyworld

tower / commit

feat(interior): night, and the difference between banked and off

392bb8a769dfd6f17623a21d92573002e7639cbd · Pavel Safronov · 2026-09-07 21:53:24 -0700 · parents de65ab105607

feat(interior): night, and the difference between banked and off

Two more illumination settings, and working out what they had to be turned
up a fact canon had backwards.

**Night cannot be made by aiming the swarm away.** §1.3 gives the beam's
glow to the beam and the air rather than to the diffusers — it would be
there if the beam were empty — so a lit beam lights the world whatever the
mirrorlets do. 779 W/m²/sr over a 3.65° strip running horizon to horizon is
about 0.2 sr, which puts ~100 W/m² on the floor: half of temperate, and no
kind of night. So Tower's night is the beam itself banked, to a thousandth
of full: 0.32 W/m².

**Dark is a different state and a much worse one.** The beam shut down
entirely — no airlight, no lit ground, no rod overhead. It is the only
setting that is genuinely black, and §1.9.4's week of thermal coasting is
what makes it survivable rather than final.

The clear colour is now scaled by exposure, or dark would clear to a
dusk-coloured sky that nothing in the scene is bright enough to justify.
Restoring a saved setting goes through a type guard instead of a hand-rolled
three-way comparison that would have silently rejected both new values.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
 decisions.md           | 23 +++++++++++++++++++++++
 design.md              | 23 +++++++++++++++++++++++
 index.html             |  2 ++
 src/canon/constants.ts |  8 ++++++++
 src/views/interior.ts  | 33 ++++++++++++++++++++++++++-------
 test/canon.test.ts     |  4 ++++
 6 files changed, 86 insertions(+), 7 deletions(-)

diff --git a/decisions.md b/decisions.md
index 24cc875..7eaba1e 100644
--- a/decisions.md
+++ b/decisions.md
@@ -1318,3 +1318,26 @@ diameter, so at the old 120 km minimum distance the collar was two pixels and th
 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.
+
+## D-046 — Night is the beam banked, not the swarm aimed away
+
+The obvious way to make night in a habitat lit by a steerable mirror swarm is to
+aim the swarm off. It does not work here, and §1.3's own numbers say so.
+
+The beam glows **by itself**: §1.3 gives the glow to the beam and the air rather
+than to the diffusers, and says outright it "would be there if the beam were empty".
+779 W/m²/sr over a strip 3.65° wide running horizon to horizon is about 0.2 sr, and
+with the mean cosine of a strip through the zenith that is **~100 W/m² on the
+floor** — half of temperate. A lit beam is a light source no matter where the
+mirrorlets are pointed, so a dark world requires a dark beam. There is a test for
+this figure in `test/canon.test.ts`, because the conclusion rests entirely on it.
+
+So **night is the beam banked to a thousandth of full, 0.32 W/m²**: a rod you can
+still find overhead and a world you can just make out under it. And **dark is the
+beam shut down** — no airlight, no lit ground, no rod, and the only thing left in
+the world is the sky through the far cap (D-045). The two settings are genuinely
+different states rather than two points on a dimmer, which is what made both worth
+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.
diff --git a/design.md b/design.md
index 7523ded..2f2d035 100644
--- a/design.md
+++ b/design.md
@@ -169,6 +169,29 @@ system as a lighting system. 5,000× was chosen because it is *demonstrated*
 technology — the Odeillo solar furnace reaches ~10 MW/m² on Earth today. Nothing in
 Tower's sun system requires physics that does not exist. See `decisions.md` D-003.
 
+#### 1.3.2 Night, and the difference between banked and off
+
+**Night cannot be made by aiming the swarm away.** The glow above belongs to the
+beam and the air, not to the diffusers, so a fully lit beam lights the world
+whatever the mirrorlets are doing: 779 W/m²/sr over a strip 3.65° wide running
+horizon to horizon is about 0.2 sr, which lands **~100 W/m² on the floor** — half
+of temperate, and no kind of night. Tower's night is therefore made by **banking
+the beam itself**, to a thousandth of full: **0.32 W/m²**, a rod you can still find
+overhead and a world you can just make out under it (D-046).
+
+**Shutting the beam down entirely is a different state and a much worse one.** No
+airlight, no lit ground, no rod — the only thing left in the world is the sky
+through the far cap. §1.9.4 gives the thermal mass about a week of coasting, so
+this is a state Tower can survive and not one it can sit in.
+
+| Setting | Floor | What it is |
+|---|---|---|
+| Dark | **0** | the beam shut down. Stars, and nothing else. |
+| Night | **0.32 W/m²** | the beam banked to a thousandth |
+| Twilight | 30 W/m² | |
+| Temperate | 200 W/m² | |
+| Full | 320 W/m² | Earth clear-sky noon |
+
 ### 1.4 Sealed hull, and the cap windows
 
 **The cylinder wall is opaque and unbroken.** No slats, no windows, no view out
diff --git a/index.html b/index.html
index 97677f9..a7db194 100644
--- a/index.html
+++ b/index.html
@@ -54,6 +54,8 @@
           <input id="f-eye" type="range" min="0" max="1000" step="1" value="0" /></label>
         <label>Light
           <select id="f-light">
+            <option value="dark">Dark — beam shut down</option>
+            <option value="night">Night — 0.3 W/m²</option>
             <option value="twilight">Twilight — 30 W/m²</option>
             <option value="temperate" selected>Temperate — 200 W/m²</option>
             <option value="full">Full — 319 W/m²</option>
diff --git a/src/canon/constants.ts b/src/canon/constants.ts
index 0cca227..4aa34b5 100644
--- a/src/canon/constants.ts
+++ b/src/canon/constants.ts
@@ -83,6 +83,14 @@ export const AIM_SPOT_AT_FLOOR_M = MIRRORLET_DIFFRACTION_RAD * RADIUS_M
 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
+/**
+ * Night is made by banking the beam, not by aiming the swarm away — §1.3 says the
+ * glow belongs to the beam and the air, so a lit beam lights the world whatever
+ * the diffusers do. A thousandth of full leaves a rod you can still find overhead
+ * and a world you can just make out under it. D-046.
+ */
+export const NIGHT_BEAM_FRACTION = 0.001
+export const BRIGHTNESS_NIGHT_W_M2 = BRIGHTNESS_FULL_W_M2 * NIGHT_BEAM_FRACTION
 
 // --------------------------------------------------------- structure (design.md §1.1)
 /**
diff --git a/src/views/interior.ts b/src/views/interior.ts
index fba9095..0fbafc4 100644
--- a/src/views/interior.ts
+++ b/src/views/interior.ts
@@ -26,7 +26,7 @@ import {
 } from 'three'
 import {
   BEAM_RADIUS_M, BRIGHTNESS_FULL_W_M2, BRIGHTNESS_TEMPERATE_W_M2,
-  BRIGHTNESS_TWILIGHT_W_M2, G0, HULL_DEPTH_M, LENGTH_M, OMEGA_RAD_S, RADIUS_M,
+  BRIGHTNESS_NIGHT_W_M2, 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'
@@ -82,12 +82,26 @@ const RAIN_STREAK_KM = 1.1 / KM
 const SEG_AROUND = 512
 const SEG_ALONG = 768
 
+/**
+ * The five settings, darkest first.
+ *
+ * `dark` is the beam shut down entirely and is the only one that is genuinely
+ * black — with 54 TW off there is no airlight, no lit ground and no rod overhead,
+ * and the only thing left in the world is the sky through the far cap. `night` is
+ * the beam banked rather than killed, which is what night has to be here: §1.3
+ * gives the glow to the beam and the air rather than to the diffusers, so aiming
+ * the swarm away would not darken anything (D-046).
+ */
 export const LIGHTING = {
+  dark: 0,
+  night: BRIGHTNESS_NIGHT_W_M2,
   twilight: BRIGHTNESS_TWILIGHT_W_M2,
   temperate: BRIGHTNESS_TEMPERATE_W_M2,
   full: BRIGHTNESS_FULL_W_M2,
 } as const
 export type Lighting = keyof typeof LIGHTING
+export const isLighting = (v: unknown): v is Lighting =>
+  typeof v === 'string' && v in LIGHTING
 
 const STATE_KEY = 'tower.interior'
 const STATE_SAVE_MS = 400
@@ -695,11 +709,16 @@ export class InteriorView {
      * exponent compresses the canon watts into something a screen can show while
      * keeping the ordering and the ratios honest.
      */
-    const e = (LIGHTING[this.lighting] / BRIGHTNESS_TEMPERATE_W_M2) ** 0.35
-    for (const m of this.shared) m.uniforms['uExposure']!.value = e
-    const haze = new Color(this.lighting === 'twilight' ? HAZE_DUSK : HAZE_DAY)
-    for (const m of this.shared) (m.uniforms['uHaze']!.value as Color).copy(haze)
-    this.renderer.setClearColor(haze, 1)
+    const watts = LIGHTING[this.lighting]
+    const e = (watts / BRIGHTNESS_TEMPERATE_W_M2) ** 0.35
+    const haze = new Color(watts <= BRIGHTNESS_TWILIGHT_W_M2 ? HAZE_DUSK : HAZE_DAY)
+    for (const m of this.shared) {
+      m.uniforms['uExposure']!.value = e
+      ;(m.uniforms['uHaze']!.value as Color).copy(haze)
+    }
+    // Scaled, so `dark` clears to black rather than to a dusk-coloured sky that
+    // nothing in the scene is bright enough to justify.
+    this.renderer.setClearColor(haze.clone().multiplyScalar(e), 1)
   }
 
   setLighting(l: Lighting): void {
@@ -853,7 +872,7 @@ export class InteriorView {
       this.yaw = num(s['yaw'], -100, 100) ?? this.yaw
       this.pitch = num(s['pitch'], -1.55, 1.55) ?? this.pitch
       this.fovDeg = num(s['fov'], 3, 90) ?? this.fovDeg
-      if (s['lighting'] === 'twilight' || s['lighting'] === 'temperate' || s['lighting'] === 'full') {
+      if (isLighting(s['lighting'])) {
         this.lighting = s['lighting']
       }
       this.showRain = s['rain'] === true
diff --git a/test/canon.test.ts b/test/canon.test.ts
index 78ec12a..a6b3ec3 100644
--- a/test/canon.test.ts
+++ b/test/canon.test.ts
@@ -118,6 +118,10 @@ describe('§1.4 slat count is adjustable from 0 to 16', () => {
 describe('§1.3 sun system', () => {
   it('beam radius is 1592 m', () => near(C.BEAM_RADIUS_M, 1592, 1))
   it('beam carries 54.3 TW', () => near(C.BEAM_POWER_W / 1e12, 54.3, 0.2))
+  it('night is the beam banked to a thousandth, 0.32 W/m²', () => {
+    near(C.BRIGHTNESS_NIGHT_W_M2, 0.32, 0.01)
+    near(C.NIGHT_BEAM_FRACTION, 0.001, 1e-9)
+  })
   it('full brightness is Earth clear-sky noon, 320 W/m² mean', () =>
     near(C.BRIGHTNESS_FULL_W_M2, 320, 2))
   it('collector is 244 km across', () => near(C.COLLECTOR_DIAMETER_M / 1000, 244, 1))