tower / commit
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 } +}