fuzzyworld

tower / commit

cascadia, initial commit

1affced2306035c02eb54aef47b2fb23da42a8f8 · Pavel Safronov · 2026-09-08 16:35:44 -0700 · parents 37a25a7f9859

cascadia, initial commit
 .gitignore                 |   3 +
 README.md                  |  43 ++++-
 decisions.md               |  98 +++++++++++
 design.md                  |  33 ++++
 index.html                 |  15 +-
 package.json               |   3 +-
 src/ui/app.ts              |  64 ++++++-
 src/views/exterior.ts      |  17 +-
 src/views/interior.ts      |  26 ++-
 src/views/map.ts           |  17 +-
 src/views/raster.ts        |  28 +++
 static/cascadia-color.png  | Bin 0 -> 3698974 bytes
 static/cascadia-height.png | Bin 0 -> 3872191 bytes
 test/raster.test.ts        |  44 +++++
 tools/cascadia.mjs         | 419 +++++++++++++++++++++++++++++++++++++++++++++
 tools/png.mjs              |  59 ++++++-
 16 files changed, 856 insertions(+), 13 deletions(-)

diff --git a/.gitignore b/.gitignore
index f20f0fe..98b742a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,6 @@ tower-backup-*.json
 
 # Left over from a rejected Python attempt; kept out of the repo.
 .venv/
+
+# Elevation tiles cached by tools/cascadia.mjs. Re-fetchable; not source.
+.cache/
diff --git a/README.md b/README.md
index 8f4d9a2..d5d6f96 100644
--- a/README.md
+++ b/README.md
@@ -113,9 +113,10 @@ regressions, not things that were already broken.
 ```
 npm install
 npm run dev      # http://localhost:5173
-npm test         # 94 tests; canon figures are pinned to design.md
+npm test         # 104 tests; canon figures are pinned to design.md
 npm run build
 npm run terrain   # regenerate the maps from content/geography.json
+npm run cascadia  # regenerate the 1:1 Cascadia surface (see below)
 npm run screenshots  # drive the app and rephotograph docs/screenshots/
 ```
 
@@ -152,6 +153,46 @@ and takes about 6 seconds. Flags:
 | `--quiet` | one line per stage instead of one per feature |
 | `--force` | let the seed overwrite places added in the app |
 
+### The Cascadia surface — a ruler
+
+The panel's **Surface** control can swap the authored ground for **the Pacific
+Northwest at 1:1**, to answer the question a number cannot: *how big is this
+actually?*
+
+It fits because Tower is a corridor and so is Cascadia. Measured in UTM 10N, the
+ground from the Pacific surf at Kalaloch east to the Columbia at Entiat is
+**312.8 km against Tower's 314.2 km circumference** — so the real coast closes the
+ring on real terrain, with 1.4 km to spare. Walk east from the beach, over the
+Olympics, across Puget Sound, over the Cascades and down the east flank to the
+Columbia, and you are back at the beach. Seattle sits 150 km deepward of the sun
+cap, and the point 100 km straight overhead is *both* Kalaloch and Entiat — one
+place, reached by walking either way round. Nothing is rescaled and nothing is
+invented.
+
+**It is not canon**, contains no Tower geography, defaults off, and draws no
+places. The one artefact is a seam where θ wraps and geography does not — a scarp
+of 369–844 m through the settled half, kept as a landform rather than repaired.
+See D-055 for the alternatives that were measured and rejected.
+
+```
+npm run cascadia            # ~2 s from cache, ~5 s cold
+```
+
+| | |
+|---|---|
+| `static/cascadia-height.png` | 16-bit greyscale, sharing canon's `-560`–`4400` m window |
+| `static/cascadia-color.png` | the same 2048 x 1192 grid, 264 m/px |
+
+Elevation comes from the AWS open terrain tiles — SRTM/3DEP over land, GEBCO below
+water, Canadian ground north of 49°N in the same grid — cached under `.cache/`,
+re-fetchable, and not committed. There are no dependencies beyond Node; the UTM
+projection and the PNG decoder are in the repo.
+
+Every surface **must** share the raster grid and height window: the interior view
+bakes that window into its GLSL as literals, so a surface declaring its own would
+silently displace its ground. `test/raster.test.ts` reads the constants back out of
+`tools/cascadia.mjs` and holds it to them.
+
 ## Reading and writing
 
 The map opens in **writer mode**: tools, editor, canon controls and import/export.
diff --git a/decisions.md b/decisions.md
index 29c1d02..1c86c2f 100644
--- a/decisions.md
+++ b/decisions.md
@@ -1644,3 +1644,101 @@ seven times too wide, which washed the whole disc gold instead of tracing it.
 lamp, so the rim is outside `uExposure`, like the stars. It does fade as the eye
 adapts, but from a much higher floor than a star's — this is sunlit structure, and
 it stays faintly visible at noon where a star cannot.
+
+## D-055 — Cascadia at 1:1, as a ruler
+
+Tower's inner surface is 169,646 km², which design.md §1 has always given as
+Wisconsin's total area. That is a fact about a *number*, and a number is a poor way
+to feel a size. The question this decision answers is the reader's: **how big is
+this actually?**
+
+The obvious answer — put Wisconsin inside it — does not work, and finding out why
+is what produced the answer that does. Wisconsin has Tower's area but not its
+shape: it spans about 525 km east–west and 510 km north–south, while Tower unrolls
+to a 540 x 314 km rectangle. At 1:1 it cannot be laid down at all, because 525 km
+of state will not go round a 314 km circumference; it overlaps itself by 67%.
+Scaled to fit undistorted it covers a third of the surface, and squashed to fill it
+it is stretched 1.77:1 and no longer Wisconsin. **The areas match and the shapes do
+not**, which is worth recording as the honest form of the coincidence.
+
+Cascadia works where Wisconsin cannot, because it is a corridor and Tower is a
+corridor. The coast runs north–south, the Sound runs north–south, the Cascade crest
+runs north–south. And the width is a second coincidence, better than the first:
+measured in UTM 10N, **the ground from the Pacific surf at Kalaloch east to the
+Columbia River at Entiat is 312.8 km, against a circumference of 314.2** — 1.4 km
+of slack. Western Washington closes the ring on real terrain. Walk east from the
+beach, over the Olympics, across Puget Sound, over the Cascades, down the east
+flank to the Columbia, and you arrive back at the beach.
+
+**Nothing is invented and nothing is rescaled.** The consequence, which is the
+reason this reads as a world rather than as a diagram: the point 100 km straight
+over Seattle is both the surf north of Kalaloch and the Columbia at Entiat — one
+place, reached by walking either way round. Seattle sits 150 km deepward of the sun
+cap, Olympia 88, Bellingham 277, Vancouver 336.
+
+**Tower has no north**, which is what makes this legitimate rather than a cheat.
+The compass is sunward/deepward/spinward/antispinward (content/geography.json), so
+orienting a real region to the cylinder distorts nothing — there is no north to
+distort against.
+
+### The seam, and what was tried against it
+
+Theta wraps and real geography does not, so column 0 and column 1191 are neighbours
+on the cylinder while being 314 km apart on Earth. The step between them is 852 m
+median, 1,537 m at p90 — small through the settled half (369–844 m from the
+Columbia bar to Bellingham) and largest past 49°N, in fjord country where kilometre
+walls are already normal.
+
+Two levers were measured rather than argued about:
+
+- **Rotating the sampling frame** to follow the Cascadia trend. No help at any
+  angle from 0° to 20°: the median stays 850–960 m and p90 and max get worse. The
+  mismatch is vertical, not alignment.
+- **Sliding the cut east.** This works — 36 km east more than halves the step
+  through the settled half, to 195–378 m. **Rejected anyway**, because it selects a
+  different 314 km of ground: the outer Pacific coast leaves the world, and with it
+  the identity above. Overhead from Seattle becomes the Columbia Basin instead of
+  surf-and-river at once. The world also goes drier and more monotone.
+
+So the seam stays where it is, and is treated as a landform rather than an error: a
+scarp running the length of the world exactly where the wet side meets the dry one,
+which is the truest thing about this coast. Real analogues are not hard to find —
+the Blue Ridge escarpment, the Drakensberg.
+
+### What it costs the codebase
+
+Almost nothing, deliberately. **The Cascadia rasters borrow canon's grid and height
+window rather than declaring their own** (-560..4400 m, 2048 x 1192). That is not a
+convenience, it is what keeps the mode cheap: src/views/interior.ts bakes the height
+window into its GLSL as literals, so a surface with its own range would need a
+second shader. It costs nothing to share because Cascadia happens to fit — the
+highest land here is Rainier at 4,352 m, under the 4,400 m ceiling. Deep ocean is
+clamped instead, which is invisible under water; carrying the abyssal plain's 2.6 km
+would coarsen every hillside in the world to pay for it. test/raster.test.ts reads
+the constants back out of tools/cascadia.mjs and holds it to them, because this is
+D-024's failure mode exactly: a surface that quietly disagreed would displace its
+ground rather than fail.
+
+So a surface is a swap of two PNG filenames (src/views/raster.ts), and the three
+views each gained a `setSurface`.
+
+### What it is not
+
+**This is not canon and contains no Tower geography.** It contradicts §1.9.3
+outright — all relief in Tower is engineered, because a bare cylinder is an
+equipotential with no slope anywhere — and the rivers are wrong in a way worth
+naming: they were carved under Earth gravity with no Coriolis, so they do not lean
+antispinward the way §1.9.2 says everything falling in Tower does. Snoqualmie Falls
+is a compass pointing the wrong way.
+
+It is therefore labelled in the panel, defaults off, and **draws no places**. The
+world list still shows them, because that is the author's data and hiding it would
+look like loss, but nothing is drawn on the terrain: a marker reading "The Capital"
+pinned to Puget Sound would claim a correspondence that does not exist. Probes and
+sightlines stay, because those measure the cylinder, and the cylinder is the same
+either way. The choice is not carried in the URL hash either — a permalink names a
+place in Tower, and Cascadia has none.
+
+Elevation comes from the AWS open terrain tiles (SRTM/3DEP over land, GEBCO below
+water, Canadian ground north of 49°N in the same grid), cached under `.cache/` and
+re-fetchable; they are not source and are not committed.
diff --git a/design.md b/design.md
index bdbbe05..46f6aec 100644
--- a/design.md
+++ b/design.md
@@ -42,6 +42,12 @@ so nothing is subtracted. *An earlier version sized star slats to leave exactly
 Wisconsin's land area of 140,268 km²; D-017 removes them and that second
 coincidence with it.*
 
+The Wisconsin comparison is a fact about area alone, and the honest form of it is
+worth stating: **the areas match and the shapes do not.** Wisconsin spans about
+525 km east–west against a circumference of 314, so at 1:1 it cannot be laid on
+this surface at all — it overlaps itself by 67%. Feeling the size needs a region
+shaped like a corridor rather than a blob, which is what §2.5 is for.
+
 ### 1.2 Spin and gravity
 
 | Quantity | Value |
@@ -811,6 +817,33 @@ into the shell as basins, so standing in one puts you on its floor.
 
 ---
 
+### 2.5 Surfaces — the Cascadia ruler
+
+The app can paint the cylinder with a second, non-canon ground: **the Pacific
+Northwest at 1:1**, as a scale reference. In UTM 10N the ground from the Pacific
+surf at Kalaloch east to the Columbia at Entiat measures 312.8 km against Tower's
+314.2 km circumference, so western Washington closes the ring on real terrain with
+1.4 km to spare — beach, Olympics, Puget Sound, Cascades, east flank, Columbia,
+back to the beach. Seattle lands 150 km deepward of the sun cap, and the point
+100 km straight overhead is both Kalaloch and Entiat: one place, reached by walking
+either way round.
+
+This is legitimate because **Tower has no north** (§1.7) — the compass is
+sunward/deepward/spinward/antispinward, so there is nothing for a real region's
+orientation to contradict.
+
+It is **not canon**. It contradicts §1.9.3 (all relief in Tower is engineered) and
+its rivers do not lean antispinward the way §1.9.2 requires. It is labelled in the
+panel, defaults off, and draws no places. The one artefact is a seam: theta wraps
+and geography does not, leaving a scarp of 369–844 m through the settled half,
+treated as a landform rather than repaired. Rotating the frame and sliding the cut
+were both measured against it; see D-055 for why neither is used.
+
+Every surface shares the raster grid and height window (§2.2), which is what keeps
+a surface to a swap of two PNG filenames rather than a second shader.
+
+`npm run cascadia` builds it. See D-055.
+
 ## 3. Writer mode and the world file
 
 The app is an instrument for writing the novel as much as a thing to look at. It
diff --git a/index.html b/index.html
index 3d9ae5c..0140358 100644
--- a/index.html
+++ b/index.html
@@ -36,8 +36,21 @@
     <details class="sec" id="view-section" open>
       <summary>View</summary>
       <label class="row" id="terrain-row"><input id="f-terrain" type="checkbox" checked /> Show terrain</label>
+      <label>Surface
+        <select id="f-surface">
+          <option value="canon" selected>Tower — the authored world</option>
+          <option value="cascadia">Cascadia — the real coast, 1:1</option>
+        </select></label>
+      <p class="hint" id="surface-hint" hidden>Not Tower. This is the Pacific
+        Northwest at true scale, wrapped onto the same cylinder as a ruler: the
+        ground from the Pacific surf east to the Columbia measures 312.8 km, and
+        Tower is 314.2 km around, so the real coast closes the ring on real
+        terrain. Seattle sits 150 km deepward of the sun cap, and the point 100 km
+        straight overhead is both the surf north of Kalaloch and the Columbia at
+        Entiat — one place, reached by walking either way round. Nothing here is
+        canon; no place in the world list belongs to it.</p>
       <div id="map-options">
-        <p class="hint">Rendered by <code>npm run terrain</code> from
+        <p class="hint" id="map-hint">Rendered by <code>npm run terrain</code> from
           <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>
diff --git a/package.json b/package.json
index 9711d67..78ec138 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,8 @@
     "test": "vitest run",
     "test:watch": "vitest",
     "terrain": "node tools/terrain.mjs",
-    "screenshots": "node tools/screenshots.mjs"
+    "screenshots": "node tools/screenshots.mjs",
+    "cascadia": "node tools/cascadia.mjs"
   },
   "devDependencies": {
     "@types/node": "^26.4.1",
diff --git a/src/ui/app.ts b/src/ui/app.ts
index c552f85..3493dee 100644
--- a/src/ui/app.ts
+++ b/src/ui/app.ts
@@ -14,6 +14,7 @@ import {
 import { KIND_COLOR } from '../views/palette.js'
 import { WorldStore, backupFilename, download } from '../world/store.js'
 import { TowerMap } from '../views/map.js'
+import { type Surface, isSurface } from '../views/raster.js'
 import type { ExteriorView } from '../views/exterior.js'
 import type { InteriorView, Lighting } from '../views/interior.js'
 import { G0, LENGTH_M, SPIN_PERIOD_S, gravityAtHeight } from '../canon/constants.js'
@@ -22,6 +23,7 @@ import { eyeHeightFromSlider, sliderFromEyeHeight } from './scales.js'
 type Tool = 'select' | PlaceKind
 type ViewName = 'map' | 'exterior' | 'interior'
 const VIEW_KEY = 'tower.view'
+const SURFACE_KEY = 'tower.surface'
 const isView = (v: string | null): v is ViewName =>
   v === 'map' || v === 'exterior' || v === 'interior'
 
@@ -98,6 +100,7 @@ export class App {
   private selectedId: string | null = null
   private draft: [number, number][] = []
   private probeFrom: SurfacePoint | null = null
+  private surface: Surface = 'canon'
 
   constructor(private store: WorldStore) {
     this.map = new TowerMap($<HTMLCanvasElement>('map'), {
@@ -106,6 +109,7 @@ export class App {
       onHover: (p) => this.onHover(p),
     })
     this.buildViews()
+    this.wireSurface()
     this.buildTools()
     this.buildKindSelect()
     this.wireEditor()
@@ -153,6 +157,47 @@ export class App {
     return 'map'
   }
 
+  /**
+   * The surface switch. Not a view and not a writer mode: it changes what ground
+   * every view is drawing, so it lives beside the terrain toggle rather than in
+   * the view bar. Persisted, because a reader who came for the scale comparison
+   * should not be dropped back into canon by a reload — and it is deliberately
+   * not carried in the hash, because a shared permalink names a place in Tower
+   * and Cascadia has no places (D-055).
+   */
+  private wireSurface(): void {
+    const sel = $<HTMLSelectElement>('f-surface')
+    try {
+      const stored = localStorage.getItem(SURFACE_KEY)
+      if (isSurface(stored)) this.surface = stored
+    } catch { /* unreadable storage is not an error here */ }
+    sel.value = this.surface
+    /**
+     * `redraw` is false for the initial call: this runs from the constructor,
+     * before the tools, the editor and the kind select exist, and render() reads
+     * all of them. The constructor renders once on its own a few lines later.
+     */
+    const apply = (redraw: boolean) => {
+      const canon = this.surface === 'canon'
+      $('surface-hint').hidden = canon
+      // The map's own hint describes how the authored raster is made, which is not
+      // true of any other surface.
+      $('map-hint').hidden = !canon
+      this.map.setSurface(this.surface)
+      this.exterior?.setSurface(this.surface)
+      this.interior?.setSurface(this.surface)
+      if (redraw) this.render()
+    }
+    sel.onchange = () => {
+      this.surface = isSurface(sel.value) ? sel.value : 'canon'
+      try {
+        localStorage.setItem(SURFACE_KEY, this.surface)
+      } catch { /* private window: the choice just will not persist */ }
+      apply(true)
+    }
+    apply(false)
+  }
+
   private buildViews(): void {
     for (const b of document.querySelectorAll<HTMLButtonElement>('#views button')) {
       b.onclick = () => { void this.setView(b.dataset['view'] as ViewName) }
@@ -293,6 +338,7 @@ export class App {
       this.exterior.spinning = spin.checked
       this.exterior.timeScale = SPEEDS[Number(speed.value)]!
       this.exterior.showTerrain = $<HTMLSelectElement>('f-ext-view').value === 'xray'
+      this.exterior.setSurface(this.surface)
     }
     this.exterior.resize()
     this.exterior.setPlaces(this.store.places, this.selectedId)
@@ -319,6 +365,7 @@ export class App {
       $<HTMLSelectElement>('f-light').value = this.interior.lighting
       $<HTMLInputElement>('f-cycle').checked = this.interior.cycling
       $<HTMLSelectElement>('f-light').disabled = this.interior.cycling
+      this.interior.setSurface(this.surface)
     }
     this.interior.resize()
     this.interior.setPlaces(this.store.places.filter((p) => p.geometry.type !== 'band'))
@@ -693,15 +740,24 @@ export class App {
       b.classList.toggle('active', b.dataset['tool'] === this.tool)
     }
 
-    this.map.places = this.store.places
+    /**
+     * Places belong to Tower, not to the ground. On a non-canon surface the world
+     * list still shows them — it is the author's data, and hiding it would look
+     * like data loss — but nothing is drawn over the terrain, because a marker
+     * reading "The Capital" pinned to Puget Sound claims a correspondence that
+     * does not exist. Probes and sightlines stay: those measure the cylinder, and
+     * the cylinder is the same either way (D-055).
+     */
+    const placed = this.surface === 'canon' ? this.store.places : []
+    this.map.places = placed
     this.map.selectedId = this.selectedId
-    this.map.dragEnabled = this.tool === 'select'
+    this.map.dragEnabled = this.tool === 'select' && this.surface === 'canon'
     if (this.view === 'exterior') {
-      this.exterior?.setPlaces(this.store.places, this.selectedId)
+      this.exterior?.setPlaces(placed, this.selectedId)
       this.renderSpinReadout()
     }
     if (this.view === 'interior') {
-      this.interior?.setPlaces(this.store.places.filter((p) => p.geometry.type !== 'band'))
+      this.interior?.setPlaces(placed.filter((p) => p.geometry.type !== 'band'))
       this.renderStanding()
     }
     this.map.draw()
diff --git a/src/views/exterior.ts b/src/views/exterior.ts
index 5681b74..8144e55 100644
--- a/src/views/exterior.ts
+++ b/src/views/exterior.ts
@@ -24,6 +24,7 @@ import { type SurfacePoint, mapThetaOrigin, normalizeTheta } from '../canon/geom
 import type { Place } from '../world/schema.js'
 import { capGlazingTexture, capPatternTexture } from './cap-pattern.js'
 import { KIND_COLOR } from './palette.js'
+import { type Surface, rasterSet } from './raster.js'
 
 const KM = 1000
 const R = RADIUS_M / KM
@@ -57,6 +58,7 @@ export class ExteriorView {
   private hullMat: MeshStandardMaterial
   private innerMat!: MeshStandardMaterial
   private terrainMap: Texture | null = null
+  private surface: Surface = 'canon'
   private _showTerrain = true
   private raycaster = new Raycaster()
   private running = false
@@ -183,10 +185,23 @@ export class ExteriorView {
    * 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.
    */
+  /** Swap the ground painted onto the hull. See raster.js for what a surface is. */
+  setSurface(surface: Surface): void {
+    if (surface === this.surface) return
+    this.surface = surface
+    this.terrainMap?.dispose()
+    this.terrainMap = null
+    this.applyTerrain()
+    this.loadTerrain()
+  }
+
   private loadTerrain(): void {
+    const want = this.surface
     new TextureLoader().load(
-      `${import.meta.env.BASE_URL}terrain-color.png`,
+      `${import.meta.env.BASE_URL}${rasterSet(this.surface).color}`,
       (tex) => {
+        // The reader may have switched surfaces while this was in flight.
+        if (want !== this.surface) { tex.dispose(); return }
         tex.colorSpace = SRGBColorSpace
         tex.wrapS = RepeatWrapping
         tex.wrapT = RepeatWrapping
diff --git a/src/views/interior.ts b/src/views/interior.ts
index 6650c5e..2daf70a 100644
--- a/src/views/interior.ts
+++ b/src/views/interior.ts
@@ -37,8 +37,8 @@ import {
   MIRROR_UV_RADIUS, capGlazingTexture, sunCapGlazingTexture,
 } from './cap-pattern.js'
 import {
-  COLOR_RASTER, HEIGHT_RASTER, RASTER_HEIGHT, RASTER_HEIGHT_SPAN_M,
-  RASTER_MIN_HEIGHT_M, RASTER_WIDTH,
+  type Surface, RASTER_HEIGHT, RASTER_HEIGHT_SPAN_M,
+  RASTER_MIN_HEIGHT_M, RASTER_WIDTH, rasterSet,
 } from './raster.js'
 
 const KM = 1000
@@ -520,6 +520,7 @@ export class InteriorView {
   private camera: PerspectiveCamera
   private ground: Mesh
   private groundMat: ShaderMaterial
+  private surface: Surface = 'canon'
   private shared: ShaderMaterial[] = []
   private running = false
   private lastFrame = 0
@@ -644,6 +645,19 @@ export class InteriorView {
     return new Mesh(g, this.groundMat)
   }
 
+  /**
+   * Swap the ground you are standing on. See raster.js for what a surface is.
+   *
+   * Every surface shares the raster dimensions and the height window, so nothing
+   * here has to rebuild the shader or the mesh — only the two textures and the
+   * CPU-side height copy the camera stands on change.
+   */
+  setSurface(surface: Surface): void {
+    if (surface === this.surface) return
+    this.surface = surface
+    void this.loadRasters(this.groundMat)
+  }
+
   private async loadRasters(m: ShaderMaterial): Promise<void> {
     const load = (name: string): Promise<Texture> =>
       new Promise((resolve, reject) => {
@@ -657,7 +671,13 @@ export class InteriorView {
         }, undefined, reject)
       })
     try {
-      const [color, height] = await Promise.all([load(COLOR_RASTER), load(HEIGHT_RASTER)])
+      const want = this.surface
+      const set = rasterSet(want)
+      const [color, height] = await Promise.all([load(set.color), load(set.height)])
+      // The reader may have switched surfaces while these were in flight; the
+      // height raster in particular must never disagree with the one readHeights
+      // has already put on the CPU, or the camera stands on the wrong ground.
+      if (want !== this.surface) { color.dispose(); height.dispose(); return }
       m.uniforms['uColor']!.value = color
       m.uniforms['uHeight']!.value = height
       m.needsUpdate = true
diff --git a/src/views/map.ts b/src/views/map.ts
index 9215500..fae660f 100644
--- a/src/views/map.ts
+++ b/src/views/map.ts
@@ -17,6 +17,7 @@ import {
 } from '../canon/geometry.js'
 import { type Place, isAreaKind } from '../world/schema.js'
 import { KIND_COLOR } from './palette.js'
+import { type Surface, rasterSet } from './raster.js'
 
 export interface MapCallbacks {
   onPick: (p: SurfacePoint, hit: Place | null, shiftKey: boolean) => void
@@ -48,6 +49,7 @@ export class TowerMap {
   private terrain: HTMLImageElement | null = null
   showTerrain = true
 
+  private surface: Surface = 'canon'
   private dragging: { id: string } | null = null
   private panning: { x: number; y: number; ox: number; oy: number } | null = null
   private moved = false
@@ -68,11 +70,24 @@ export class TowerMap {
    */
   private loadTerrain(): void {
     const img = new Image()
+    const want = this.surface
     img.onload = () => {
+      // A slow load of the surface the reader has since switched away from must
+      // not paint over the one they are looking at.
+      if (want !== this.surface) return
       this.terrain = img
       this.draw()
     }
-    img.src = `${import.meta.env.BASE_URL}terrain-color.png`
+    img.src = `${import.meta.env.BASE_URL}${rasterSet(this.surface).color}`
+  }
+
+  /** Swap the ground under the map. See raster.ts for what a surface is. */
+  setSurface(surface: Surface): void {
+    if (surface === this.surface) return
+    this.surface = surface
+    this.terrain = null
+    this.draw()
+    this.loadTerrain()
   }
 
   // ------------------------------------------------------------------- transform
diff --git a/src/views/raster.ts b/src/views/raster.ts
index 609396c..9527454 100644
--- a/src/views/raster.ts
+++ b/src/views/raster.ts
@@ -22,3 +22,31 @@ export const RASTER_HEIGHT_SPAN_M = RASTER_MAX_HEIGHT_M - RASTER_MIN_HEIGHT_M
 
 export const COLOR_RASTER = 'terrain-color.png'
 export const HEIGHT_RASTER = 'terrain-height.png'
+
+/**
+ * Which surface the rasters describe.
+ *
+ * `canon` is Tower: authored geography, generated by `npm run terrain` from
+ * content/geography.json. `cascadia` is **not Tower** — it is the Pacific
+ * Northwest at 1:1, laid on the same cylinder because the ground from the Pacific
+ * surf east to the Columbia is 312.8 km and Tower's circumference is 314.2, so the
+ * real coast closes the ring on real terrain. It exists as a ruler, to answer "how
+ * big is this actually", and it carries no Tower geography whatsoever. See D-055.
+ */
+export type Surface = 'canon' | 'cascadia'
+
+export const isSurface = (v: unknown): v is Surface => v === 'canon' || v === 'cascadia'
+
+/**
+ * The two rasters for a surface.
+ *
+ * Every surface shares RASTER_WIDTH, RASTER_HEIGHT and the height window above —
+ * that is not a coincidence to be relied on quietly but a requirement, because the
+ * interior view bakes the window into its GLSL as literals and would otherwise
+ * need one shader per surface. test/raster.test.ts holds the generators to it.
+ */
+export function rasterSet(surface: Surface): { color: string; height: string } {
+  return surface === 'cascadia'
+    ? { color: 'cascadia-color.png', height: 'cascadia-height.png' }
+    : { color: COLOR_RASTER, height: HEIGHT_RASTER }
+}
diff --git a/static/cascadia-color.png b/static/cascadia-color.png
new file mode 100644
index 0000000..33f5a46
Binary files /dev/null and b/static/cascadia-color.png differ
diff --git a/static/cascadia-height.png b/static/cascadia-height.png
new file mode 100644
index 0000000..15e396a
Binary files /dev/null and b/static/cascadia-height.png differ
diff --git a/test/raster.test.ts b/test/raster.test.ts
index f12bafd..2ac290c 100644
--- a/test/raster.test.ts
+++ b/test/raster.test.ts
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs'
 import { describe, expect, it } from 'vitest'
 import {
   RASTER_HEIGHT, RASTER_MAX_HEIGHT_M, RASTER_MIN_HEIGHT_M, RASTER_WIDTH,
+  isSurface, rasterSet,
 } from '../src/views/raster.js'
 import { MAP_CUT_WITHOUT_SLATS } from '../src/canon/geometry.js'
 
@@ -48,3 +49,46 @@ describe('raster metadata matches the generator', () => {
     expect(geo.render.mapThetaOrigin).toBeCloseTo(MAP_CUT_WITHOUT_SLATS, 6)
   })
 })
+
+/**
+ * The Cascadia surface is a second pair of rasters on the same cylinder (D-055).
+ * It only works because it shares canon's grid and height window: the interior
+ * view bakes that window into its GLSL as literals, so a surface that quietly
+ * declared its own would displace its ground by the wrong scale — silently, which
+ * is D-024's failure mode exactly. tools/cascadia.mjs cannot import this module,
+ * so read the constants back out of it and hold it to them.
+ */
+describe('every surface shares one height window', () => {
+  const src = readFileSync('tools/cascadia.mjs', 'utf8')
+  const constant = (name: string): number => {
+    // The declarations carry trailing comments, so match the number, not the line.
+    const m = new RegExp(`^const ${name} = (-?\\d+)\\b`, 'm').exec(src)
+    if (!m) throw new Error(`tools/cascadia.mjs no longer declares ${name}`)
+    return Number(m[1])
+  }
+
+  it('cascadia borrows canon\u2019s range rather than declaring its own', () => {
+    expect(constant('HEIGHT_MIN_M')).toBe(RASTER_MIN_HEIGHT_M)
+    expect(constant('HEIGHT_MAX_M')).toBe(RASTER_MAX_HEIGHT_M)
+  })
+
+  it('cascadia is sampled onto canon\u2019s grid', () => {
+    expect(constant('GRID_W')).toBe(RASTER_WIDTH)
+    expect(constant('GRID_H')).toBe(RASTER_HEIGHT)
+  })
+
+  it('names a distinct pair of rasters per surface', () => {
+    expect(rasterSet('canon')).not.toEqual(rasterSet('cascadia'))
+    for (const s of ['canon', 'cascadia'] as const) {
+      expect(rasterSet(s).color).toMatch(/\.png$/)
+      expect(rasterSet(s).height).toMatch(/\.png$/)
+    }
+  })
+
+  it('rejects a surface name it does not know', () => {
+    expect(isSurface('canon')).toBe(true)
+    expect(isSurface('cascadia')).toBe(true)
+    expect(isSurface('wisconsin')).toBe(false)
+    expect(isSurface(null)).toBe(false)
+  })
+})
diff --git a/tools/cascadia.mjs b/tools/cascadia.mjs
new file mode 100644
index 0000000..47c679a
--- /dev/null
+++ b/tools/cascadia.mjs
@@ -0,0 +1,419 @@
+/**
+ * Cascadia ingest — the "real world, real size" mode. `npm run cascadia`
+ *
+ * Tower's inner surface is 540 km along the axis by 314.159 km around. Measured in
+ * UTM 10N, the ground from the Pacific surf at Kalaloch east to the Columbia River
+ * at Entiat is 312.8 km — 1.4 km short of the circumference. So western Washington
+ * closes the ring on real terrain: walk east from the beach, over the Olympics,
+ * across Puget Sound, over the Cascades, down the east flank to the Columbia, and
+ * you arrive back at the beach. Nothing is invented and nothing is rescaled. This
+ * is Tower at 1:1, and the point directly over Seattle is a place you can drive to.
+ *
+ * That is the whole reason this mode exists: it is a RULER, not canon. It contains
+ * no Tower geography at all. See the decision record before wiring it to anything.
+ *
+ * Writes, alongside the authored rasters that `npm run terrain` produces:
+ *   static/cascadia-height.png   16-bit greyscale, HEIGHT_MIN_M..HEIGHT_MAX_M
+ *   static/cascadia-color.png    8-bit RGB, hypsometric tint + hillshade
+ *
+ * Elevation comes from the AWS open terrain tiles (terrarium encoding), which are
+ * SRTM/3DEP over land and GEBCO below water, and which — unlike 3DEP alone — carry
+ * both bathymetry and the Canadian ground north of 49°N in one consistent grid.
+ */
+import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
+import { encodeGray16, encodeRGB, decodeRGB } from './png.mjs'
+
+// ------------------------------------------------------------------ the frame
+/**
+ * Tower has no north. The compass is sunward/deepward/spinward/antispinward
+ * (content/geography.json), so rotating a real region to fit the cylinder is not a
+ * distortion — there is nothing to distort against. ROT_DEG turns the sampling
+ * frame that many degrees west of north, which is how the strip is made to follow
+ * the Cascadia trend rather than a meridian. Kept at 0 until measured against real
+ * elevations; see the seam report this tool prints.
+ */
+const ROT_DEG = Number(process.env.ROT ?? 0)
+/** A real place pinned to a grid pixel. ROT_DEG rotates the frame about this. */
+const ANCHOR = { name: 'Seattle', lat: 47.6062, lon: -122.3321, x: 570, y: 582 }
+/**
+ * Where the ring is cut, as metres spinward (east) of the Kalaloch shoreline. The
+ * cut is the one place the world cannot be continuous, so it is worth choosing
+ * rather than inheriting: sliding it east lifts BOTH edges off the water and onto
+ * ground, which is what actually shrinks the step. See the seam report below.
+ */
+const CUT_SHIFT_M = Number(process.env.CUT ?? 0)
+/**
+ * Zero, after measuring. Sliding the cut 36 km east more than halves the step
+ * through the settled half (195-378 m against 369-844 m), but it selects a
+ * different 314 km of ground: the outer Pacific coast leaves the world, and with
+ * it the fact this mode exists for — that 124.42W and 120.24W are ONE point,
+ * 100 km straight over Seattle, reachable by walking either way round. Rotating
+ * the frame was tried too and does not help at any angle from 0 to 20 degrees:
+ * the mismatch is vertical, not alignment. So the seam stays where it is, an
+ * 800 m scarp running the length of the world exactly where the wet side meets
+ * the dry one, which is the truest thing about this coast.
+ */
+
+const GRID_W = 2048          // x is z, the axis. Sun cap at x = 0.
+const GRID_H = 1192          // y is theta, and it wraps.
+const LENGTH_M = 540_000
+const CIRCUMFERENCE_M = 2 * Math.PI * 50_000
+const MPP_X = LENGTH_M / GRID_W
+const MPP_Y = CIRCUMFERENCE_M / GRID_H
+
+/**
+ * The working range of cascadia-height.png.
+ *
+ * These are not chosen, they are BORROWED: they must equal RASTER_MIN_HEIGHT_M and
+ * RASTER_MAX_HEIGHT_M in src/views/raster.ts exactly. The interior view bakes that
+ * range into its GLSL as literals, so a mode with its own window would need a
+ * second shader; sharing canon's makes this mode a swap of two PNG filenames and
+ * nothing else. It costs nothing to share, because Cascadia happens to fit: the
+ * highest land here is Rainier at 4,352 m, under the 4,400 m ceiling.
+ *
+ * Deep ocean is clamped rather than carried. The abyssal plain is 2.6 km down and
+ * under water, so flattening it is invisible, while spending a third of the range
+ * on it would coarsen every hillside in the world to pay for it.
+ */
+const HEIGHT_MIN_M = -560
+const HEIGHT_MAX_M = 4400
+const SNOWLINE_M = 1900
+
+const ZOOM = 9               // ~206 m/px at 47.6°N, against the grid's 264 m/px
+const TILE = 256
+const CACHE = '.cache/terrain-tiles'
+const TILE_URL = (z, x, y) =>
+  `https://s3.amazonaws.com/elevation-tiles-prod/terrarium/${z}/${x}/${y}.png`
+
+// ------------------------------------------------------------------- progress
+const T0 = Date.now()
+const secs = (from) => `${((Date.now() - from) / 1000).toFixed(1)}s`
+const mb = (n) => `${(n / 1048576).toFixed(1)} MB`
+function line(indent, label, right = '') {
+  const pad = Math.max(1, 58 - indent * 2 - label.length - right.length)
+  console.log(' '.repeat(indent * 2) + label + ' '.repeat(pad) + right)
+}
+function step(indent, label, fn, extra = () => '') {
+  const t = Date.now()
+  const out = fn()
+  line(indent, label, `${extra(out)}${secs(t)}`.trim())
+  return out
+}
+
+// --------------------------------------------------------- WGS84 / UTM zone 10N
+// Snyder's series, good to a centimetre — far past what a 264 m grid can notice.
+const A = 6378137, F = 1 / 298.257223563, K0 = 0.9996, LON0 = (-123 * Math.PI) / 180
+const E2 = F * (2 - F), EP2 = E2 / (1 - E2)
+const M0 = 1 - E2 / 4 - (3 * E2 ** 2) / 64 - (5 * E2 ** 3) / 256
+
+function toUTM(latDeg, lonDeg) {
+  const p = (latDeg * Math.PI) / 180, l = (lonDeg * Math.PI) / 180
+  const sp = Math.sin(p), cp = Math.cos(p), tp = Math.tan(p)
+  const N = A / Math.sqrt(1 - E2 * sp * sp)
+  const T = tp * tp, C = EP2 * cp * cp, a = cp * (l - LON0)
+  const M = A * (M0 * p
+    - (3 * E2 / 8 + 3 * E2 ** 2 / 32 + 45 * E2 ** 3 / 1024) * Math.sin(2 * p)
+    + (15 * E2 ** 2 / 256 + 45 * E2 ** 3 / 1024) * Math.sin(4 * p)
+    - (35 * E2 ** 3 / 3072) * Math.sin(6 * p))
+  return [
+    K0 * N * (a + (1 - T + C) * a ** 3 / 6
+      + (5 - 18 * T + T * T + 72 * C - 58 * EP2) * a ** 5 / 120) + 500000,
+    K0 * (M + N * tp * (a * a / 2 + (5 - T + 9 * C + 4 * C * C) * a ** 4 / 24
+      + (61 - 58 * T + T * T + 600 * C - 330 * EP2) * a ** 6 / 720)),
+  ]
+}
+
+function fromUTM(e, n) {
+  const mu = n / K0 / (A * M0)
+  const e1 = (1 - Math.sqrt(1 - E2)) / (1 + Math.sqrt(1 - E2))
+  const p1 = mu + (3 * e1 / 2 - 27 * e1 ** 3 / 32) * Math.sin(2 * mu)
+    + (21 * e1 ** 2 / 16 - 55 * e1 ** 4 / 32) * Math.sin(4 * mu)
+    + (151 * e1 ** 3 / 96) * Math.sin(6 * mu)
+    + (1097 * e1 ** 4 / 512) * Math.sin(8 * mu)
+  const sp = Math.sin(p1), cp = Math.cos(p1), tp = Math.tan(p1)
+  const C1 = EP2 * cp * cp, T1 = tp * tp
+  const N1 = A / Math.sqrt(1 - E2 * sp * sp)
+  const R1 = (A * (1 - E2)) / (1 - E2 * sp * sp) ** 1.5
+  const D = (e - 500000) / (N1 * K0)
+  const lat = p1 - (N1 * tp / R1) * (D * D / 2
+    - (5 + 3 * T1 + 10 * C1 - 4 * C1 * C1 - 9 * EP2) * D ** 4 / 24
+    + (61 + 90 * T1 + 298 * C1 + 45 * T1 * T1 - 252 * EP2 - 3 * C1 * C1) * D ** 6 / 720)
+  const lon = LON0 + (D - (1 + 2 * T1 + C1) * D ** 3 / 6
+    + (5 - 2 * C1 + 28 * T1 - 3 * C1 * C1 + 8 * EP2 + 24 * T1 * T1) * D ** 5 / 120) / cp
+  return [(lat * 180) / Math.PI, (lon * 180) / Math.PI]
+}
+
+// ------------------------------------------------------------- grid <-> ground
+const BETA = (ROT_DEG * Math.PI) / 180
+// +z (deepward) points BETA west of north; +theta (spinward) is 90° clockwise of it.
+const ZE = -Math.sin(BETA), ZN = Math.cos(BETA)
+const YE = Math.cos(BETA), YN = Math.sin(BETA)
+const [AE, AN] = toUTM(ANCHOR.lat, ANCHOR.lon)
+// Grid origin: back off from the anchor along both axes to reach pixel (0,0).
+const OE = AE - (ANCHOR.x + 0.5) * MPP_X * ZE - ((ANCHOR.y + 0.5) * MPP_Y - CUT_SHIFT_M) * YE
+const ON = AN - (ANCHOR.x + 0.5) * MPP_X * ZN - ((ANCHOR.y + 0.5) * MPP_Y - CUT_SHIFT_M) * YN
+
+/** Grid pixel centre -> UTM 10N easting/northing. */
+const gridToUTM = (x, y) => {
+  const dz = (x + 0.5) * MPP_X, dy = (y + 0.5) * MPP_Y
+  return [OE + dz * ZE + dy * YE, ON + dz * ZN + dy * YN]
+}
+
+// ----------------------------------------------------------------- the tiles
+/**
+ * Web Mercator global pixel coordinates at ZOOM. Terrain tiles are square in
+ * Mercator, so the ground sample distance varies with latitude; at ZOOM 9 it runs
+ * 206 m at Seattle to 192 m at the far cap, comfortably finer than the 264 m grid.
+ */
+const lonToGX = (lon) => ((lon + 180) / 360) * TILE * 2 ** ZOOM
+const latToGY = (lat) => {
+  const s = Math.sin((lat * Math.PI) / 180)
+  return (0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)) * TILE * 2 ** ZOOM
+}
+
+async function fetchTile(tx, ty) {
+  const path = `${CACHE}/${ZOOM}-${tx}-${ty}.png`
+  if (existsSync(path)) return readFileSync(path)
+  for (let attempt = 1; ; attempt++) {
+    try {
+      const res = await fetch(TILE_URL(ZOOM, tx, ty))
+      if (!res.ok) throw new Error(`HTTP ${res.status}`)
+      const buf = Buffer.from(await res.arrayBuffer())
+      writeFileSync(path, buf)
+      return buf
+    } catch (err) {
+      if (attempt >= 4) throw new Error(`tile ${ZOOM}/${tx}/${ty}: ${err.message}`)
+      await new Promise((r) => setTimeout(r, 400 * attempt))
+    }
+  }
+}
+
+/** Fetch the tiles covering the strip and assemble one elevation field over them. */
+async function loadElevation() {
+  // Bounding box of the (possibly rotated) strip, walked around its edge.
+  let latMin = 90, latMax = -90, lonMin = 180, lonMax = -180
+  for (let i = 0; i <= 64; i++) {
+    for (const [x, y] of [
+      [(i / 64) * GRID_W, 0], [(i / 64) * GRID_W, GRID_H],
+      [0, (i / 64) * GRID_H], [GRID_W, (i / 64) * GRID_H],
+    ]) {
+      const [lat, lon] = fromUTM(...gridToUTM(x, y))
+      latMin = Math.min(latMin, lat); latMax = Math.max(latMax, lat)
+      lonMin = Math.min(lonMin, lon); lonMax = Math.max(lonMax, lon)
+    }
+  }
+  const tx0 = Math.floor(lonToGX(lonMin) / TILE), tx1 = Math.floor(lonToGX(lonMax) / TILE)
+  const ty0 = Math.floor(latToGY(latMax) / TILE), ty1 = Math.floor(latToGY(latMin) / TILE)
+  const cols = tx1 - tx0 + 1, rows = ty1 - ty0 + 1
+  line(1, 'extent',
+    `${latMin.toFixed(2)}..${latMax.toFixed(2)}N  ` +
+    `${(-lonMax).toFixed(2)}..${(-lonMin).toFixed(2)}W`)
+
+  const W = cols * TILE, H = rows * TILE
+  const field = new Float32Array(W * H)
+  const jobs = []
+  for (let ty = ty0; ty <= ty1; ty++) for (let tx = tx0; tx <= tx1; tx++) jobs.push([tx, ty])
+
+  let done = 0, fetched = 0
+  const t = Date.now()
+  // Eight at a time: enough to saturate the link, few enough to stay polite.
+  const workers = Array.from({ length: 8 }, async () => {
+    for (;;) {
+      const job = jobs.shift()
+      if (!job) return
+      const [tx, ty] = job
+      if (!existsSync(`${CACHE}/${ZOOM}-${tx}-${ty}.png`)) fetched++
+      const { data, channels } = decodeRGB(await fetchTile(tx, ty))
+      const ox = (tx - tx0) * TILE, oy = (ty - ty0) * TILE
+      for (let py = 0; py < TILE; py++) {
+        for (let px = 0; px < TILE; px++) {
+          const s = (py * TILE + px) * channels
+          // terrarium: (R * 256 + G + B / 256) - 32768 metres
+          field[(oy + py) * W + ox + px] =
+            data[s] * 256 + data[s + 1] + data[s + 2] / 256 - 32768
+        }
+      }
+      if (++done % 20 === 0) process.stdout.write(`\r    tiles ${done}/${cols * rows}`)
+    }
+  })
+  await Promise.all(workers)
+  process.stdout.write('\r' + ' '.repeat(30) + '\r')
+  line(1, 'tiles', `${cols}x${rows} at z${ZOOM}, ${fetched} fetched, ${secs(t)}`)
+  return { field, W, H, gx0: tx0 * TILE, gy0: ty0 * TILE }
+}
+
+/** Bilinear sample of the assembled field, in degrees. */
+function sampler(src) {
+  const { field, W, H, gx0, gy0 } = src
+  return (lat, lon) => {
+    const fx = lonToGX(lon) - gx0, fy = latToGY(lat) - gy0
+    const x0 = Math.max(0, Math.min(W - 2, Math.floor(fx)))
+    const y0 = Math.max(0, Math.min(H - 2, Math.floor(fy)))
+    const tx = Math.max(0, Math.min(1, fx - x0)), ty = Math.max(0, Math.min(1, fy - y0))
+    const i = y0 * W + x0
+    return (field[i] * (1 - tx) + field[i + 1] * tx) * (1 - ty)
+         + (field[i + W] * (1 - tx) + field[i + W + 1] * tx) * ty
+  }
+}
+
+// -------------------------------------------------------------------- colour
+/**
+ * Hypsometric tint for the Pacific Northwest. Sea is depth-shaded; land runs from
+ * the wet coastal green through the dry east-flank olive to rock and snow. The
+ * ramp is steeper below 800 m than a global one would be, because almost everyone
+ * in this world lives under 200 m and the Sound country needs the contrast.
+ */
+const LAND = [
+  [0, [72, 104, 66]], [120, [86, 118, 70]], [400, [104, 130, 76]],
+  [900, [126, 134, 84]], [1400, [140, 128, 92]], [1900, [138, 126, 114]],
+  [2600, [150, 148, 148]], [3400, [186, 190, 196]],
+]
+function tint(h) {
+  if (h <= LAND[0][0]) return LAND[0][1]
+  for (let i = 1; i < LAND.length; i++) {
+    if (h <= LAND[i][0]) {
+      const [a, ca] = LAND[i - 1], [b, cb] = LAND[i]
+      const t = (h - a) / (b - a)
+      return [0, 1, 2].map((k) => ca[k] + (cb[k] - ca[k]) * t)
+    }
+  }
+  return LAND[LAND.length - 1][1]
+}
+
+function colorise(height) {
+  const az = (315 * Math.PI) / 180, alt = (42 * Math.PI) / 180, strength = 0.55
+  const lx = Math.cos(alt) * Math.sin(az), ly = Math.cos(alt) * Math.cos(az)
+  const lz = Math.sin(alt)
+  const rgb = new Uint8Array(GRID_W * GRID_H * 3)
+  for (let y = 0; y < GRID_H; y++) {
+    for (let x = 0; x < GRID_W; x++) {
+      const i = y * GRID_W + x
+      const nx = (height[y * GRID_W + Math.max(0, x - 1)]
+                - height[y * GRID_W + Math.min(GRID_W - 1, x + 1)]) / (2 * MPP_X)
+      // theta wraps, so the shading does too — no seam in the hillshade.
+      const ny = (height[((y - 1 + GRID_H) % GRID_H) * GRID_W + x]
+                - height[((y + 1) % GRID_H) * GRID_W + x]) / (2 * MPP_Y)
+      const len = Math.hypot(nx, ny, 1)
+      let shade = (nx * lx + ny * ly + lz) / len
+      shade = 1 - strength + strength * Math.max(0, shade) * 1.35
+
+      const h = height[i]
+      let c
+      if (h <= 0) {
+        // Depth ramp tuned to the Sound and the Strait, not to the abyssal plain:
+        // most of this world's water is under 300 m and wants the whole gradient.
+        const d = Math.min(1, -h / 320)
+        c = [56 - 26 * d, 94 - 34 * d, 130 - 30 * d]
+        shade = 0.93 + 0.07 * shade
+      } else {
+        c = tint(h)
+        if (h > SNOWLINE_M) {
+          // Snow does not lie on cliffs; blending it out with slope is what turns a
+          // white blob into a mountain with rock showing through.
+          const slope = Math.hypot(nx, ny)
+          const holds = 1 - Math.min(1, Math.max(0, (slope - 0.5) / 0.5))
+          const t = Math.min(1, (h - SNOWLINE_M) / 700) * holds
+          c = c.map((v, k) => v + ([247, 249, 253][k] - v) * t)
+        }
+      }
+      rgb[i * 3] = Math.max(0, Math.min(255, c[0] * shade))
+      rgb[i * 3 + 1] = Math.max(0, Math.min(255, c[1] * shade))
+      rgb[i * 3 + 2] = Math.max(0, Math.min(255, c[2] * shade))
+    }
+  }
+  return rgb
+}
+
+// -------------------------------------------------------------------- process
+mkdirSync(CACHE, { recursive: true })
+console.log(`\nTower — Cascadia at 1:1, ${GRID_W}x${GRID_H} at ${MPP_X.toFixed(1)} m/px`)
+line(1, 'frame', `anchor ${ANCHOR.name}, rotation ${ROT_DEG}°, cut ${(CUT_SHIFT_M / 1000).toFixed(1)} km east`)
+
+const src = await loadElevation()
+const sample = sampler(src)
+
+const height = step(1, 'sample', () => {
+  const out = new Float32Array(GRID_W * GRID_H)
+  for (let y = 0; y < GRID_H; y++) {
+    for (let x = 0; x < GRID_W; x++) {
+      const [lat, lon] = fromUTM(...gridToUTM(x, y))
+      out[y * GRID_W + x] = sample(lat, lon)
+    }
+  }
+  return out
+})
+
+let lo = Infinity, hi = -Infinity, wet = 0
+for (const v of height) {
+  if (v < lo) lo = v
+  if (v > hi) hi = v
+  if (v <= 0) wet++
+}
+line(1, 'terrain', `${lo.toFixed(0)}..${hi.toFixed(0)} m, ` +
+  `${((1 - wet / height.length) * 100).toFixed(0)}% land`)
+
+/**
+ * The seam. theta wraps, so column 0 and column GRID_H-1 are neighbours on the
+ * cylinder even though they are 314 km apart on Earth. This mode's whole claim is
+ * that the ring closes on real ground, so the size of that step is the number that
+ * says whether the claim holds — print it rather than trusting the geometry.
+ */
+const seam = []
+for (let x = 0; x < GRID_W; x++) {
+  seam.push(Math.abs(height[x] - height[(GRID_H - 1) * GRID_W + x]))
+}
+const sorted = [...seam].sort((a, b) => a - b)
+line(1, 'seam step', `median ${sorted[GRID_W >> 1].toFixed(0)} m, ` +
+  `p90 ${sorted[Math.floor(GRID_W * 0.9)].toFixed(0)} m, max ${sorted[GRID_W - 1].toFixed(0)} m`)
+if (process.env.SEAM) {
+  // Where the cliff is, not just how big. A step that is small through the
+  // inhabited middle and large only in the far north is a different proposition
+  // from one that is uniformly bad, and the median alone cannot tell them apart.
+  for (let i = 0; i < 9; i++) {
+    const x = Math.min(GRID_W - 1, Math.round((i / 8) * GRID_W))
+    const [lat] = fromUTM(...gridToUTM(x, 0))
+    const band = seam.slice(Math.max(0, x - 64), x + 64)
+    const mean = band.reduce((a, b) => a + b, 0) / band.length
+    line(2, `${lat.toFixed(2)}N  z=${(x * MPP_X / 1000).toFixed(0)} km`,
+      `${'#'.repeat(Math.round(mean / 60))} ${mean.toFixed(0)} m`)
+  }
+}
+
+const rgb = step(1, 'colourise', () => colorise(height))
+
+const span = HEIGHT_MAX_M - HEIGHT_MIN_M
+const norm = new Float64Array(height.length)
+/**
+ * Clipping the seafloor is deliberate and clipping the land is a bug, so they are
+ * counted apart. terrain.mjs fails on any clipping at all because every metre of
+ * its terrain was authored on purpose (D-024); here the abyssal plain is 2.6 km
+ * down and under water, and flattening it costs nothing anybody can see, while
+ * carrying it would coarsen every hillside in the world to pay for it.
+ */
+let drowned = 0, overflowed = 0
+for (let i = 0; i < norm.length; i++) {
+  const v = (height[i] - HEIGHT_MIN_M) / span
+  if (v < 0) drowned++
+  if (v > 1) overflowed++
+  norm[i] = Math.min(1, Math.max(0, v))
+}
+line(1, 'height range',
+  `${HEIGHT_MIN_M}..${HEIGHT_MAX_M} m, ${(span / 255).toFixed(1)} m per 8-bit step`)
+if (drowned) line(1, 'seafloor clamped', `${drowned} px below ${HEIGHT_MIN_M} m (intended)`)
+if (overflowed) {
+  console.error(`\n  ! ${overflowed} pixel(s) of LAND above ${HEIGHT_MAX_M} m were ` +
+    `flattened; terrain reaches ${hi.toFixed(0)} m. Raise HEIGHT_MAX_M.\n`)
+  process.exitCode = 1
+}
+
+line(1, 'encode')
+const write = (name, make) =>
+  step(2, name, () => {
+    const buf = make()
+    writeFileSync(`static/${name}`, buf)
+    return buf
+  }, (b) => `${mb(b.length).padEnd(10)}`)
+write('cascadia-height.png', () => encodeGray16(GRID_W, GRID_H, norm))
+write('cascadia-color.png', () => encodeRGB(GRID_W, GRID_H, rgb))
+
+console.log(`\ndone in ${secs(T0)}\n`)
diff --git a/tools/png.mjs b/tools/png.mjs
index b4af6e8..c3776c9 100644
--- a/tools/png.mjs
+++ b/tools/png.mjs
@@ -5,7 +5,7 @@
  * would quantise a 5,000 m range into 20 m steps) and 8-bit RGB (the colour map).
  * Scanlines use the Up filter, which suits smooth terrain and costs three lines.
  */
-import { deflateSync } from 'node:zlib'
+import { deflateSync, inflateSync } from 'node:zlib'
 
 const CRC_TABLE = (() => {
   const t = new Int32Array(256)
@@ -85,3 +85,60 @@ export function encodeGray16(width, height, values, level = 9) {
 export function encodeRGB(width, height, rgb, level = 6) {
   return encode(width, height, 2, 8, Buffer.from(rgb.buffer, rgb.byteOffset, rgb.length), 3, level)
 }
+
+/**
+ * Decode an 8-bit PNG to interleaved RGB. The inverse of the above, and only as
+ * general as tools/cascadia.mjs needs: colour types 0, 2 and 6 at bit depth 8,
+ * which is what every elevation tile service emits. Interlaced files are rejected
+ * rather than mishandled, because a silently wrong height field is D-024's failure
+ * mode — it displaces terrain instead of breaking it.
+ */
+export function decodeRGB(buf) {
+  if (buf.readUInt32BE(0) !== 0x89504e47) throw new Error('not a PNG')
+  let width = 0, height = 0, colorType = 0, bitDepth = 0
+  const idat = []
+  for (let p = 8; p < buf.length;) {
+    const len = buf.readUInt32BE(p)
+    const type = buf.toString('ascii', p + 4, p + 8)
+    const data = buf.subarray(p + 8, p + 8 + len)
+    if (type === 'IHDR') {
+      width = data.readUInt32BE(0)
+      height = data.readUInt32BE(4)
+      bitDepth = data[8]
+      colorType = data[9]
+      if (data[12] !== 0) throw new Error('interlaced PNG unsupported')
+    } else if (type === 'IDAT') idat.push(data)
+    else if (type === 'IEND') break
+    p += len + 12
+  }
+  if (bitDepth !== 8) throw new Error(`bit depth ${bitDepth} unsupported`)
+  const channels = { 0: 1, 2: 3, 6: 4 }[colorType]
+  if (!channels) throw new Error(`colour type ${colorType} unsupported`)
+
+  const raw = inflateSync(Buffer.concat(idat))
+  const stride = width * channels
+  const out = Buffer.alloc(stride * height)
+  // Undo the per-scanline filter. Paeth is the only one that needs a helper.
+  for (let y = 0; y < height; y++) {
+    const filter = raw[y * (stride + 1)]
+    const src = y * (stride + 1) + 1
+    const dst = y * stride
+    const up = (y - 1) * stride
+    for (let x = 0; x < stride; x++) {
+      const a = x >= channels ? out[dst + x - channels] : 0
+      const b = y > 0 ? out[up + x] : 0
+      const c = x >= channels && y > 0 ? out[up + x - channels] : 0
+      let v = raw[src + x]
+      if (filter === 1) v += a
+      else if (filter === 2) v += b
+      else if (filter === 3) v += (a + b) >> 1
+      else if (filter === 4) {
+        const q = a + b - c
+        const pa = Math.abs(q - a), pb = Math.abs(q - b), pc = Math.abs(q - c)
+        v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c
+      }
+      out[dst + x] = v & 0xff
+    }
+  }
+  return { width, height, channels, data: out }
+}