tower / commit
feat(world): generate the world live from features, and drop the rasters
README.md | 117 +- content/geography.json | 930 --------------- content/world.json | 890 +++++++++++++++ decisions.md | 88 ++ design.md | 136 ++- docs/screenshots/desktop-exterior-true.png | Bin 506132 -> 524368 bytes docs/screenshots/desktop-exterior.png | Bin 332632 -> 539835 bytes docs/screenshots/desktop-interior-dawn.png | Bin 488592 -> 469376 bytes docs/screenshots/desktop-interior-night.png | Bin 283968 -> 305425 bytes docs/screenshots/desktop-interior-overhead.png | Bin 545253 -> 510622 bytes docs/screenshots/desktop-interior-suncap.png | Bin 399761 -> 389055 bytes docs/screenshots/desktop-interior.png | Bin 545905 -> 414916 bytes docs/screenshots/desktop-map-sightline.png | Bin 791611 -> 928873 bytes docs/screenshots/desktop-map.png | Bin 711911 -> 901075 bytes docs/screenshots/desktop-reader.png | Bin 699893 -> 885047 bytes docs/screenshots/mobile-exterior.png | Bin 81610 -> 121671 bytes docs/screenshots/mobile-interior.png | Bin 172435 -> 120338 bytes docs/screenshots/mobile-map.png | Bin 190936 -> 239517 bytes docs/screenshots/mobile-panel.png | Bin 76386 -> 81032 bytes docs/screenshots/overview.png | Bin 1156398 -> 1211003 bytes index.html | 21 +- package-lock.json | 14 + package.json | 3 +- src/style.css | 15 + src/terrain/compile.ts | 465 ++++++++ src/terrain/field.ts | 292 +++++ src/terrain/map-image.ts | 93 ++ src/terrain/noise.ts | 115 ++ src/terrain/palette.ts | 97 ++ src/terrain/service.ts | 200 ++++ src/terrain/shapes.ts | 211 ++++ src/terrain/tile.ts | 239 ++++ src/terrain/worker.ts | 43 + src/ui/app.ts | 196 +++- src/ui/scales.ts | 4 +- src/views/exterior.ts | 45 +- src/views/interior-model.ts | 241 ++-- src/views/interior.ts | 239 ++-- src/views/map.ts | 65 +- src/views/palette.ts | 15 +- src/views/raster.ts | 24 - src/views/terrain-lod.ts | 292 +++++ src/world/schema.ts | 389 ++++++- src/world/store.ts | 67 +- static/terrain-color.png | Bin 1933009 -> 0 bytes static/terrain-corridor.png | Bin 1529037 -> 0 bytes static/terrain-height.png | Bin 3241370 -> 0 bytes static/world.json | 1459 +++++++++++------------- test/canon.test.ts | 9 +- test/raster.test.ts | 50 - test/terrain.test.ts | 174 +++ test/world.test.ts | 226 ++-- tools/noise.mjs | 68 -- tools/png.mjs | 87 -- tools/port-world.mjs | 116 ++ tools/screenshots.mjs | 28 +- tools/terrain.mjs | 770 ------------- world-generation.md | 1057 +++++++++++++++++ 58 files changed, 6231 insertions(+), 3359 deletions(-) diff --git a/README.md b/README.md index f1bf538..8a2c524 100644 --- a/README.md +++ b/README.md @@ -27,14 +27,24 @@ nothing passive would stay in the beam past mid-afternoon. It is a person. ## Status -**All three views are built** — map, exterior and interior — over an authored -geography and a working writer mode. - -Built: the unrolled surface map, Tower from outside, standing inside it, place -editing for thirteen kinds (points, lines, polygons and bands), localStorage -autosave, backup and static exports, import, permalinks, and the sightline probe. +**All three views are built** — map, exterior and interior — over a world that +**generates itself live, in the browser**, from a file of authored features, and a +working writer mode. + +Built: the unrolled surface map, Tower from outside, standing inside it and flying +over it, feature editing for fourteen kinds (points, lines, polygons and bands), +localStorage autosave, backup and static exports, import, permalinks, and the +sightline probe. + +**In progress: [world-generation.md](world-generation.md).** Phase 1 is built — +the terrain is no longer baked into images by a build step; every mountain, lake, +sea, river, desert and forest is a feature the browser turns into ground on demand, +in web workers, from a metre underfoot to the whole world. Phases 2–8 add eroded +mountains, water with waves and flow, ships, deserts, forests of individual trees, +settlements and roads. - [design.md](design.md) — canon, physics and application architecture +- [world-generation.md](world-generation.md) — how the world is generated, and the plan - [decisions.md](decisions.md) — choices made and why Both are series-bible material: coherence outranks spectacle, and the prose is @@ -55,9 +65,11 @@ and writes [`docs/screenshots/`](docs/screenshots/). Taking them *through the UI the point: a shot taken this way fails when the UI fails, so a black canvas ends up in the picture instead of hiding behind a passing unit test. -The whole tour takes about **35 seconds**. It used to take six and a half minutes, -nearly all of it spent rendering Tower's haze integral on the CPU — the default -headless browser has no GPU. It now asks for the full Chrome build, which reaches +The whole tour takes about **a minute**, and waits on the world being generated +rather than on fixed delays: a shot is taken once the terrain has compiled, the map +is drawn, and every tile of ground the view wants has arrived. It once took six and a +half minutes, nearly all of them spent rendering Tower's haze integral on the CPU — +the default headless browser has no GPU. It now asks for the full Chrome build, which reaches the real one, and photographs four shots at a time, since a shot is mostly spent waiting. The cost is that the committed images are tied to the GPU that drew them: another machine will show a large one-off divergence rather than a regression. @@ -71,7 +83,7 @@ another machine will show a large one-off divergence rather than a regression. | [interior](docs/screenshots/desktop-interior.png) | standing at The Narrows, the ground curving up on both sides | | [interior-overhead](docs/screenshots/desktop-interior-overhead.png) | looking up: the far side 100 km overhead is clearer than ground 100 km away | | [interior-dawn](docs/screenshots/desktop-interior-dawn.png) | dawn — a lit sky over dark ground, the light still 27 km away | -| [reader](docs/screenshots/desktop-reader.png) | `?reader=1` — terrain, places, a detail card and the probe, no editing | +| [reader](docs/screenshots/desktop-reader.png) | `?reader=1` — terrain, features, a detail card and the probe, no editing | | Mobile — 390×844 | | |---|---| @@ -113,9 +125,8 @@ regressions, not things that were already broken. ``` npm install npm run dev # http://localhost:5173, plus a LAN URL and a QR code for it -npm test # 100 tests; canon figures are pinned to design.md +npm test # 120 tests; canon figures are pinned to design.md npm run build -npm run terrain # regenerate the maps from content/geography.json npm run screenshots # drive the app and rephotograph docs/screenshots/ ``` @@ -126,7 +137,8 @@ network while it runs; drop the `--host` if that is not wanted. Vite + TypeScript, deployed to GitLab Pages by `.gitlab-ci.yml` on every push to the default branch. Three.js is loaded only when a 3D view is opened, so a reader who -stays on the map never downloads it. +stays on the map never downloads it. There is no terrain build step: the world is +generated in the browser from `static/world.json` (world-generation.md §5). Assets are referenced **relatively**, so the same build works whether Pages serves it from `https://pavelsafronov.gitlab.io/tower/` or from a generated unique domain @@ -154,10 +166,12 @@ measures how colourful it is: `19` is the clear colour, so it means that surface put nothing in the middle of the view — expected for anything currently off screen. The ground reads about `30` when it is carrying land. **A ground near `19` while you are looking at the ground is the -diagnosis**: the terrain shader is not getting its raster. The rest of the dump carries -the camera, the sampler settings for both rasters, whether their GL textures are still -live, and the per-frame trace, so the heading the view changed at can be read off -afterwards. `window.tower` is the view itself, for poking at directly. +diagnosis**: the ground's tiles are missing or its shader is failing. The dump's +`terrain` entry says which: the world generation the view is drawing, how many tiles are +on screen, whether every tile it wants has arrived, and how busy the worker pool is. The +rest carries the camera and the per-frame trace, with the tile count in every frame, so +the heading the view changed at can be read off afterwards. `window.tower` is the view +itself, and `window.towerTerrain` the terrain service, for poking at directly. This is on in the built site as well as in dev, deliberately: the fault has only ever turned up in ordinary use, and it costs two properties on `window` that do nothing @@ -165,32 +179,30 @@ until they are called. ## Geography -[`content/geography.json`](content/geography.json) is the authored source of truth -for terrain — coordinates in canon (θ, z), elevations in metres relative to the base -shell, negative for anything cut below it. `render.minHeightM` / `render.maxHeightM` -declare the range the height raster is stretched across; the generator prints the -range the terrain actually occupies and warns if it has outgrown the declaration. `npm run terrain` renders it, with no dependencies beyond Node: +[`content/world.json`](content/world.json) is the world: every feature — mountains, +lakes, seas, rivers, desert, forest, settlements and plain markers — with its shape in +canon coordinates (θ, z) and its parameters beside it, plus the author's notes. The +browser generates the terrain from it live (world-generation.md, decisions.md D-062); +there is no build step and there are no terrain images. +[`static/world.json`](static/world.json) is the same world as readers get it, with every +note stripped — the **Export static map content** button writes it. -| | | -|---|---| -| `static/terrain-height.png` | 16-bit greyscale, across the declared `minHeightM`–`maxHeightM` | -| `static/terrain-color.png` | the whole world, 264 m/px | -| `static/terrain-corridor.png` | the developed corridor, 122 m/px | -| `static/world.json` | seed places for the app | +Generation is deterministic. The seeds are in the file, so every reader sees the same +world the author does, to the tree. -It reports each stage as it goes — every relief op, colourisation, and PNG encode — -and takes about 6 seconds. Flags: +When the world loads, and after any edit that changes the ground, it is compiled in a +worker — rivers given their long profiles, lake levels found, summits measured. If that +takes longer than 400 ms a dialog says what Tower is making, one step at a time. A +rename, a blurb or a note changes nothing on the ground and regenerates nothing. -| | | -|---|---| -| `--probe <theta>` | print elevation jumps along a row, for checking river gradients and finding discontinuities | -| `--quiet` | one line per stage instead of one per feature | -| `--force` | let the seed overwrite places added in the app | +The world was ported from v1's `content/geography.json` by +[`tools/port-world.mjs`](tools/port-world.mjs), kept for reading; there is no +backwards compatibility with v1 files. ## Reading and writing The map opens in **writer mode**: tools, editor, canon controls and import/export. -Add **`?reader=1`** to preview what a reader gets — the terrain, the places, a +Add **`?reader=1`** to preview what a reader gets — the terrain, the features, a detail card and the sightline probe, with no editing. Writer is the default while the world is still being built; that is a one-line change when it is ready to publish. Either way it is a UI mode, not a security boundary — with no backend an @@ -221,9 +233,10 @@ useful precisely because the wall is opaque. The active view is remembered and t 1x to 1200x real time, and a point picked off the turning hull still comes back in canon coordinates. -**Interior** is standing inside. The shell is the real cylinder, displaced by the -height raster and painted with the colour one, so the ground curves up on both sides -and closes overhead. Haze is integrated through Tower's rotational air column for +**Interior** is standing inside. The ground is the real cylinder, built from +generated tiles that get finer around you — half a metre between vertices underfoot, +half a kilometre on the far side of the world — so it curves up on both sides and +closes overhead. Haze is integrated through Tower's rotational air column for every pixel, which reproduces the strangest fact about the place: the land 100 km straight up is **78%** clear while the ground 100 km away is **31%** — distant geography here is best seen by looking up. @@ -239,6 +252,11 @@ caption — still **walks you there**, which is the only way to cross the world one move: most of it is beyond the haze horizon from any given spot, so travelling is more use than turning. +**Or fly.** W A S D or the arrow keys move you, E climbs and Q descends, and shift +goes five times faster. Speed follows your height above the ground: a brisk walk +at standing height, and a sea crossed in seconds from ten kilometres up, with nothing +to set in between. + **The far cap is a window** — 100 km of it, looking into deep anti-sun space. By day it is simply a disc of blue, because 540 km of air in front of it scatters more light than anything behind it sends; the stars are there at noon and unseeable, @@ -273,7 +291,7 @@ to a thousandth instead of killing it, which is what night here has to be: the b glows by itself, so a lit beam lights the world whatever the mirrors are doing, and aiming them away would leave the floor at about 100 W/m² — half of noon. -The eye-height slider runs from 1.7 m to 25 km with the local gravity beside it, and +The eye-height slider runs from 1.7 m to 45 km with the local gravity beside it, and **Rain** leans 1.5° antispinward. Nothing *inside* rotates, because you, the ground and the air all turn together — so for 539 of the 540 km that 1.5° is the only evidence Tower turns at all. The exception is the window: the sky out there does not @@ -284,19 +302,24 @@ has ever had anything in it but Tower. ## Working in the map -Pick a tool, click to place; **Lake**, **Forest**, **Desert** and **Region** trace a -polygon and **River** and **Canyon** trace an open line (Enter finishes, Esc -cancels). Drag markers to move them, **shift-click two points** to -measure a sightline, **f** to fit the view. +Open **Add**, pick a kind, and click to place it. **Mountains**, **Lake**, **Sea**, +**Desert**, **Forest** and **Region** trace an outline; **River**, **Road** and +**Path** trace an open line — a river source first (Enter finishes, Esc cancels). +The rest are a single click. The ground regenerates as soon as the feature exists. +Drag markers to move them, **shift-click two points** to measure a sightline, **f** +to fit the view. + +Changing a feature's **Kind** swaps its parameters for the new kind's defaults and +keeps its name, notes, shape and seed; only kinds that can take its shape are offered. Canon is **0 star slats** — the hull is sealed, the inner surface is one unbroken 169,646 km², and the only views out are at the end caps. The runtime control for -this is disabled and hidden (decisions.md D-033): the terrain raster is baked at 0 -slats, so any other count draws bands over land that has none. +this is disabled and hidden (decisions.md D-033): the world is generated as one +unbroken surface, so any other count draws bands over land that has none. Everything autosaves to this browser's localStorage. Two exports: | | | |---|---| | **Export backup** | everything, private notes included. Keep out of the public repo — `.gitignore` already excludes `tower-backup-*.json`. | -| **Export static map content** | public places only, `notes` stripped. This is `static/world.json`, the world readers see, and the seed for a browser with no local draft. | +| **Export static map content** | public features only, every `notes` stripped. This is `static/world.json`, the world readers see, and the seed for a browser with no local draft. | diff --git a/content/geography.json b/content/geography.json deleted file mode 100644 index d176496..0000000 --- a/content/geography.json +++ /dev/null @@ -1,930 +0,0 @@ -{ - "version": 1, - "$comment": [ - "Authored geography for Tower. This file is the SOURCE OF TRUTH for terrain.", - "`npm run terrain` reads it and generates the height map, the colour map, and a", - "seed world.json of places for the app.", - "", - "Coordinates are canon (design.md §1.7): theta in radians around the cylinder,", - "z in metres from the sun cap. Elevation is metres ABOVE the base shell radius —", - "i.e. inward, toward the axis — because in Tower 'up' means closer to the spin", - "axis. All relief is engineered; a bare cylinder is an equipotential with no", - "slope anywhere (design.md §1.9.3).", - "", - "All names are PROVISIONAL. Every feature carries \"provisionalName\": true until", - "the author replaces it." - ], - "datum": { - "radiusM": 50000, - "lengthM": 540000, - "circumferenceM": 314159.27, - "primeMeridian": "theta = 0 runs through the capital" - }, - "compass": { - "sunward": "-z, toward the sun cap", - "deepward": "+z, toward the far cap", - "spinward": "+theta, the direction of rotation", - "antispinward": "-theta — everything that falls leans this way (§1.9.2), so a waterfall is a compass" - }, - "render": { - "gridWidth": 2048, - "gridHeight": 1192, - "metresPerPixel": 263.7, - "minHeightM": -560, - "maxHeightM": 4400, - "$heightRangeNote": "The working range of terrain-height.png, not a limit on the world. Browsers decode a 16-bit PNG to 8 bits, so the app reads 256 levels across this span — keeping it tight to the terrain that exists is what makes the interior view's ground smooth. Below zero is real: lake and sea floors are cut beneath the datum.", - "mapThetaOrigin": 3.14159265, - "$mapThetaOriginNote": "Where the unrolled map is cut. With no slats there is no empty theta to hide the seam in (D-013), so the cut goes opposite the capital — the antimeridian principle, applied to habitation instead of geology.", - "snowlineM": 3000, - "hillshade": { - "azimuthDeg": 315, - "altitudeDeg": 42, - "strength": 0.55 - }, - "corridor": { - "$comment": "A close view of the developed region. At world scale the canyon is six pixels wide.", - "width": 2048, - "height": 1020, - "zFrom": 40000, - "zTo": 305000, - "thetaFrom": -0.6, - "thetaTo": 0.72 - } - }, - "hydrology": { - "$comment": "The loop of D-018. Sized so the catchment supports the flow at a plausible rainfall.", - "meanFlowM3S": 392, - "catchmentKM2": 9800, - "impliedPrecipitationMMYr": 1262, - "sourceElevationM": 1900, - "mouthElevationM": 160, - "liftHeadM": 1780, - "pumpPowerW": 6850000000, - "pumpPowerPercentOfBudget": 0.0126, - "closure": "made-weather", - "closureNote": "Tower evaporates The Deep and snows it onto the range; the tarns are the top of the loop. Four rivers descend, three are defeated by the ridges and gather along their front, and all of it leaves through The Narrows as one river.", - "trunk": "the-long-water", - "tributaries": [ - "westwall-water", - "sisters-water", - "eastwall-water" - ] - }, - "features": [ - { - "id": "the-plain", - "name": "The Open Ground", - "provisionalName": true, - "kind": "region", - "biome": "plain", - "geometry": { - "type": "whole-surface" - }, - "relief": { - "op": "base", - "baseM": 60, - "noiseAmpM": 260, - "noiseScaleM": 78000, - "octaves": 5, - "detailAmpM": 45, - "detailScaleM": 12000, - "microAmpM": 26, - "microScaleM": 1500, - "$microNote": "The octave the raster can just about hold: 1.5 km features at 264 m per pixel. Below this the PNG cannot carry it and the interior view has to invent it." - }, - "notes": "Everything outside the developed corridor. Deliberately gentle — this is shaped ground that nobody has bothered to make dramatic." - }, - { - "id": "sunward-sea", - "name": "The Sunward Sea", - "provisionalName": true, - "kind": "lake", - "biome": "water", - "geometry": { - "type": "band", - "ring": true, - "zFrom": 0, - "zTo": 42000 - }, - "waterLevelM": 90, - "relief": { - "op": "flood", - "depthM": 420, - "edgeM": 16000, - "warpM": 6000 - }, - "notes": "At the foot of the sun cap. Gravity on a cap is tangent to it, so water cannot rest there — it runs to the rim and pools. This ocean is not designed; it is what happens." - }, - { - "id": "deepward-sea", - "name": "The Deepward Sea", - "provisionalName": true, - "kind": "lake", - "biome": "water", - "geometry": { - "type": "band", - "ring": true, - "zFrom": 496000, - "zTo": 540000 - }, - "waterLevelM": 90, - "relief": { - "op": "flood", - "depthM": 420, - "edgeM": 16000, - "warpM": 6000 - }, - "notes": "The same at the far cap, and the larger of the two. A 314 km shoreline with a 50 km cliff behind it, and the only windows in the world at its foot (§1.4)." - }, - { - "id": "sun-cap", - "name": "The Sun Cap", - "provisionalName": true, - "kind": "landmark", - "geometry": { - "type": "point", - "theta": 0, - "z": 0 - }, - "notes": "The sunward end of the world, and one of only two views out (§1.4). A cap is a wall, not a floor: gravity there points radially outward, tangent to its surface, so nothing rests on it. Fifty kilometres of vertical cliff with its windows at the base, and the beam entering through a 3.2 km aperture at its centre." - }, - { - "id": "far-cap", - "name": "The Far Cap", - "provisionalName": true, - "kind": "landmark", - "geometry": { - "type": "point", - "theta": 0, - "z": 540000 - }, - "notes": "The deepward end, 540 km away, standing over The Deepward Sea. Its windows look along the spin axis — which is the celestial pole — so the stars do not sweep past but turn about a fixed centre, one revolution every 7 min 29 s. The only moving sky anyone in Tower ever sees." - }, - { - "id": "snowcrown", - "name": "Snowcrown", - "provisionalName": true, - "kind": "mountain", - "biome": "alpine", - "geometry": { - "type": "point", - "theta": 0.3, - "z": 88000 - }, - "elevationM": 4300, - "relief": { - "op": "dome", - "radiusThetaM": 30000, - "radiusZM": 33000, - "heightM": 4300, - "falloff": 1.7, - "ridgedAmp": 0.55, - "ridgedScaleM": 15000, - "warpM": 9000 - } - }, - { - "id": "the-sisters", - "name": "The Sisters", - "provisionalName": true, - "kind": "mountain", - "biome": "alpine", - "geometry": { - "type": "point", - "theta": -0.52, - "z": 94000 - }, - "elevationM": 3650, - "relief": { - "op": "dome", - "radiusThetaM": 26000, - "radiusZM": 28000, - "heightM": 3650, - "falloff": 1.7, - "ridgedAmp": 0.55, - "ridgedScaleM": 15000, - "warpM": 9000 - } - }, - { - "id": "westwall", - "name": "Westwall", - "provisionalName": true, - "kind": "mountain", - "biome": "alpine", - "geometry": { - "type": "point", - "theta": -1.1, - "z": 86000 - }, - "elevationM": 3400, - "relief": { - "op": "dome", - "radiusThetaM": 24000, - "radiusZM": 26000, - "heightM": 3400, - "falloff": 1.7, - "ridgedAmp": 0.55, - "ridgedScaleM": 15000, - "warpM": 9000 - } - }, - { - "id": "eastwall", - "name": "Eastwall", - "provisionalName": true, - "kind": "mountain", - "biome": "alpine", - "geometry": { - "type": "point", - "theta": 0.98, - "z": 92000 - }, - "elevationM": 3550, - "relief": { - "op": "dome", - "radiusThetaM": 25000, - "radiusZM": 27000, - "heightM": 3550, - "falloff": 1.7, - "ridgedAmp": 0.55, - "ridgedScaleM": 15000, - "warpM": 9000 - } - }, - { - "id": "the-girdle", - "name": "The Girdle", - "provisionalName": true, - "kind": "region", - "biome": "alpine", - "geometry": { - "type": "band", - "ring": true, - "z": 90000 - }, - "relief": { - "op": "ridge", - "blend": "max", - "heightM": 2900, - "widthZM": 17000, - "warpM": 12000, - "ridgedAmp": 0.6, - "ridgedScaleM": 14000 - }, - "notes": "The range does not stop at Eastwall. It carries on round the far side of the world and closes on itself — a ring of peaks at z 90 km, unbroken except where the rivers cut it. Snowcrown, The Sisters, Westwall and Eastwall are the named high points of it, not a range of their own; the rest is unwalked. Rings the world 86 km sunward of the three low ridges, and is the reason every river on this half of Tower runs deepward." - }, - { - "id": "hollow-tarn", - "name": "Hollow Tarn", - "provisionalName": true, - "kind": "lake", - "biome": "water", - "geometry": { - "type": "ellipse", - "theta": 0.22, - "z": 100000, - "radiusThetaM": 2000, - "radiusZM": 2400 - }, - "relief": { - "op": "tarn", - "depthM": 190, - "rimM": 120, - "fillFraction": 0.6 - }, - "notes": "The Long Water's source and the top of the world's water loop. Every litre in Tower passes through here." - }, - { - "id": "sisters-tarn", - "name": "Sisters Tarn", - "provisionalName": true, - "kind": "lake", - "biome": "water", - "geometry": { - "type": "ellipse", - "theta": -0.47, - "z": 104000, - "radiusThetaM": 2000, - "radiusZM": 2400 - }, - "relief": { - "op": "tarn", - "depthM": 190, - "rimM": 120, - "fillFraction": 0.6 - }, - "notes": "Largest of the high lakes. A ski settlement stands on its deepward shore." - }, - { - "id": "west-tarn", - "name": "Cold Tarn", - "provisionalName": true, - "kind": "lake", - "biome": "water", - "geometry": { - "type": "ellipse", - "theta": -1.05, - "z": 98000, - "radiusThetaM": 2000, - "radiusZM": 2400 - }, - "relief": { - "op": "tarn", - "depthM": 190, - "rimM": 120, - "fillFraction": 0.6 - } - }, - { - "id": "east-tarn", - "name": "Long Tarn", - "provisionalName": true, - "kind": "lake", - "biome": "water", - "geometry": { - "type": "ellipse", - "theta": 0.94, - "z": 103000, - "radiusThetaM": 2000, - "radiusZM": 2400 - }, - "relief": { - "op": "tarn", - "depthM": 190, - "rimM": 120, - "fillFraction": 0.6 - } - }, - { - "id": "the-dry-reach", - "name": "The Dry Reach", - "provisionalName": true, - "kind": "desert", - "biome": "desert", - "geometry": { - "type": "band", - "ring": true, - "zFrom": 148000, - "zTo": 218000 - }, - "relief": { - "op": "plateau", - "heightM": 780, - "edgeM": 20000, - "noiseAmpM": 90, - "noiseScaleM": 9000, - "warpM": 13000, - "warpScaleM": 40000 - }, - "notes": "Rings the world. On a cylinder you cannot go around a band — a ringed desert is an absolute barrier, which no Earth desert ever is. 70 km deep, 22,000 km², 13% of the world. Dry because Tower withholds rain here, not because terrain shades it." - }, - { - "id": "first-ridge", - "name": "First Ridge", - "provisionalName": true, - "kind": "region", - "biome": "desert", - "geometry": { - "type": "band", - "ring": true, - "z": 176000 - }, - "relief": { - "op": "ridge", - "heightM": 260, - "widthZM": 7000, - "warpM": 5000 - }, - "notes": "Rings the world with its two siblings: three concentric walls across Tower's waist. Every drainage off the range is turned by them. Only one river was here before they were raised and only that one still cuts through — The Narrows is the single breach in all three." - }, - { - "id": "second-ridge", - "name": "Second Ridge", - "provisionalName": true, - "kind": "region", - "biome": "desert", - "geometry": { - "type": "band", - "ring": true, - "z": 190000 - }, - "relief": { - "op": "ridge", - "heightM": 300, - "widthZM": 6500, - "warpM": 5000 - } - }, - { - "id": "third-ridge", - "name": "Third Ridge", - "provisionalName": true, - "kind": "region", - "biome": "desert", - "geometry": { - "type": "band", - "ring": true, - "z": 203000 - }, - "relief": { - "op": "ridge", - "heightM": 240, - "widthZM": 7500, - "warpM": 5000 - } - }, - { - "id": "westwall-water", - "name": "Westwall Water", - "provisionalName": true, - "kind": "river", - "geometry": { - "type": "polyline", - "points": [ - [ - -1.06, - 104000 - ], - [ - -1.02, - 118000 - ], - [ - -0.92, - 132000 - ], - [ - -0.8, - 144000 - ], - [ - -0.66, - 152000 - ], - [ - -0.52, - 155500 - ], - [ - -0.4, - 158500 - ], - [ - -0.28, - 157500 - ], - [ - -0.16, - 160000 - ], - [ - -0.06, - 161000 - ], - [ - 0.062, - 162000 - ] - ] - }, - "profileM": [ - 1980, - 1560, - 1180, - 930, - 830, - 790, - 762, - 752, - 730, - 715, - 700 - ], - "relief": { - "op": "carve", - "reaches": [ - { - "toIndex": 99, - "valleyWidthM": 2600, - "channelWidthM": 280, - "depthM": 220, - "wallExponent": 1.3, - "shoulderM": 2600 - } - ] - }, - "notes": "Turned by First Ridge and forced to run spinward along its front, gathering the others." - }, - { - "id": "sisters-water", - "name": "Sisters Water", - "provisionalName": true, - "kind": "river", - "geometry": { - "type": "polyline", - "points": [ - [ - -0.47, - 110000 - ], - [ - -0.45, - 124000 - ], - [ - -0.4, - 138000 - ], - [ - -0.33, - 148000 - ], - [ - -0.24, - 154000 - ], - [ - -0.14, - 157500 - ], - [ - -0.04, - 160000 - ], - [ - 0.062, - 162000 - ] - ] - }, - "profileM": [ - 1900, - 1500, - 1180, - 950, - 835, - 780, - 735, - 700 - ], - "relief": { - "op": "carve", - "reaches": [ - { - "toIndex": 99, - "valleyWidthM": 2600, - "channelWidthM": 280, - "depthM": 220, - "wallExponent": 1.3, - "shoulderM": 2600 - } - ] - } - }, - { - "id": "eastwall-water", - "name": "Eastwall Water", - "provisionalName": true, - "kind": "river", - "geometry": { - "type": "polyline", - "points": [ - [ - 0.95, - 108000 - ], - [ - 0.92, - 122000 - ], - [ - 0.86, - 134000 - ], - [ - 0.78, - 145000 - ], - [ - 0.66, - 152000 - ], - [ - 0.54, - 155000 - ], - [ - 0.42, - 158000 - ], - [ - 0.3, - 157000 - ], - [ - 0.2, - 159500 - ], - [ - 0.12, - 161000 - ], - [ - 0.062, - 162000 - ] - ] - }, - "profileM": [ - 1960, - 1580, - 1250, - 970, - 850, - 805, - 775, - 762, - 738, - 716, - 700 - ], - "relief": { - "op": "carve", - "reaches": [ - { - "toIndex": 99, - "valleyWidthM": 2600, - "channelWidthM": 280, - "depthM": 220, - "wallExponent": 1.3, - "shoulderM": 2600 - } - ] - }, - "notes": "Runs antispinward along the ridge front. It and Westwall Water meet head-on at the gap." - }, - { - "id": "the-long-water", - "name": "The Long Water", - "provisionalName": true, - "kind": "river", - "geometry": { - "type": "polyline", - "points": [ - [ - 0.22, - 100000 - ], - [ - 0.195, - 112000 - ], - [ - 0.17, - 126000 - ], - [ - 0.14, - 140000 - ], - [ - 0.1, - 152000 - ], - [ - 0.062, - 162000 - ], - [ - 0.03, - 170000 - ], - [ - -0.01, - 178000 - ], - [ - 0.015, - 186000 - ], - [ - -0.02, - 194000 - ], - [ - 0.005, - 201000 - ], - [ - -0.015, - 208000 - ], - [ - 0.01, - 216000 - ], - [ - 0.048, - 226000 - ], - [ - 0.086, - 236000 - ], - [ - 0.12, - 246000 - ] - ] - }, - "profileM": [ - 1900, - 1560, - 1240, - 940, - 800, - 700, - 640, - 580, - 520, - 460, - 415, - 370, - 320, - 250, - 180, - 124 - ], - "relief": { - "op": "carve", - "reaches": [ - { - "$reach": "mountain valley", - "toIndex": 6, - "valleyWidthM": 4200, - "channelWidthM": 280, - "depthM": 300, - "wallExponent": 1.25, - "shoulderM": 3500 - }, - { - "$reach": "The Narrows", - "toIndex": 12, - "valleyWidthM": 3200, - "channelWidthM": 260, - "depthM": 480, - "wallExponent": 0.5, - "shoulderM": 1100 - }, - { - "$reach": "forest reach", - "toIndex": 17, - "valleyWidthM": 5200, - "channelWidthM": 340, - "depthM": 80, - "wallExponent": 1.7, - "shoulderM": 6500 - } - ] - }, - "notes": "Trunk of the whole system. Above the gap it is one of four; below it carries all of them — about 392 m³/s off a 9,800 km² range. Zero Coriolis on its axial reaches (§1.9.1); the tributaries running along the ridge front are the only circumferential water in the world, and they weigh fractionally more or less for it (§1.9.2)." - }, - { - "id": "the-narrows", - "name": "The Narrows", - "provisionalName": true, - "kind": "canyon", - "geometry": { - "type": "polyline", - "points": [ - [ - 0.03, - 170000 - ], - [ - -0.01, - 178000 - ], - [ - 0.015, - 186000 - ], - [ - -0.02, - 194000 - ], - [ - 0.005, - 201000 - ], - [ - -0.015, - 208000 - ] - ] - }, - "depthM": 420, - "lengthM": 40000, - "notes": "The only breach in all three ridges, and therefore the only route along the axis of the world. 40 km long, rim ~1,000 m, up to 480 m deep. Whoever holds The Narrows holds Tower. Antecedent, exactly like the Yakima crossing the Manastash and Umtanum." - }, - { - "id": "greatwood", - "name": "Greatwood", - "provisionalName": true, - "kind": "forest", - "biome": "forest", - "geometry": { - "type": "band", - "ring": true, - "zFrom": 214000, - "zTo": 430000 - }, - "relief": { - "op": "plateau", - "heightM": 250, - "edgeM": 30000, - "noiseAmpM": 70, - "noiseScaleM": 15000, - "warpM": 18000, - "warpScaleM": 55000 - }, - "notes": "A forest belt ringing the world deepward of the desert. Tower rains here on purpose." - }, - { - "id": "the-deep", - "name": "The Deep", - "provisionalName": true, - "kind": "lake", - "biome": "water", - "geometry": { - "type": "rect", - "zFrom": 248000, - "zTo": 402000, - "thetaFrom": 0.06, - "thetaTo": 0.7 - }, - "waterLevelM": 120, - "relief": { - "op": "flood", - "depthM": 330, - "edgeM": 11000, - "warpM": 7000, - "warpScaleM": 34000 - }, - "notes": "Long down the axis and narrow around it, which is the whole point: you cross it the SHORT way, around theta, and the far shore rides 2.6 km above your eye. 154 km long, 32 km wide. Fed by The Long Water at its sunward head. Its heat capacity is the world's flywheel (§1.9.4)." - }, - { - "id": "the-capital", - "name": "The Capital", - "provisionalName": true, - "kind": "city", - "geometry": { - "type": "point", - "theta": 0.02, - "z": 270000 - }, - "elevationM": 175, - "notes": "z = 270 km, the axial midpoint — the only centre a cylinder has. On The Deep's antispinward shore, so the city looks across 32 km of water at a far shore standing 2.6 km up in its sky. Deepward of The Narrows: it sits behind the world's only gate." - }, - { - "id": "the-spindle", - "name": "The Spindle", - "provisionalName": true, - "kind": "tower", - "geometry": { - "type": "point", - "theta": 0.02, - "z": 271500 - }, - "elevationM": 175, - "heightM": 3200, - "notes": "The tall tower. At 3.2 km its top sits in 0.94 g and its sway period differs from its base. Height is a free choice — see the open question in design.md §1.11." - }, - { - "id": "sisters-station", - "name": "Sisters Station", - "provisionalName": true, - "kind": "city", - "geometry": { - "type": "point", - "theta": -0.47, - "z": 110500 - }, - "elevationM": 2100, - "notes": "Ski settlement above Sisters Tarn. Runs facing antispinward are the fast ones: a skier descending against the spin loses up to 8.6% of their weight at speed, and the effect grows as they accelerate. Prograde runs grip harder instead. Every slope in Tower has a handedness; no Earth mountain does." - }, - { - "id": "hollow-station", - "name": "Hollow Station", - "provisionalName": true, - "kind": "city", - "geometry": { - "type": "point", - "theta": 0.235, - "z": 102000 - }, - "elevationM": 2100 - } - ] -} diff --git a/content/world.json b/content/world.json new file mode 100644 index 0000000..03f885e --- /dev/null +++ b/content/world.json @@ -0,0 +1,890 @@ +{ + "version": 2, + "seed": 1729, + "ground": { + "baseM": 60, + "reliefM": 260, + "notes": "Everything outside the developed corridor. Deliberately gentle — this is shaped ground that nobody has bothered to make dramatic." + }, + "features": [ + { + "id": "sunward-sea", + "name": "The Sunward Sea", + "blurb": "", + "notes": "At the foot of the sun cap. Gravity on a cap is tangent to it, so water cannot rest there — it runs to the rim and pools. This ocean is not designed; it is what happens.", + "public": true, + "seed": 1437950893, + "kind": "sea", + "geometry": { + "type": "band", + "zFrom": 0, + "zTo": 42000 + }, + "levelM": 90, + "depthM": 420, + "shoreM": 16000 + }, + { + "id": "deepward-sea", + "name": "The Deepward Sea", + "blurb": "", + "notes": "The same at the far cap, and the larger of the two. A 314 km shoreline with a 50 km cliff behind it, and the only windows in the world at its foot (§1.4).", + "public": true, + "seed": 1346489887, + "kind": "sea", + "geometry": { + "type": "band", + "zFrom": 496000, + "zTo": 540000 + }, + "levelM": 90, + "depthM": 420, + "shoreM": 16000 + }, + { + "id": "sun-cap", + "name": "The Sun Cap", + "blurb": "", + "notes": "The sunward end of the world, and one of only two views out (§1.4). A cap is a wall, not a floor: gravity there points radially outward, tangent to its surface, so nothing rests on it. Fifty kilometres of vertical cliff with its windows at the base, and the beam entering through a 3.2 km aperture at its centre.", + "public": true, + "seed": 722399060, + "kind": "landmark", + "geometry": { + "type": "point", + "theta": 0, + "z": 0 + } + }, + { + "id": "far-cap", + "name": "The Far Cap", + "blurb": "", + "notes": "The deepward end, 540 km away, standing over The Deepward Sea. Its windows look along the spin axis — which is the celestial pole — so the stars do not sweep past but turn about a fixed centre, one revolution every 7 min 29 s. The only moving sky anyone in Tower ever sees.", + "public": true, + "seed": 1439513367, + "kind": "landmark", + "geometry": { + "type": "point", + "theta": 0, + "z": 540000 + } + }, + { + "id": "snowcrown", + "name": "Snowcrown", + "blurb": "", + "notes": "", + "public": true, + "seed": 1497766597, + "kind": "mountains", + "geometry": { + "type": "polygon", + "points": [ + [0.9, 88000], + [0.894867, 92307], + [0.879555, 96541], + [0.854328, 100629], + [0.819615, 104500], + [0.776012, 108089], + [0.724264, 111335], + [0.665257, 114181], + [0.6, 116579], + [0.52961, 118488], + [0.455291, 119876], + [0.378316, 120718], + [0.3, 121000], + [0.221684, 120718], + [0.144709, 119876], + [0.07039, 118488], + [0, 116579], + [-0.065257, 114181], + [-0.124264, 111335], + [-0.176012, 108089], + [-0.219615, 104500], + [-0.254328, 100629], + [-0.279555, 96541], + [-0.294867, 92307], + [-0.3, 88000], + [-0.294867, 83693], + [-0.279555, 79459], + [-0.254328, 75371], + [-0.219615, 71500], + [-0.176012, 67911], + [-0.124264, 64665], + [-0.065257, 61819], + [0, 59421], + [0.07039, 57512], + [0.144709, 56124], + [0.221684, 55282], + [0.3, 55000], + [0.378316, 55282], + [0.455291, 56124], + [0.52961, 57512], + [0.6, 59421], + [0.665257, 61819], + [0.724264, 64665], + [0.776012, 67911], + [0.819615, 71500], + [0.854328, 75371], + [0.879555, 79459], + [0.894867, 83693] + ] + }, + "peaks": [ + { + "name": "Snowcrown", + "theta": 0.3, + "z": 88000, + "elevationM": 4300 + } + ], + "heightM": 4300, + "ruggedness": 0.55, + "snowlineM": 3000 + }, + { + "id": "the-sisters", + "name": "The Sisters", + "blurb": "", + "notes": "", + "public": true, + "seed": 1441553728, + "kind": "mountains", + "geometry": { + "type": "polygon", + "points": [ + [0, 94000], + [-0.004449, 97655], + [-0.017719, 101247], + [-0.039583, 104715], + [-0.069667, 108000], + [-0.107456, 111045], + [-0.152304, 113799], + [-0.203444, 116214], + [-0.26, 118249], + [-0.321005, 119869], + [-0.385414, 121046], + [-0.452126, 121760], + [-0.52, 122000], + [-0.587874, 121760], + [-0.654586, 121046], + [-0.718995, 119869], + [-0.78, 118249], + [-0.836556, 116214], + [-0.887696, 113799], + [-0.932544, 111045], + [-0.970333, 108000], + [-1.000417, 104715], + [-1.022281, 101247], + [-1.035551, 97655], + [-1.04, 94000], + [-1.035551, 90345], + [-1.022281, 86753], + [-1.000417, 83285], + [-0.970333, 80000], + [-0.932544, 76955], + [-0.887696, 74201], + [-0.836556, 71786], + [-0.78, 69751], + [-0.718995, 68131], + [-0.654586, 66954], + [-0.587874, 66240], + [-0.52, 66000], + [-0.452126, 66240], + [-0.385414, 66954], + [-0.321005, 68131], + [-0.26, 69751], + [-0.203444, 71786], + [-0.152304, 74201], + [-0.107456, 76955], + [-0.069667, 80000], + [-0.039583, 83285], + [-0.017719, 86753], + [-0.004449, 90345] + ] + }, + "peaks": [ + { + "name": "The Sisters", + "theta": -0.52, + "z": 94000, + "elevationM": 3650 + } + ], + "heightM": 3650, + "ruggedness": 0.55, + "snowlineM": 3000 + }, + { + "id": "westwall", + "name": "Westwall", + "blurb": "", + "notes": "", + "public": true, + "seed": 946001310, + "kind": "mountains", + "geometry": { + "type": "polygon", + "points": [ + [-0.62, 86000], + [-0.624106, 89394], + [-0.636356, 92729], + [-0.656538, 95950], + [-0.684308, 99000], + [-0.71919, 101828], + [-0.760589, 104385], + [-0.807795, 106627], + [-0.86, 108517], + [-0.916312, 110021], + [-0.975767, 111114], + [-1.037347, 111778], + [-1.1, 112000], + [-1.162653, 111778], + [-1.224233, 111114], + [-1.283688, 110021], + [-1.34, 108517], + [-1.392205, 106627], + [-1.439411, 104385], + [-1.48081, 101828], + [-1.515692, 99000], + [-1.543462, 95950], + [-1.563644, 92729], + [-1.575894, 89394], + [-1.58, 86000], + [-1.575894, 82606], + [-1.563644, 79271], + [-1.543462, 76050], + [-1.515692, 73000], + [-1.48081, 70172], + [-1.439411, 67615], + [-1.392205, 65373], + [-1.34, 63483], + [-1.283688, 61979], + [-1.224233, 60886], + [-1.162653, 60222], + [-1.1, 60000], + [-1.037347, 60222], + [-0.975767, 60886], + [-0.916312, 61979], + [-0.86, 63483], + [-0.807795, 65373], + [-0.760589, 67615], + [-0.71919, 70172], + [-0.684308, 73000], + [-0.656538, 76050], + [-0.636356, 79271], + [-0.624106, 82606] + ] + }, + "peaks": [ + { + "name": "Westwall", + "theta": -1.1, + "z": 86000, + "elevationM": 3400 + } + ], + "heightM": 3400, + "ruggedness": 0.55, + "snowlineM": 3000 + }, + { + "id": "eastwall", + "name": "Eastwall", + "blurb": "", + "notes": "", + "public": true, + "seed": 653882312, + "kind": "mountains", + "geometry": { + "type": "polygon", + "points": [ + [1.48, 92000], + [1.475722, 95524], + [1.462963, 98988], + [1.44194, 102332], + [1.413013, 105500], + [1.376677, 108437], + [1.333553, 111092], + [1.284381, 113421], + [1.23, 115383], + [1.171342, 116945], + [1.10941, 118080], + [1.045263, 118769], + [0.98, 119000], + [0.914737, 118769], + [0.85059, 118080], + [0.788658, 116945], + [0.73, 115383], + [0.675619, 113421], + [0.626447, 111092], + [0.583323, 108437], + [0.546987, 105500], + [0.51806, 102332], + [0.497037, 98988], + [0.484278, 95524], + [0.48, 92000], + [0.484278, 88476], + [0.497037, 85012], + [0.51806, 81668], + [0.546987, 78500], + [0.583323, 75563], + [0.626447, 72908], + [0.675619, 70579], + [0.73, 68617], + [0.788658, 67055], + [0.85059, 65920], + [0.914737, 65231], + [0.98, 65000], + [1.045263, 65231], + [1.10941, 65920], + [1.171342, 67055], + [1.23, 68617], + [1.284381, 70579], + [1.333553, 72908], + [1.376677, 75563], + [1.413013, 78500], + [1.44194, 81668], + [1.462963, 85012], + [1.475722, 88476] + ] + }, + "peaks": [ + { + "name": "Eastwall", + "theta": 0.98, + "z": 92000, + "elevationM": 3550 + } + ], + "heightM": 3550, + "ruggedness": 0.55, + "snowlineM": 3000 + }, + { + "id": "the-girdle", + "name": "The Girdle", + "blurb": "", + "notes": "The range does not stop at Eastwall. It carries on round the far side of the world and closes on itself — a ring of peaks at z 90 km, unbroken except where the rivers cut it. Snowcrown, The Sisters, Westwall and Eastwall are the named high points of it, not a range of their own; the rest is unwalked. Rings the world 86 km sunward of the three low ridges, and is the reason every river on this half of Tower runs deepward.", + "public": true, + "seed": 1143607758, + "kind": "mountains", + "geometry": { + "type": "band", + "zFrom": 56000, + "zTo": 124000 + }, + "peaks": [], + "heightM": 2900, + "ruggedness": 0.6, + "snowlineM": 3000 + }, + { + "id": "hollow-tarn", + "name": "Hollow Tarn", + "blurb": "", + "notes": "The Long Water's source and the top of the world's water loop. Every litre in Tower passes through here.", + "public": true, + "seed": 226829642, + "kind": "lake", + "geometry": { + "type": "polygon", + "points": [ + [0.26, 100000], + [0.259231, 100468], + [0.256955, 100918], + [0.253259, 101333], + [0.248284, 101697], + [0.242223, 101996], + [0.235307, 102217], + [0.227804, 102354], + [0.22, 102400], + [0.212196, 102354], + [0.204693, 102217], + [0.197777, 101996], + [0.191716, 101697], + [0.186741, 101333], + [0.183045, 100918], + [0.180769, 100468], + [0.18, 100000], + [0.180769, 99532], + [0.183045, 99082], + [0.186741, 98667], + [0.191716, 98303], + [0.197777, 98004], + [0.204693, 97783], + [0.212196, 97646], + [0.22, 97600], + [0.227804, 97646], + [0.235307, 97783], + [0.242223, 98004], + [0.248284, 98303], + [0.253259, 98667], + [0.256955, 99082], + [0.259231, 99532] + ] + }, + "levelM": "auto", + "depthM": 190, + "basin": "bowl", + "shoreM": 2000, + "rimM": 120 + }, + { + "id": "sisters-tarn", + "name": "Sisters Tarn", + "blurb": "", + "notes": "Largest of the high lakes. A ski settlement stands on its deepward shore.", + "public": true, + "seed": 1798023826, + "kind": "lake", + "geometry": { + "type": "polygon", + "points": [ + [-0.43, 104000], + [-0.430769, 104468], + [-0.433045, 104918], + [-0.436741, 105333], + [-0.441716, 105697], + [-0.447777, 105996], + [-0.454693, 106217], + [-0.462196, 106354], + [-0.47, 106400], + [-0.477804, 106354], + [-0.485307, 106217], + [-0.492223, 105996], + [-0.498284, 105697], + [-0.503259, 105333], + [-0.506955, 104918], + [-0.509231, 104468], + [-0.51, 104000], + [-0.509231, 103532], + [-0.506955, 103082], + [-0.503259, 102667], + [-0.498284, 102303], + [-0.492223, 102004], + [-0.485307, 101783], + [-0.477804, 101646], + [-0.47, 101600], + [-0.462196, 101646], + [-0.454693, 101783], + [-0.447777, 102004], + [-0.441716, 102303], + [-0.436741, 102667], + [-0.433045, 103082], + [-0.430769, 103532] + ] + }, + "levelM": "auto", + "depthM": 190, + "basin": "bowl", + "shoreM": 2000, + "rimM": 120 + }, + { + "id": "west-tarn", + "name": "Cold Tarn", + "blurb": "", + "notes": "", + "public": true, + "seed": 1277495802, + "kind": "lake", + "geometry": { + "type": "polygon", + "points": [ + [-1.01, 98000], + [-1.010769, 98468], + [-1.013045, 98918], + [-1.016741, 99333], + [-1.021716, 99697], + [-1.027777, 99996], + [-1.034693, 100217], + [-1.042196, 100354], + [-1.05, 100400], + [-1.057804, 100354], + [-1.065307, 100217], + [-1.072223, 99996], + [-1.078284, 99697], + [-1.083259, 99333], + [-1.086955, 98918], + [-1.089231, 98468], + [-1.09, 98000], + [-1.089231, 97532], + [-1.086955, 97082], + [-1.083259, 96667], + [-1.078284, 96303], + [-1.072223, 96004], + [-1.065307, 95783], + [-1.057804, 95646], + [-1.05, 95600], + [-1.042196, 95646], + [-1.034693, 95783], + [-1.027777, 96004], + [-1.021716, 96303], + [-1.016741, 96667], + [-1.013045, 97082], + [-1.010769, 97532] + ] + }, + "levelM": "auto", + "depthM": 190, + "basin": "bowl", + "shoreM": 2000, + "rimM": 120 + }, + { + "id": "east-tarn", + "name": "Long Tarn", + "blurb": "", + "notes": "", + "public": true, + "seed": 1335790732, + "kind": "lake", + "geometry": { + "type": "polygon", + "points": [ + [0.98, 103000], + [0.979231, 103468], + [0.976955, 103918], + [0.973259, 104333], + [0.968284, 104697], + [0.962223, 104996], + [0.955307, 105217], + [0.947804, 105354], + [0.94, 105400], + [0.932196, 105354], + [0.924693, 105217], + [0.917777, 104996], + [0.911716, 104697], + [0.906741, 104333], + [0.903045, 103918], + [0.900769, 103468], + [0.9, 103000], + [0.900769, 102532], + [0.903045, 102082], + [0.906741, 101667], + [0.911716, 101303], + [0.917777, 101004], + [0.924693, 100783], + [0.932196, 100646], + [0.94, 100600], + [0.947804, 100646], + [0.955307, 100783], + [0.962223, 101004], + [0.968284, 101303], + [0.973259, 101667], + [0.976955, 102082], + [0.979231, 102532] + ] + }, + "levelM": "auto", + "depthM": 190, + "basin": "bowl", + "shoreM": 2000, + "rimM": 120 + }, + { + "id": "the-dry-reach", + "name": "The Dry Reach", + "blurb": "", + "notes": "Rings the world. On a cylinder you cannot go around a band — a ringed desert is an absolute barrier, which no Earth desert ever is. 70 km deep, 22,000 km², 13% of the world. Dry because Tower withholds rain here, not because terrain shades it.", + "public": true, + "seed": 227210026, + "kind": "desert", + "geometry": { + "type": "band", + "zFrom": 148000, + "zTo": 218000 + }, + "upliftM": 780, + "edgeM": 20000 + }, + { + "id": "first-ridge", + "name": "First Ridge", + "blurb": "", + "notes": "Rings the world with its two siblings: three concentric walls across Tower's waist. Every drainage off the range is turned by them. Only one river was here before they were raised and only that one still cuts through — The Narrows is the single breach in all three.", + "public": true, + "seed": 1871446183, + "kind": "mountains", + "geometry": { + "type": "band", + "zFrom": 162000, + "zTo": 190000 + }, + "peaks": [], + "heightM": 260, + "ruggedness": 0, + "snowlineM": 3000 + }, + { + "id": "second-ridge", + "name": "Second Ridge", + "blurb": "", + "notes": "", + "public": true, + "seed": 1668141571, + "kind": "mountains", + "geometry": { + "type": "band", + "zFrom": 177000, + "zTo": 203000 + }, + "peaks": [], + "heightM": 300, + "ruggedness": 0, + "snowlineM": 3000 + }, + { + "id": "third-ridge", + "name": "Third Ridge", + "blurb": "", + "notes": "", + "public": true, + "seed": 540879940, + "kind": "mountains", + "geometry": { + "type": "band", + "zFrom": 188000, + "zTo": 218000 + }, + "peaks": [], + "heightM": 240, + "ruggedness": 0, + "snowlineM": 3000 + }, + { + "id": "westwall-water", + "name": "Westwall Water", + "blurb": "", + "notes": "Turned by First Ridge and forced to run spinward along its front, gathering the others.", + "public": true, + "seed": 1255511300, + "kind": "river", + "geometry": { + "type": "polyline", + "points": [ + [-1.06, 104000], + [-1.02, 118000], + [-0.92, 132000], + [-0.8, 144000], + [-0.66, 152000], + [-0.52, 155500], + [-0.4, 158500], + [-0.28, 157500], + [-0.16, 160000], + [-0.06, 161000], + [0.062, 162000] + ] + }, + "flowM3S": 98, + "sourceM": 1980, + "outlet": "auto" + }, + { + "id": "sisters-water", + "name": "Sisters Water", + "blurb": "", + "notes": "", + "public": true, + "seed": 654450092, + "kind": "river", + "geometry": { + "type": "polyline", + "points": [ + [-0.47, 110000], + [-0.45, 124000], + [-0.4, 138000], + [-0.33, 148000], + [-0.24, 154000], + [-0.14, 157500], + [-0.04, 160000], + [0.062, 162000] + ] + }, + "flowM3S": 98, + "sourceM": 1900, + "outlet": "auto" + }, + { + "id": "eastwall-water", + "name": "Eastwall Water", + "blurb": "", + "notes": "Runs antispinward along the ridge front. It and Westwall Water meet head-on at the gap.", + "public": true, + "seed": 856398702, + "kind": "river", + "geometry": { + "type": "polyline", + "points": [ + [0.95, 108000], + [0.92, 122000], + [0.86, 134000], + [0.78, 145000], + [0.66, 152000], + [0.54, 155000], + [0.42, 158000], + [0.3, 157000], + [0.2, 159500], + [0.12, 161000], + [0.062, 162000] + ] + }, + "flowM3S": 98, + "sourceM": 1960, + "outlet": "auto" + }, + { + "id": "the-long-water", + "name": "The Long Water", + "blurb": "", + "notes": "Trunk of the whole system. Above the gap it is one of four; below it carries all of them — about 392 m³/s off a 9,800 km² range. Zero Coriolis on its axial reaches (§1.9.1); the tributaries running along the ridge front are the only circumferential water in the world, and they weigh fractionally more or less for it (§1.9.2).", + "public": true, + "seed": 2105334655, + "kind": "river", + "geometry": { + "type": "polyline", + "points": [ + [0.22, 100000], + [0.195, 112000], + [0.17, 126000], + [0.14, 140000], + [0.1, 152000], + [0.062, 162000], + [0.03, 170000], + [-0.01, 178000], + [0.015, 186000], + [-0.02, 194000], + [0.005, 201000], + [-0.015, 208000], + [0.01, 216000], + [0.048, 226000], + [0.086, 236000], + [0.12, 246000] + ] + }, + "flowM3S": 98, + "sourceM": 1900, + "outlet": "auto" + }, + { + "id": "the-narrows", + "name": "The Narrows", + "blurb": "", + "notes": "The only breach in all three ridges, and therefore the only route along the axis of the world. 40 km long, rim ~1,000 m, up to 480 m deep. Whoever holds The Narrows holds Tower. Antecedent, exactly like the Yakima crossing the Manastash and Umtanum.", + "public": true, + "seed": 1630660453, + "kind": "landmark", + "geometry": { + "type": "polyline", + "points": [ + [0.03, 170000], + [-0.01, 178000], + [0.015, 186000], + [-0.02, 194000], + [0.005, 201000], + [-0.015, 208000] + ] + } + }, + { + "id": "greatwood", + "name": "Greatwood", + "blurb": "", + "notes": "A forest belt ringing the world deepward of the desert. Tower rains here on purpose.", + "public": true, + "seed": 756694743, + "kind": "forest", + "geometry": { + "type": "band", + "zFrom": 214000, + "zTo": 430000 + }, + "upliftM": 250, + "edgeM": 30000 + }, + { + "id": "the-deep", + "name": "The Deep", + "blurb": "", + "notes": "Long down the axis and narrow around it, which is the whole point: you cross it the SHORT way, around theta, and the far shore rides 2.6 km above your eye. 154 km long, 32 km wide. Fed by The Long Water at its sunward head. Its heat capacity is the world's flywheel (§1.9.4).", + "public": true, + "seed": 1519613949, + "kind": "lake", + "geometry": { + "type": "polygon", + "points": [ + [0.06, 248000], + [0.7, 248000], + [0.7, 402000], + [0.06, 402000] + ] + }, + "levelM": 120, + "depthM": 330, + "basin": "flooded", + "shoreM": 11000, + "rimM": 0 + }, + { + "id": "the-capital", + "name": "The Capital", + "blurb": "", + "notes": "z = 270 km, the axial midpoint — the only centre a cylinder has. On The Deep's antispinward shore, so the city looks across 32 km of water at a far shore standing 2.6 km up in its sky. Deepward of The Narrows: it sits behind the world's only gate.", + "public": true, + "seed": 1711426401, + "kind": "settlement", + "geometry": { + "type": "point", + "theta": 0.02, + "z": 270000 + }, + "size": "city" + }, + { + "id": "the-spindle", + "name": "The Spindle", + "blurb": "", + "notes": "The tall tower. At 3.2 km its top sits in 0.94 g and its sway period differs from its base. Height is a free choice — see the open question in design.md §1.11.", + "public": true, + "seed": 554690230, + "kind": "tower", + "geometry": { + "type": "point", + "theta": 0.02, + "z": 271500 + }, + "heightM": 3200 + }, + { + "id": "sisters-station", + "name": "Sisters Station", + "blurb": "", + "notes": "Ski settlement above Sisters Tarn. Runs facing antispinward are the fast ones: a skier descending against the spin loses up to 8.6% of their weight at speed, and the effect grows as they accelerate. Prograde runs grip harder instead. Every slope in Tower has a handedness; no Earth mountain does.", + "public": true, + "seed": 1005547319, + "kind": "settlement", + "geometry": { + "type": "point", + "theta": -0.47, + "z": 110500 + }, + "size": "station" + }, + { + "id": "hollow-station", + "name": "Hollow Station", + "blurb": "", + "notes": "", + "public": true, + "seed": 613188767, + "kind": "settlement", + "geometry": { + "type": "point", + "theta": 0.235, + "z": 102000 + }, + "size": "station" + } + ] +} diff --git a/decisions.md b/decisions.md index eaa1c4b..2686df4 100644 --- a/decisions.md +++ b/decisions.md @@ -1951,3 +1951,91 @@ when it is first constructed. Switching to another view and back never rebuilds exterior, so the reading survives that as well. An unreadable or unknown stored value falls back to true. The screenshot script selects a reading explicitly for each shot, in a fresh browser context, so it is unaffected. + +--- + +## D-062 — The world generates itself, live, from features + +**Decided by the author** (2026-09-16), after two rounds of answers below. Full design in +`world-generation.md`; Phase 1 is D-063. + +The terrain stops being two baked images. `content/geography.json` and `world.json` +merge into one world file of typed features — mountains, lakes, rivers, seas, desert, +forest, city, roads and paths — and the browser generates the world from it in web +workers, on load and on every edit, streaming detail in around the observer. Trees, +buildings, water surfaces with waves and flow, and ships on the cap seas become real +geometry. + +**What it reverses.** D-006 chose "real geometry, simple ground" and ruled out scenery, +settlements and vegetation; this adds all three. D-019's build step, its PNG rasters +and its no-dependencies rule go, along with D-021, D-035, D-039 and D-057, which exist +only because of the rasters. `design.md` §2.4's "water has no surface" and §4's +"procedural scenery, settlements" out-of-scope line are reversed. These are listed in +`world-generation.md` §11. The author agreed to all of it by asking for the build to +begin. + +**Author's answers, 2026-09-16** (`world-generation.md` §12.1). Nothing inside Tower is +a remnant: every landform, including the desert's dry washes and shorelines, is there +because Tower chose to quote something it saw on Earth or elsewhere. Wind is Tower's +published weather plan, which it revises when it likes — storms are scheduled, and +cancelled if someone foolish sails into one. Settlements are generated to fill the +world. Trees are real size, with a tall forest of 90–115 m giants as a landmark. A +3–8 s compile behind a Terraria-style status dialog is acceptable. Phones get the same +world at full density, with shorter draw distances. Second round: days count from 0; the seed lives in +the world file, so the world is identical for every reader; generated settlements are +named for their size; the tall forest is placed at random near the capital once and +then fixed; the status dialog lines are approved. + +--- + +## D-063 — Phase 1: the ground is generated, and the rivers had to be engineered + +**Built.** `world-generation.md` phase 1: world file v2 and the one-off port of v1's +geography; the world compile and the elevation function (`src/terrain/`), in a pool of +web workers; the ground as a quadtree of tiles; the status dialog; keyboard flight; and +the map and the x-ray hull drawn from a picture rendered live from the features. +`tools/terrain.mjs`, its PNG encoder and noise, the three terrain PNGs, `raster.ts` and +`content/geography.json` are gone. Two runtime dependencies came in, **simplex-noise** +and **comlink**; the other three the plan adopts wait for the phases that need them. + +**One `geometry` field for every kind.** The plan sketched kind-specific fields — +`area`, `shore`, `course`. As built, every feature has `geometry` and its parameters +sit beside it, so the map, the list, the editor and the permalinks needed no per-kind +code. Parameters arrive with the phase whose generator reads them. + +**The rivers cross a lowland they cannot get out of, and the profile had to be chosen.** +v1 authored a 60 m lowland between the Girdle and the Dry Reach and a 800–1,100 m +plateau beyond it, and painted its rivers across at about 1,000 m. Generated honestly, +a river cannot do that. Three profiles were tried against the real ground: + +| Profile | Result | +|---|---| +| clamped to the ground | every river falls to 120 m in the lowland and cuts a 900 m canyon through 100 km of desert | +| graded curve, source to mouth | a 1,300 m embankment across the lowland | +| **least earthwork** (isotonic regression, ≥ 2 m/km) | The Narrows cut 300–500 m deep, as §1.11 says; up to 880 m of embankment under each river | + +The third is built. It is the only one of the three that is a rule rather than a guess — +Tower moves as little ground as it can — and it reproduces the canyon canon already +described. The embankments are the price, and they are real in the render. Whether the +world should change instead (raise the lowland, or let it hold a lake) is recorded as an +open question in design.md §1.11: that is a decision about Tower, not about the code. + +**Tiles were being measured against the tallest mountain.** A fixed 4.5 km allowance for +relief made every tile within 5 km of the eye look near enough to split, all the way to +level 10 — 11,000 tiles on screen. Each tile is now bounded by the relief it actually +has, and the split distance was measured down from 2.4 to 1.8 tile-widths: 1.9 M +triangles and about 200 draw calls, settled in 1.4 s at the Capital. + +**A coarse tile cannot show an 80 m river.** One vertex of a 500 m tile falling in a +channel painted a 500 m blue diamond, and the far side of the world wore a dotted line +down every river. Water now weighs by how much of the vertex's spacing it covers. + +**The x-ray hull had been mirrored end to end since D-024.** Three's cylinder puts v = 1 +at the far cap after the rotation, and the UV rewrite flipped it, so the Girdle's snow +was drawn at the far cap. The committed v1 screenshot shows it. Fixed with the rest, +since the picture on the hull changed anyway. + +**Readers pay a compile on every visit, and it is short.** About 120 ms to compile and +1.2 s to draw the map, so the status dialog rarely appears on the author's laptop; it +will on slower machines, and it will once erosion arrives in phase 2. A production +build reaches a fully detailed interior 4.3 s after a cold load. diff --git a/design.md b/design.md index d4f6258..197a786 100644 --- a/design.md +++ b/design.md @@ -537,8 +537,9 @@ Tower's countries, its climate map and its astronomy all at once — is what mad ### 1.11 The world as built -Authored geography lives in **`content/geography.json`** — the source of truth — and -`npm run terrain` renders it. Names are provisional. Tower is laid out in **belts +Authored geography lives in **`content/world.json`** — the source of truth — as +features the browser generates the terrain from live (world-generation.md, D-062). +Names are provisional. Tower is laid out in **belts along the axis**, which is the natural grain of an engineered climate (§1.9.4): | z | | | @@ -573,6 +574,17 @@ gathering each other, and leave with the fourth through the single gap. where Coriolis is exactly zero. - Whoever holds The Narrows holds the axis of the world. The Capital sits behind it. +**The drainage needs the floor built up in one place, and generating it honestly is +what showed it** (D-063). Between the Girdle's foot and the Dry Reach's rise — z 115 +to 135 km — the ground authored in v1 is a lowland barely 60 m high, while the plateau +the rivers must cross beyond it stands at 800–1,100 m. v1 drew its rivers across that +lowland at about 1,000 m, painted over ground a kilometre below them. A river cannot +climb, so the generator gives each river the descending profile nearest the ground it +crosses: it cuts The Narrows about 300–500 m into the ridges — the depth this section +has always given — and **builds the lowland up by up to 880 m under each river** to +carry it there. Tower shapes its floor so its rivers run; here that means four +engineered embankments across a plain. See the open question below. + #### The Deep is oriented for the view Long down the axis and narrow around it, so you cross it the **short** way — around @@ -608,6 +620,10 @@ Hollow Station — around 2,100 m, with the runs above them. #### Open questions +- **The lowland under the rivers.** The four embankments above follow from the ground + as authored. The alternatives are to raise the lowland itself — the Girdle's skirt + reaching out to meet the Dry Reach — or to let the rivers pool there as a lake that + spills over the plateau's lip. Either is a change to the world, not to the generator. - **The Spindle's height.** 3.2 km is a placeholder; an object dropped from its top lands ~760 m antispinward of its base. - **z 430–496 km** is unauthored. The circumference outside the named peaks is no @@ -626,6 +642,10 @@ Hollow Station — around 2,100 m, with the runs above them. fighting them for every touch. - **Three.js** for the exterior and interior 3D views. - **Canvas 2D** for the map view. +- **Web workers** for world generation (world-generation.md §5): a pool of up to six, + driven through **comlink**. Noise is **simplex-noise**, sampled in 3D on the + cylinder itself so nothing tears at θ = 0. Those two are the only runtime + dependencies besides Three.js. - **GitLab CI → GitLab Pages**, publishing `dist/` as `static/`. ### 2.2 Module layout @@ -638,18 +658,29 @@ src/ atmosphere.ts the rotational density profile and its extinction (§1.5) motion.ts how things fall in the rotating frame (§1.9.2) — the only evidence of the spin anywhere but the far cap window + terrain/ world generation, pure TypeScript — runs in workers, on the + main thread and under vitest alike (world-generation.md §5) + compile.ts the world compile: shapes, river profiles, lake levels, summits + field.ts the elevation function: one answer to "how high is it here" + noise.ts seeded simplex noise on the cylinder, and the hash under it + shapes.ts signed distance, smoothed lines, on the unrolled (z, θR) plane + tile.ts the tile quadtree scheme, and one tile's mesh data + palette.ts material weights to colour, as TypeScript and as GLSL + map-image.ts the map's picture of the world, rendered in bands + worker.ts one worker: compile, tiles, map rows + service.ts the pool, generations, the status dialog's progress, ground queries views/ - palette.ts one colour per place kind, shared by map, list and editor - raster.ts what npm run terrain wrote: grid, and what white means + palette.ts one colour per feature kind, shared by map, list and editor + terrain-lod.ts the ground as a quadtree of tiles, refined around the eye world/ - schema.ts world.json types + validation - store.ts places CRUD, localStorage drafts, JSON import/export + schema.ts world.json v2 types + validation: features, not places + store.ts feature CRUD, localStorage drafts, JSON import/export instruments.ts sightline, viewshed, lighting probe, travel (§3.3) state/ store.ts selected point, time-of-day, spin phase, view mode, writer flag views/ exterior.ts Three.js: hull, collector, beam, spin, point picker - interior-model.ts ground, caps, bore, beam and haze, shared by both 3D views + interior-model.ts ground tiles, caps, bore, beam and haze, shared by both 3D views map.ts Canvas 2D: unrolled surface, slats, terminator, point picker interior.ts Three.js: shell geometry, the haze shader of §1.5, beam, rain ui/ @@ -659,9 +690,7 @@ src/ main.ts tools/ node-only, never bundled; plain .mjs with no build step - terrain.mjs renders content/geography.json into static/ (§1.11) - png.mjs minimal PNG encoder — zlib is its only dependency - noise.mjs fbm and ridged noise for the terrain relief + port-world.mjs the one-off port of v1's geography.json into world.json v2 screenshots.mjs drives the running application in a headless browser and photographs it into docs/screenshots/ (D-042) ``` @@ -733,7 +762,8 @@ is 244 km across and flies 320 km ahead, and it is the larger object. **The hull has two readings, and the view switches between them** (D-047). **True** is the outer skin: bare structure, with a window at each cap. -**X-ray** wraps the terrain raster onto the outside instead — from outside Tower is +**X-ray** wraps the map's picture of the terrain onto the outside instead — the same +image the map draws, rendered live from the world (world-generation.md §9) — from outside Tower is structure, so what that texture shows is what is *inside*, which makes it a diagram and not a view. Both are worth having and they are not the same claim, so the control is a two-way switch rather than a checkbox. **True is the default**, and the @@ -755,7 +785,7 @@ and the exterior hands them the world-to-tower transform each frame, because the hull is turning and the air turns with it. The outer skin keeps its own mesh and its own two modes. Three's cylinder runs u -around and v along, while the raster is the other way round, so the UVs are rewritten +around and v along, while the image is the other way round, so the UVs are rewritten from the original attribute rather than derived from vertex positions — the seam vertices are duplicated, and deriving an angle from them smears one column of triangles across the whole image. @@ -769,10 +799,10 @@ camera; the sidebar and the map carry the places themselves. ### 2.4 The interior view — built -Scope held: **real geometry, simple ground.** +Scope, since D-062: **real geometry, and ground generated to match it.** -Real: the shell is the actual cylinder, curving up on both sides and closing -overhead, displaced by `terrain-height.png` and painted with `terrain-color.png`. +Real: the ground is the actual cylinder, curving up on both sides and closing +overhead, built from generated tiles (world-generation.md §6). **Stars only through the far cap** — the wall is sealed (§1.4), so for 539 of the 540 km the sky is land and light. The far cap is glazed (D-045) and the sky beyond its stars are scattered evenly over the whole sphere — rolled in a lattice of 3D cells @@ -780,10 +810,15 @@ and kept only where they land in a one-cell shell around the eye — and looked view direction turned about the spin axis by the spin phase: one turn every 7 min 29 s, and the point on the axis genuinely motionless. -Simple: the ground is the raster plus a little procedural grain in the near field, -not modelled scenery. No buildings, no vegetation meshes. Cells are 0.7 km and -texels 264 m, so relief a valley away is real and the ground underfoot is not. -Detailed procedural scenery stays deferred. +The ground is a **quadtree of tiles**, each a 65-vertex grid bent onto the cylinder +in a worker: 34 km tiles on the far side of the world, 33 m tiles underfoot, so vertices +are half a kilometre apart overhead and half a metre apart at your feet. A tile splits +once the eye is within 1.8 tile-widths of it, and only once all four of its children +have arrived — detail pops in, and the ground is never missing. The tiles carry +material weights rather than colour, and the shader turns them into colour from the +same palette the map uses, with v1's mottling and a little grain near the eye. There +is still no scenery: no buildings, no trees, no water surface of its own. Those are +phases 3–7 of world-generation.md. **Haze is the shader, and it is integrated rather than faked.** Every fragment samples the rotational density profile 24 times along the ray to the camera and @@ -810,11 +845,14 @@ it is the only evidence left now that the rain is gone. Both caps are windows (D-050), so it can be had looking either way along the axis — deepward into empty space, or sunward past the black disc of the collector. -The observer stands on the terrain, which means the height raster is read back onto -the CPU and sampled against the mesh's own cell corners — the GPU cannot be asked -where the ground is (D-035). +The observer stands on the terrain by asking the same elevation function the tiles are +built from, at full detail. D-035's readback of a raster is gone with the raster. + +**Keyboard flight**: W A S D or the arrows move along the heading, E and Q climb and +descend, shift is five times faster. Speed is proportional to height above the ground, +so the ground passes at the same apparent pace from a metre up or ten kilometres. -Instruments in the view: eye height from 1.7 m to 25 km with the local gravity +Instruments in the view: eye height from 1.7 m to 45 km with the local gravity beside it, the five canon illumination settings of §1.3, the field of view as a pair of binoculars, and captions on every place still visible through the haze. Selecting a place walks the observer to it (D-038) — the haze horizon closes at @@ -832,8 +870,10 @@ The far cap's glazing is set a kilometre into its own frame, so from inside ther a **1 km reveal** around it. That is the only place the wall depth of D-044 can be seen from within the world, which is why it is the only place it is drawn. -Not modelled: water has no surface. Lakes and seas are painted in the raster and cut -into the shell as basins, so standing in one puts you on its floor. +Water has a surface, but not yet a mesh of its own. A tile shows whichever is higher, +the ground or the water on it, so a lake reads as flat water at its level and standing +in one puts you on its surface. Waves, flow and shorelines are world-generation.md +phase 3. --- @@ -866,20 +906,30 @@ be mistaken for access control. ```jsonc { - "version": 1, - "places": [{ - "id": "hallow-crossing", // permalink target; see below - "name": "Hallow Crossing", - "kind": "city|embassy|lake|region|landmark|note", - "geometry": { "type": "point", "theta": 1.9635, "z": 270000 }, - // or { "type": "polygon", "points": [[theta,z], ...] } for lakes, regions + "version": 2, + "seed": 1729, // every feature's randomness mixes this in + "ground": { "baseM": 60, "reliefM": 260, "notes": "" }, + "features": [{ + "id": "the-deep", // permalink target; see below + "name": "The Deep", + "kind": "mountains|lake|sea|river|desert|forest|settlement|road|path|embassy|tower|landmark|note|region", + "geometry": { "type": "polygon", "points": [[theta, z], ...] }, + // or point, polyline, or band { "zFrom", "zTo" } for anything ringing the world "blurb": "public description, shown to readers", "notes": "the author's working notes — NEVER shipped", - "public": true // false = not ready to exist publicly + "public": true, // false = not ready to exist publicly + "seed": 1437950893, // written once at creation, never from the id + "levelM": 120, "depthM": 330, "basin": "flooded", "shoreM": 11000 + // ...and whatever parameters the kind has: world-generation.md §3 }] } ``` +Every kind has the same `geometry` field, so the map, the list and the editor treat +them one way; what a kind adds is parameters beside it, and a phase of +world-generation.md adds a parameter only when a generator reads it. v1 files, with +`places`, load as empty — there is no backwards compatibility (D-062). + **Two text fields, deliberately separate.** `blurb` is written for readers and ships; `notes` is the author's working thinking and never leaves the machine except in a backup. One field with a flag would have made every note a publication @@ -902,6 +952,12 @@ files: **`world.json`** (public entries only, committed) and should nonetheless be version-controlled — in a private repo, or a second one — is the author's call and is deliberately unresolved. +**`content/world.json` is the whole world, notes included, and it is committed** — +exactly as `content/geography.json` was before it, which carried the same notes. It +is not served (Vite serves `static/`), but the repository is public, so anything in its +notes is public too. Nothing about v2 changed that; it is recorded here so the move +from one file to the other is not mistaken for a change in what is private. + ### 3.3 Instruments The measuring tools are the real substance of writer mode. Each answers a question @@ -940,9 +996,9 @@ a moment. Reading the world, not editing it. world needs developing before there is anything to render.* Map view, place editing for all thirteen kinds, points/polylines/polygons, localStorage autosave, backup and static exports, import, and the sightline probe. -7. **Content pipeline and reader mode** — ✅ `content/geography.json` + - `npm run terrain` (D-019); the rendered terrain drawn under the map; reader and - writer modes (D-011); permalinks (D-010). +7. **Content pipeline and reader mode** — ✅ reader and writer modes (D-011); + permalinks (D-010). The terrain pipeline of D-019 — `content/geography.json` and + `npm run terrain` — is replaced by live generation (below). 8. **Polish** — mobile ✅ (the sidebar is a drawer below 720 px, D-043) and a screenshot tour that photographs all ten views through the UI, D-042. Remaining: transitions, performance. @@ -955,5 +1011,9 @@ readers, and "stand here and look at this" is the whole reason it exists as a companion piece. Named locations from the novel should be markable on the map for the same reason. -Later, explicitly out of scope for now: procedural scenery, settlements, weather -systems. +**Live world generation** — in progress, planned in world-generation.md (D-062). +Phase 1 ✅ (D-063): world file v2, the elevation function, the worker pool, tiled +ground, the status dialog, keyboard flight, the map drawn from the features. Phases 2–8 +bring procedural scenery, settlements, and weather as Tower's published schedule of +wind and waves — which this section used to list as out of scope. Clouds and rain stay +undrawn (D-049). diff --git a/docs/screenshots/desktop-exterior-true.png b/docs/screenshots/desktop-exterior-true.png index 68b3dcd..7ef525d 100644 Binary files a/docs/screenshots/desktop-exterior-true.png and b/docs/screenshots/desktop-exterior-true.png differ diff --git a/docs/screenshots/desktop-exterior.png b/docs/screenshots/desktop-exterior.png index 8dcba17..4635b8a 100644 Binary files a/docs/screenshots/desktop-exterior.png and b/docs/screenshots/desktop-exterior.png differ diff --git a/docs/screenshots/desktop-interior-dawn.png b/docs/screenshots/desktop-interior-dawn.png index ed59d14..71fac24 100644 Binary files a/docs/screenshots/desktop-interior-dawn.png and b/docs/screenshots/desktop-interior-dawn.png differ diff --git a/docs/screenshots/desktop-interior-night.png b/docs/screenshots/desktop-interior-night.png index c8c5f3b..8e2fb63 100644 Binary files a/docs/screenshots/desktop-interior-night.png and b/docs/screenshots/desktop-interior-night.png differ diff --git a/docs/screenshots/desktop-interior-overhead.png b/docs/screenshots/desktop-interior-overhead.png index f35fc93..7168884 100644 Binary files a/docs/screenshots/desktop-interior-overhead.png and b/docs/screenshots/desktop-interior-overhead.png differ diff --git a/docs/screenshots/desktop-interior-suncap.png b/docs/screenshots/desktop-interior-suncap.png index 4b085a1..0981048 100644 Binary files a/docs/screenshots/desktop-interior-suncap.png and b/docs/screenshots/desktop-interior-suncap.png differ diff --git a/docs/screenshots/desktop-interior.png b/docs/screenshots/desktop-interior.png index ce2a59f..62b49a1 100644 Binary files a/docs/screenshots/desktop-interior.png and b/docs/screenshots/desktop-interior.png differ diff --git a/docs/screenshots/desktop-map-sightline.png b/docs/screenshots/desktop-map-sightline.png index 36adbc9..efb751d 100644 Binary files a/docs/screenshots/desktop-map-sightline.png and b/docs/screenshots/desktop-map-sightline.png differ diff --git a/docs/screenshots/desktop-map.png b/docs/screenshots/desktop-map.png index 9359a35..f6f8f95 100644 Binary files a/docs/screenshots/desktop-map.png and b/docs/screenshots/desktop-map.png differ diff --git a/docs/screenshots/desktop-reader.png b/docs/screenshots/desktop-reader.png index 239d0b8..da6bd35 100644 Binary files a/docs/screenshots/desktop-reader.png and b/docs/screenshots/desktop-reader.png differ diff --git a/docs/screenshots/mobile-exterior.png b/docs/screenshots/mobile-exterior.png index a37e1a4..bca5379 100644 Binary files a/docs/screenshots/mobile-exterior.png and b/docs/screenshots/mobile-exterior.png differ diff --git a/docs/screenshots/mobile-interior.png b/docs/screenshots/mobile-interior.png index 5ab1c61..ce788ec 100644 Binary files a/docs/screenshots/mobile-interior.png and b/docs/screenshots/mobile-interior.png differ diff --git a/docs/screenshots/mobile-map.png b/docs/screenshots/mobile-map.png index 63491ee..20977b8 100644 Binary files a/docs/screenshots/mobile-map.png and b/docs/screenshots/mobile-map.png differ diff --git a/docs/screenshots/mobile-panel.png b/docs/screenshots/mobile-panel.png index 76a73fe..3359577 100644 Binary files a/docs/screenshots/mobile-panel.png and b/docs/screenshots/mobile-panel.png differ diff --git a/docs/screenshots/overview.png b/docs/screenshots/overview.png index 10485e6..ff14d4d 100644 Binary files a/docs/screenshots/overview.png and b/docs/screenshots/overview.png differ diff --git a/index.html b/index.html index 023f758..d31c2fa 100644 --- a/index.html +++ b/index.html @@ -23,6 +23,11 @@ <div id="standing" class="overlay hidden"></div> <div id="cursor" class="overlay hidden"></div> <div id="sightline" class="overlay hidden"></div> + <div id="status" class="status" role="status" aria-live="polite" hidden> + <p class="status-title">Tower is making the world</p> + <p id="status-line" class="status-line"></p> + <div class="status-bar"><div id="status-fill"></div></div> + </div> </main> <div id="scrim" class="scrim"></div> <button id="panel-toggle" type="button" aria-controls="panel" aria-expanded="false">Panel</button> @@ -37,10 +42,9 @@ <summary>View</summary> <label class="row" id="terrain-row"><input id="f-terrain" type="checkbox" checked /> Show terrain</label> <div id="map-options"> - <p class="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> + <p class="hint">Generated live from the world's features, in your browser. + On the exterior it is wrapped onto the hull — an x-ray of the inner + surface, not what Tower looks like from outside.</p> </div> <div id="exterior-options" class="hidden"> <label>Hull @@ -77,7 +81,10 @@ <p class="hint"><b>One finger looks. Two fingers move</b> — drag with two and the ground follows them, pinch to zoom in like a pair of binoculars. With a mouse: drag to look, shift-drag to move, scroll to zoom. - Selecting a place walks you there. Nothing inside here rotates: you, the + <b>Fly with the keyboard:</b> W A S D or the arrows to move, E to climb, + Q to descend, shift to go five times faster. Speed follows your height, + so the ground passes at the same apparent pace from a metre up or ten + kilometres. Selecting a place walks you there. Nothing inside here rotates: you, the ground and the air all turn together. The far cap is a window, so at night the sky through it wheels once every 7 min 29 s about a single motionless star — the only place the spin is obvious.</p> @@ -85,9 +92,9 @@ </details> <details class="sec" id="tools-section"> - <summary>Place</summary> + <summary>Add</summary> <div id="tools" class="tools"></div> - <p class="hint" id="tool-hint">Click a marker to select. Drag to move. Shift-click two points to measure a sightline.</p> + <p class="hint" id="tool-hint">Click a feature to select. Drag a marker to move it. Shift-click two points to measure a sightline.</p> </details> <details class="sec" id="world-section" open> diff --git a/package-lock.json b/package-lock.json index 8e4bd26..c4af37a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "tower", "version": "0.1.0", "dependencies": { + "comlink": "^4.4.2", + "simplex-noise": "^4.0.3", "three": "^0.180.0" }, "devDependencies": { @@ -804,6 +806,12 @@ "node": ">=18" } }, + "node_modules/comlink": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/comlink/-/comlink-4.4.2.tgz", + "integrity": "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g==", + "license": "Apache-2.0" + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1311,6 +1319,12 @@ "dev": true, "license": "ISC" }, + "node_modules/simplex-noise": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/simplex-noise/-/simplex-noise-4.0.3.tgz", + "integrity": "sha512-qSE2I4AngLQG7BXqoZj51jokT4WUXe8mOBrvfOXpci8+6Yu44+/dD5zqDpOx3Ux792eamTd2lLcI8jqFntk/lg==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/package.json b/package.json index 24076b4..360fc82 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,6 @@ "preview": "vite preview", "test": "vitest run", "test:watch": "vitest", - "terrain": "node tools/terrain.mjs", "screenshots": "node tools/screenshots.mjs" }, "devDependencies": { @@ -28,6 +27,8 @@ }, "homepage": "https://pavelsafronov.gitlab.io/tower/", "dependencies": { + "comlink": "^4.4.2", + "simplex-noise": "^4.0.3", "three": "^0.180.0" } } diff --git a/src/style.css b/src/style.css index 53c9f4a..267c25f 100644 --- a/src/style.css +++ b/src/style.css @@ -45,6 +45,21 @@ canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block #sightline { left: auto; right: 12px; } .hidden { display: none !important; } +/* The status dialog (world-generation.md §10.1). Over the world, never over the panel: + the author can keep reading notes while the world is made. */ +.status[hidden] { display: none; } +.status { + position: absolute; left: 50%; top: 42%; transform: translate(-50%, -50%); + width: min(420px, calc(100% - 32px)); padding: 18px 20px 20px; box-sizing: border-box; + background: rgba(11, 13, 18, 0.92); border: 1px solid var(--line); border-radius: 8px; + text-align: center; z-index: 5; pointer-events: none; +} +.status p { margin: 0; } +.status-title { font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--dim); } +.status-line { font-size: 15px; margin: 10px 0 14px !important; min-height: 1.3em; } +.status-bar { height: 3px; background: #1b2130; border-radius: 2px; overflow: hidden; } +#status-fill { height: 100%; width: 0; background: var(--accent); transition: width 0.25s ease-out; } + aside { background: var(--panel); border-left: 1px solid var(--line); overflow-y: auto; padding: 0 14px 24px; diff --git a/src/terrain/compile.ts b/src/terrain/compile.ts new file mode 100644 index 0000000..56964e6 --- /dev/null +++ b/src/terrain/compile.ts @@ -0,0 +1,465 @@ +/** + * The world compile. See world-generation.md §5.2. + * + * Turns the authored world file into plain data the elevation function can sample + * fast and from any thread: shapes unwrapped, rivers smoothed and given a long + * profile, lake levels resolved, mountain domes normalised to their summits. It is + * the only place that looks at the whole world at once. Everything after it — every + * tile, every pixel of the map, standing on the ground — only ever asks about one + * point. + * + * Deterministic: the same world file compiles to the same data on every machine. + * The result is structured-cloneable, so one worker compiles and the rest are handed + * the answer. + */ +import { RADIUS_M } from '../canon/constants.js' +import type { Feature, Lake, Mountains, River, Sea, World } from '../world/schema.js' +import { hash3 } from './noise.js' +import { + type Box, type Line, type Shape, nearestA, shapeBox, shapeOf, signedDistance, smoothLine, +} from './shapes.js' +import { elevationStage, prepare, Stage } from './field.js' + +export interface CUplift { + id: string + kind: 'desert' | 'forest' + shape: Shape + upliftM: number + edgeM: number + warpM: number + noiseAmpM: number + noiseScaleM: number + seed: number + box: Box +} + +export interface CPeak { + z: number + a: number + elevationM: number + /** Dome radius: the distance from the summit to the edge of the range. */ + radiusM: number + /** Multiplier that lands the dome's highest point on `elevationM`. */ + scale: number +} + +export interface CMountains { + id: string + shape: Shape + peaks: CPeak[] + heightM: number + ruggedness: number + snowlineM: number + /** Deepest point inside a peakless polygon, which is where its crest stands. */ + maxInsideM: number + /** Normaliser for a peakless polygon's dome. */ + scale: number + seed: number + box: Box +} + +export interface CBasin { + id: string + kind: 'lake' | 'sea' + shape: Shape + levelM: number + depthM: number + basin: 'bowl' | 'flooded' + shoreM: number + rimM: number + maxInsideM: number + seed: number + box: Box +} + +export interface CRiver { + id: string + line: Line + /** Water surface at each line point, metres. Never rises downstream. */ + level: number[] + /** Channel half-width at each point, metres. */ + halfWidth: number[] + /** Depth of the channel bed below the surface, metres. */ + bed: number[] + /** Flow at each point, m³/s, tributaries included. */ + flow: number[] + /** How far from the centre line the valley, or the embankment carrying it, can reach. */ + reach: number + box: Box + /** What the river ends in, resolved: a lake, sea or river id, or null. */ + outlet: string | null +} + +export interface CompiledWorld { + seed: number + ground: { baseM: number; reliefM: number } + uplifts: CUplift[] + mountains: CMountains[] + basins: CBasin[] + rivers: CRiver[] +} + +/** One line of the status dialog: a step that is about to run (world-generation.md §10.1). */ +export type StepListener = (label: string) => void + +const grow = (b: Box, m: number): Box => ({ z0: b.z0 - m, z1: b.z1 + m, a0: b.a0 - m, a1: b.a1 + m }) + +/** + * Hydraulic geometry (Leopold & Maddock). Width grows as the square root of flow and + * depth a little slower, so a river's size comes from how much water it carries + * rather than from a number typed in beside it. + */ +export const channelHalfWidth = (q: number): number => Math.max(3, 2 * Math.sqrt(q)) +export const channelBed = (q: number): number => Math.max(0.6, 0.25 * q ** 0.4) + +export function compileWorld(world: World, onStep: StepListener = () => {}): CompiledWorld { + onStep('Reading the plans') + const cw: CompiledWorld = { + seed: world.seed, + ground: { baseM: world.ground.baseM, reliefM: world.ground.reliefM }, + uplifts: [], mountains: [], basins: [], rivers: [], + } + const seedOf = (f: Feature): number => hash3(world.seed, f.seed, 0x5eed) + + // Order is the order of the elevation stack (field.ts): each stage may sample + // everything compiled before it, and nothing after. + for (const f of world.features) { + if (f.kind !== 'desert' && f.kind !== 'forest') continue + onStep(f.kind === 'desert' ? `Quoting the deserts of Earth in ${f.name}` : `Planting ${f.name}`) + const shape = shapeOf(f.geometry) + if (!shape) continue + cw.uplifts.push({ + id: f.id, kind: f.kind, shape, upliftM: f.upliftM, edgeM: f.edgeM, + warpM: f.edgeM * 0.65, + // A desert is broken up at a finer grain than a forest floor. + noiseAmpM: Math.min(f.kind === 'desert' ? 90 : 70, Math.abs(f.upliftM) * 0.3), + noiseScaleM: f.kind === 'desert' ? 9000 : 15_000, + seed: seedOf(f), + box: grow(shapeBox(shape), f.edgeM * 1.7), + }) + } + + for (const f of world.features) { + if (f.kind !== 'mountains') continue + onStep(`Raising ${f.name}`) + const m = compileMountains(f, seedOf(f), cw) + if (m) cw.mountains.push(m) + } + + for (const f of world.features) { + if (f.kind !== 'lake' && f.kind !== 'sea') continue + onStep(`Filling ${f.name}`) + const b = compileBasin(f, seedOf(f), cw) + if (b) cw.basins.push(b) + } + + compileRivers(world.features.filter((f): f is River => f.kind === 'river'), cw, onStep) + return cw +} + +// -------------------------------------------------------------------- mountains + +function compileMountains(f: Mountains, seed: number, cw: CompiledWorld): CMountains | null { + const shape = shapeOf(f.geometry) + if (!shape) return null + const m: CMountains = { + id: f.id, shape, peaks: [], heightM: f.heightM, ruggedness: f.ruggedness, + snowlineM: f.snowlineM, maxInsideM: 0, scale: 1, seed, box: shapeBox(shape), + } + if (shape.type === 'band') { + // The ridge line wanders by up to 35% of the half-width (field.ts), so allow for it. + m.box = grow(m.box, ((shape.zTo - shape.zFrom) / 2) * 0.35) + return m + } + + // Summits take their radius from how far they stand from the edge of the range. + for (const p of f.peaks) { + const a = p.theta * RADIUS_M + const radiusM = Math.max(2000, signedDistance(shape, p.z, a)) + m.peaks.push({ z: p.z, a, elevationM: p.elevationM, radiusM, scale: 1 }) + } + if (!m.peaks.length) m.maxInsideM = Math.max(1000, maxInside(shape)) + // The warp moves the dome by up to 30% of its radius, so the box has to allow it. + m.box = grow(shape.box, Math.max(...m.peaks.map((p) => p.radiusM), m.maxInsideM) * 0.35) + + /** + * Land the summit on its declared elevation. Ridged noise overshoots by an amount + * that depends on the noise, so the only honest way is to measure: sample the dome + * on a grid, take its highest point, and scale. The scale is a compile-time + * constant, so every tile still computes its own heights without asking any other. + * Each peak is measured alone, since a range takes the highest of its domes. + */ + const highest = (probe: CMountains, z0: number, a0: number, r: number): number => { + const rt = prepare({ ...cw, uplifts: [], mountains: [probe], basins: [], rivers: [] }) + const GRID = 96 + let max = 0 + for (let i = 0; i < GRID; i++) { + for (let j = 0; j < GRID; j++) { + const z = z0 - r + (2 * r * (i + 0.5)) / GRID + const a = a0 - r + (2 * r * (j + 0.5)) / GRID + max = Math.max(max, elevationStage(rt, a / RADIUS_M, z, 150, Stage.MountainsOnly)) + } + } + return max + } + for (const p of m.peaks) { + const max = highest({ ...m, peaks: [{ ...p, scale: 1 }] }, p.z, p.a, p.radiusM) + p.scale = max > 0 ? p.elevationM / max : 1 + } + if (!m.peaks.length) { + const b = shape.box + const max = highest({ ...m, scale: 1 }, (b.z0 + b.z1) / 2, (b.a0 + b.a1) / 2, + Math.max(b.z1 - b.z0, b.a1 - b.a0) / 2) + m.scale = max > 0 ? f.heightM / max : 1 + } + return m +} + +/** The greatest distance from any interior point to the edge, sampled. */ +function maxInside(shape: Shape): number { + if (shape.type === 'band') return (shape.zTo - shape.zFrom) / 2 + const b = shape.box + const N = 64 + let best = 0 + for (let i = 0; i < N; i++) { + for (let j = 0; j < N; j++) { + const z = b.z0 + ((b.z1 - b.z0) * (i + 0.5)) / N + const a = b.a0 + ((b.a1 - b.a0) * (j + 0.5)) / N + best = Math.max(best, signedDistance(shape, z, a)) + } + } + return best +} + +// ----------------------------------------------------------------------- basins + +function compileBasin(f: Lake | Sea, seed: number, cw: CompiledWorld): CBasin | null { + const shape = shapeOf(f.geometry) + if (!shape) return null + const flooded = f.kind === 'sea' || f.basin === 'flooded' + const b: CBasin = { + id: f.id, kind: f.kind, shape, + levelM: 0, depthM: f.depthM, + basin: flooded ? 'flooded' : 'bowl', + shoreM: f.shoreM, + rimM: f.kind === 'lake' ? f.rimM : 0, + maxInsideM: Math.max(10, maxInside(shape)), + seed, + box: grow(shapeBox(shape), flooded ? f.shoreM + 8000 : 50), + } + + const level = f.levelM + if (typeof level === 'number') { + b.levelM = level + return b + } + /** + * An automatic level fills the basin from its floor upward. Taking the level from + * the rim instead lets the water spill downhill and leaves a half-moon rather than + * a lake — the bug D-020 recorded for v1's tarns. + */ + const rt = prepare({ ...cw, basins: [...cw.basins, { ...b, levelM: -Infinity }], rivers: [] }) + let floor = Infinity + const box = shapeBox(shape) + const N = 48 + for (let i = 0; i < N; i++) { + for (let j = 0; j < N; j++) { + const z = box.z0 + ((box.z1 - box.z0) * (i + 0.5)) / N + const a = box.a0 + ((box.a1 - box.a0) * (j + 0.5)) / N + if (signedDistance(shape, z, a) <= 0) continue + floor = Math.min(floor, elevationStage(rt, a / RADIUS_M, z, 100, Stage.Basins)) + } + } + b.levelM = Number.isFinite(floor) ? floor + f.depthM * 0.6 : 0 + return b +} + +// ----------------------------------------------------------------------- rivers + +const RIVER_STEP_M = 200 +/** + * The gentlest a river is allowed to run: 2 m per km, about the Mississippi's lower + * reaches. Anything flatter is a canal, and a canal has to be pumped. + */ +const MIN_GRADE = 0.002 +/** Rise per metre of run on the flanks of ground built up to carry a river. */ +export const EMBANKMENT_SLOPE = 0.15 +/** How far past its end a river may be from the water it flows into. */ +const OUTLET_REACH_M = 3000 + +function compileRivers(rivers: River[], cw: CompiledWorld, onStep: StepListener): void { + const lines = new Map(rivers.map((r) => [r.id, smoothLine( + (r.geometry.type === 'polyline' ? r.geometry.points : []) as [number, number][], RIVER_STEP_M)])) + const endOf = (l: Line): [number, number] => [l.pts[l.pts.length - 2]!, l.pts[l.pts.length - 1]!] + + /** Where a river ends: into which basin or river, and where along that river. */ + type Outlet = { basin: CBasin } | { river: River; at: number } | null + const outletOf = (r: River): Outlet => { + const [ez, ea] = endOf(lines.get(r.id)!) + const wanted = r.outlet === 'auto' ? null : r.outlet + for (const b of cw.basins) { + if (wanted && b.id !== wanted) continue + if (wanted || signedDistance(b.shape, ez, ea) > -OUTLET_REACH_M) return { basin: b } + } + /** + * Into another river: the nearest river that passes the end point. A river whose + * own end is the nearest point is not a candidate — two tributaries meeting + * head-on at a confluence (Westwall Water and Eastwall Water at the gap, D-020) + * both flow into the river that carries on from there, not into each other. + */ + let best: Outlet = null + let bestD = OUTLET_REACH_M + for (const other of rivers) { + if (other.id === r.id || (wanted && other.id !== wanted)) continue + const l = lines.get(other.id)! + const last = l.pts.length / 2 - 1 + let d = Infinity, at = 0 + for (let i = 0; i <= last; i++) { + const di = Math.hypot(l.pts[2 * i]! - ez, nearestA(l.pts[2 * i + 1]!, ea) - ea) + if (di < d) { d = di; at = i } + } + if (at >= last - 2 && !wanted) continue + if (d < bestD || wanted) { bestD = d; best = { river: other, at } } + } + return best + } + const outlets = new Map(rivers.map((r) => [r.id, outletOf(r)])) + + // A river into a river is resolved after the river it joins. A cycle — two rivers + // each draining into the other — cannot happen in water, so it is broken here. + const order: River[] = [] + const state = new Map<string, 'visiting' | 'done'>() + const visit = (r: River): void => { + if (state.get(r.id)) return + state.set(r.id, 'visiting') + const o = outlets.get(r.id) + if (o && 'river' in o && state.get(o.river.id) !== 'visiting') visit(o.river) + state.set(r.id, 'done') + order.push(r) + } + rivers.forEach(visit) + + // Flow, children first: a tributary's whole flow joins its parent below the confluence. + const flows = new Map(rivers.map((r) => [r.id, lines.get(r.id)!.s.map(() => r.flowM3S)])) + for (const r of [...order].reverse()) { + const o = outlets.get(r.id) + if (!o || !('river' in o)) continue + const mine = flows.get(r.id)! + const theirs = flows.get(o.river.id)! + const total = mine[mine.length - 1]! + for (let i = o.at; i < theirs.length; i++) theirs[i] = theirs[i]! + total + } + + // Levels, parents first, since a tributary's mouth is its parent's surface. + const done = new Map<string, CRiver>() + const rt = prepare({ ...cw, rivers: [] }) + for (const r of order) { + onStep(`Teaching ${r.name} which way is down`) + const line = lines.get(r.id)! + const flow = flows.get(r.id)! + const n = line.s.length + const halfWidth = flow.map(channelHalfWidth) + const bed = flow.map(channelBed) + + // The ground the river crosses: land surface, or water where it crosses water. + const surface = new Array<number>(n) + for (let i = 0; i < n; i++) { + const s = rt.sample(line.pts[2 * i + 1]! / RADIUS_M, line.pts[2 * i]!, 60) + surface[i] = Math.max(s.h, s.water) + } + + const o = outlets.get(r.id) + // A river that rises in a lake leaves it at the lake's level: the tarns are the top + // of the world's water loop (design.md §1.11). + const [sz, sa] = [line.pts[0]!, line.pts[1]!] + const lake = cw.basins.find((b) => signedDistance(b.shape, sz, sa) > 0) + let mouth: number + if (o && 'basin' in o) mouth = o.basin.levelM + else if (o && 'river' in o && done.has(o.river.id)) mouth = done.get(o.river.id)!.level[o.at]! + else mouth = surface[n - 1]! - bed[n - 1]! + + /** + * The long profile: never rising downstream (design.md §1.9.3), and otherwise as + * close to the ground the river crosses as it can be. + * + * A Tower river exists only because the floor was shaped to make it descend, so + * where the ground rises across its course something has to give. Clamping the + * water to the ground lets one dip drag the whole river down after it — v1's world + * has a lowland 60 m high between the Girdle and the Dry Reach, and a river forced + * down into it would need a canyon 900 m deep for a hundred kilometres to reach The + * Narrows. Holding the water up at a graded profile instead needs an embankment + * over a kilometre high across that lowland. + * + * So the profile is the monotone one nearest the ground: an isotonic regression, + * which cuts where the ground is high and builds up where it is low in whatever + * balance moves the least earth. Where the ground dips and rises again the answer + * is a level pool, which is what an engineered river has there anyway. + */ + const total = line.s[n - 1]! || 1 + const cap = lake ? lake.levelM : typeof r.sourceM === 'number' ? r.sourceM : Infinity + /** + * With one refinement: water on a level has nowhere to go, so the fit is made to + * fall by at least `grade` per metre everywhere. Fitting `level + grade·s` as + * non-increasing is exactly that constraint, and the trend is taken back out after. + */ + const top = Math.min(cap, surface[0]!) + const grade = Math.min(MIN_GRADE, Math.max(0, (top - mouth) / total) * 0.5) || MIN_GRADE + /** + * The ends are held, by weight: the mouth has to meet the water it flows into, and + * a source in a lake has to leave at the lake's level. + */ + const target = surface.map((g, i) => g - bed[i]!) + const weight = target.map(() => 1) + target[n - 1] = mouth + weight[n - 1] = n + // An authored source height is only a ceiling: pinning the water to it where the + // ground is far lower would stand the river's first kilometre on a tower of fill. + if (lake) { target[0] = lake.levelM; weight[0] = n } + const fitted = nonIncreasing(target.map((v, i) => v + grade * line.s[i]!), weight) + /** + * Never below the water it drains into plus the grade still to fall. Where the whole + * course lies lower than the river it joins — a tributary drawn across a lowland to + * a confluence up on a plateau — this is the embankment that carries it there. + */ + const level = fitted.map((v, i) => + Math.min(cap, Math.max(mouth + grade * (total - line.s[i]!), v - grade * line.s[i]!))) + + // Far enough for the widest embankment this river has to build. + let fill = 0 + for (let i = 0; i < n; i++) fill = Math.max(fill, level[i]! - surface[i]!) + const reach = Math.max(...halfWidth) + Math.max(4500, fill / EMBANKMENT_SLOPE) + const c: CRiver = { + id: r.id, line, level, halfWidth, bed, flow, reach, + box: grow(line.box, reach), + outlet: o ? ('basin' in o ? o.basin.id : o.river.id) : null, + } + done.set(r.id, c) + cw.rivers.push(c) + } +} + +/** + * The weighted least-squares non-increasing sequence nearest `y`: pool adjacent + * violators. Linear time. Each block holds the weighted mean of the values pooled into it. + */ +export function nonIncreasing(y: readonly number[], w: readonly number[] = y.map(() => 1)): number[] { + const mean: number[] = [] + const weight: number[] = [] + const count: number[] = [] + y.forEach((v, i) => { + mean.push(v) + weight.push(w[i]!) + count.push(1) + // A later block higher than the one before it is a violation: merge them. + while (mean.length > 1 && mean[mean.length - 1]! > mean[mean.length - 2]!) { + const m1 = mean.pop()!, w1 = weight.pop()!, c1 = count.pop()! + const m0 = mean.pop()!, w0 = weight.pop()!, c0 = count.pop()! + mean.push((m0 * w0 + m1 * w1) / (w0 + w1)) + weight.push(w0 + w1) + count.push(c0 + c1) + } + }) + const out: number[] = [] + mean.forEach((m, k) => { for (let i = 0; i < count[k]!; i++) out.push(m) }) + return out +} diff --git a/src/terrain/field.ts b/src/terrain/field.ts new file mode 100644 index 0000000..90fb628 --- /dev/null +++ b/src/terrain/field.ts @@ -0,0 +1,292 @@ +/** + * The elevation function. See world-generation.md §5.3. + * + * One pure function of a point on the surface, used by every tile, every pixel of the + * map, the observer's feet and the tests — so there is exactly one answer to "how high + * is the ground here", and the view, the map and the camera cannot disagree about it + * the way the GPU and the CPU did under the rasters (D-035). + * + * The stack, in order: + * + * ground gentle relief everywhere + * + uplift desert and forest shelves + * + mountains the highest of any range here, on top of the land + * − basins lakes and seas, carved, with their water level + * ∧ rivers valleys cut down to each river's long profile + * + * Every noise call takes `detailM`, the shortest wavelength worth computing. A tile + * 30 km across passes a few hundred metres; a tile under the observer's feet passes + * a metre. Same function, same answer, fewer octaves where they could not be seen. + */ +import { RADIUS_M } from '../canon/constants.js' +import { type CBasin, type CMountains, type CRiver, type CUplift, type CompiledWorld, EMBANKMENT_SLOPE } from './compile.js' +import { type CylPoint, cylPoint, fbm, hash3, ridged } from './noise.js' +import { type Box, LineIndex, type Nearest, nearestA, nearestOnSegments, signedDistance } from './shapes.js' + +export const Stage = { + /** Only the mountain domes, on nothing: used to measure a summit. */ + MountainsOnly: 0, + /** Everything up to and including the basins, without rivers. */ + Basins: 3, + All: 4, +} as const +export type Stage = (typeof Stage)[keyof typeof Stage] + +/** What the world is at one point. */ +export interface Sample { + /** Ground elevation above the datum, metres. Lake and sea floors are below zero. */ + h: number + /** Water surface, or -Infinity where there is none. */ + water: number + /** + * How wide the water here is, metres: Infinity for a lake or sea, the channel for a + * river. A tile whose vertices are further apart than this cannot show it. + */ + waterWidthM: number + /** 0..1 claims on the surface. The shader turns these into colour. */ + desert: number + forest: number + /** Relief the mountains added here, metres: what decides bare rock. */ + relief: number + snowlineM: number +} + +const smoothstep = (e0: number, e1: number, x: number): number => { + const t = Math.min(1, Math.max(0, (x - e0) / (e1 - e0))) + return t * t * (3 - 2 * t) +} + +/** Polynomial smooth minimum: min(a, b), with the corner rounded over `k`. */ +const smin = (a: number, b: number, k: number): number => { + const h = Math.max(k - Math.abs(a - b), 0) / k + return Math.min(a, b) - h * h * k * 0.25 +} + +function inBox(b: Box, z: number, a: number): boolean { + if (z < b.z0 || z > b.z1) return false + if (!Number.isFinite(b.a0)) return true + const q = nearestA(a, (b.a0 + b.a1) / 2) + return q >= b.a0 && q <= b.a1 +} + +/** Seeds for each layer of a feature, so no two layers share a field. */ +const layer = (seed: number, n: number): number => hash3(seed, n, 0x1a7e) & 0x7fffffff + +/** Offset a point by low-frequency noise, so no outline reads as the shape it was drawn as. */ +function warp(seed: number, p: CylPoint, z: number, a: number, ampM: number, scaleM: number, detailM: number): [number, number] { + if (!ampM) return [z, a] + const dz = fbm(layer(seed, 91), p, scaleM, 3, detailM) + const da = fbm(layer(seed, 92), p, scaleM, 3, detailM) + return [z + dz * ampM, a + da * ampM] +} + +// --------------------------------------------------------------------- runtime + +/** + * A compiled world made ready to sample in this thread: the per-thread structures + * (segment indexes) that are not plain data and so never travel with it. + */ +export class Runtime { + private indexes: LineIndex[] + private out: Sample = { h: 0, water: -Infinity, waterWidthM: 0, desert: 0, forest: 0, relief: 0, snowlineM: 3000 } + private nearest: Nearest = { d: 0, seg: 0, t: 0 } + + constructor(readonly world: CompiledWorld) { + this.indexes = world.rivers.map((r) => new LineIndex(r.line, r.reach)) + } + + /** + * Everything the world is at (theta, z). Returns a shared object: read it before + * sampling again. + */ + sample(theta: number, z: number, detailM: number, upTo: Stage = Stage.All): Sample { + const w = this.world + const o = this.out + const a = theta * RADIUS_M + const p = cylPoint(theta, z) + o.water = -Infinity + o.waterWidthM = 0 + o.desert = 0 + o.forest = 0 + o.relief = 0 + o.snowlineM = 3000 + + if (upTo === Stage.MountainsOnly) { + o.h = 0 + for (const m of w.mountains) o.h = Math.max(o.h, mountain(m, p, z, a, detailM)) + return o + } + + o.h = ground(w, p, detailM) + for (const u of w.uplifts) if (inBox(u.box, z, a)) uplift(u, p, z, a, detailM, o) + let relief = 0 + for (const m of w.mountains) { + if (!inBox(m.box, z, a)) continue + const r = mountain(m, p, z, a, detailM) + if (r > relief) { relief = r; o.snowlineM = m.snowlineM } + } + o.h += relief + o.relief = relief + for (const b of w.basins) if (inBox(b.box, z, a)) basin(b, p, z, a, detailM, o) + if (upTo === Stage.Basins) return o + w.rivers.forEach((r, i) => { + if (inBox(r.box, z, a)) river(r, this.indexes[i]!, z, a, o, this.nearest) + }) + return o + } +} + +const runtimes = new WeakMap<CompiledWorld, Runtime>() + +export function prepare(world: CompiledWorld): Runtime { + let rt = runtimes.get(world) + if (!rt) { + rt = new Runtime(world) + runtimes.set(world, rt) + } + return rt +} + +/** Just the elevation, for callers that want nothing else. */ +export function elevationStage(rt: Runtime, theta: number, z: number, detailM: number, upTo: Stage = Stage.All): number { + return rt.sample(theta, z, detailM, upTo).h +} + +// ---------------------------------------------------------------------- layers + +/** + * The open ground. Five octaves of scale, from 78 km swells down to a few metres of + * unevenness underfoot. The broad layers are v1's; the last is new, and exists only + * because the ground can now be looked at from a metre away. + */ +function ground(w: CompiledWorld, p: CylPoint, detailM: number): number { + const s = w.seed + const k = w.ground.reliefM / 260 + return w.ground.baseM + + fbm(layer(s, 1), p, 78_000, 5, detailM) * w.ground.reliefM + + fbm(layer(s, 2), p, 12_000, 4, detailM) * 45 * k + + (ridged(layer(s, 3), p, 3400, 3, detailM) - 0.5) * 26 * k + + fbm(layer(s, 4), p, 1500, 4, detailM) * 26 * k + + fbm(layer(s, 5), p, 90, 6, detailM) * 3 * k +} + +/** A raised shelf with a soft, wandering edge: the desert plateau, the forest floor. */ +function uplift(u: CUplift, p: CylPoint, z: number, a: number, detailM: number, o: Sample): void { + const [wz, wa] = warp(u.seed, p, z, a, u.warpM, u.edgeM * 2, detailM) + const m = smoothstep(-u.edgeM, 0, signedDistance(u.shape, wz, wa)) + if (m <= 0) return + const n = fbm(layer(u.seed, 1), p, u.noiseScaleM, 4, detailM) + o.h += m * (u.upliftM + n * u.noiseAmpM) + // The claim is dithered by the same noise, so neighbouring regions meet on a ragged + // line rather than a ruled one. + const claim = smoothstep(0.25, 0.4, m + n * 0.45) + if (u.kind === 'desert') o.desert = Math.max(o.desert, claim) + else o.forest = Math.max(o.forest, claim) +} + +/** How high a range stands here, above the land under it. */ +function mountain(m: CMountains, p: CylPoint, z: number, a: number, detailM: number): number { + const s = m.shape + if (s.type === 'band') { + // A ring range: a ridge along the band's centre line, broken into peaks. + const half = (s.zTo - s.zFrom) / 2 + const [wz] = warp(m.seed, p, z, a, half * 0.35, 30_000, detailM) + const d = Math.abs(wz - (s.zFrom + s.zTo) / 2) + if (d > half) return 0 + const sigma = half / 4 + const bump = Math.exp(-(d * d) / (2 * sigma * sigma)) + let scale = 1 + 0.25 * fbm(layer(m.seed, 1), p, 5000, 3, detailM) + if (m.ruggedness > 0) { + const rg = ridged(layer(m.seed, 2), p, 14_000, 6, detailM) + scale *= 1 - m.ruggedness + m.ruggedness * rg * 1.6 + } + return bump * m.heightM * scale + } + + const dome = (dd: number, wp: CylPoint): number => { + const shape = Math.cos((dd * Math.PI) / 2) ** 1.7 + const rg = ridged(layer(m.seed, 3), wp, 15_000, 6, detailM) + // Ridges bite hardest on the flanks and least at the summit, which is what stops + // a massif reading as a smooth blister. + const flank = Math.sin(dd * Math.PI) * 0.8 + 0.2 + return shape * (1 - m.ruggedness * flank + m.ruggedness * flank * rg * 1.9) + } + + if (!m.peaks.length) { + const [wz, wa] = warp(m.seed, p, z, a, m.maxInsideM * 0.3, m.maxInsideM * 0.9, detailM) + const sd = signedDistance(s, wz, wa) + if (sd <= 0) return 0 + const dd = 1 - Math.min(1, sd / m.maxInsideM) + return m.heightM * m.scale * dome(dd, cylPoint(wa / RADIUS_M, wz)) + } + + let best = 0 + for (const pk of m.peaks) { + const reach = pk.radiusM * 1.35 + if (Math.abs(z - pk.z) > reach || Math.abs(nearestA(a, pk.a) - pk.a) > reach) continue + const [wz, wa] = warp(m.seed, p, z, a, pk.radiusM * 0.3, pk.radiusM * 0.87, detailM) + const dd = Math.hypot(wz - pk.z, nearestA(wa, pk.a) - pk.a) / pk.radiusM + if (dd >= 1) continue + best = Math.max(best, pk.elevationM * pk.scale * dome(dd, cylPoint(wa / RADIUS_M, wz))) + } + return best +} + +/** Carve a lake or sea basin and set its water where the floor is below its level. */ +function basin(b: CBasin, p: CylPoint, z: number, a: number, detailM: number, o: Sample): void { + if (b.basin === 'flooded') { + const [wz, wa] = warp(b.seed, p, z, a, Math.min(7000, b.shoreM * 0.5), 32_000, detailM) + /** + * A sea's shelf starts outside its band and is at full depth just inside it, so the + * water reaches the band's edge — the cap seas meet the caps. A lake's shelf starts + * at its outline and deepens inward, so the water sits inside the outline drawn for + * it, as v1's did. + */ + const sd = signedDistance(b.shape, wz, wa) + const m = b.kind === 'sea' + ? smoothstep(-b.shoreM, 0.2 * b.shoreM, sd) + : smoothstep(0, b.shoreM, sd) + if (m <= 0.001) return + o.h -= b.depthM * m ** 0.7 + if (m > 0.05 && o.h < b.levelM) { o.water = Math.max(o.water, b.levelM); o.waterWidthM = Infinity } + return + } + const sd = signedDistance(b.shape, z, a) + if (sd <= 0) return + const d = 1 - Math.min(1, sd / b.maxInsideM) + o.h -= b.depthM * Math.cos((d * Math.PI) / 2) ** 1.2 + if (d > 0.68) o.h += b.rimM * Math.sin(((d - 0.68) / 0.32) * Math.PI) + if (d < 0.85 && o.h < b.levelM) { o.water = Math.max(o.water, b.levelM); o.waterWidthM = Infinity } +} + +/** + * Shape the floor to carry a river: cut a valley down to its surface where the ground + * is too high, and build the ground up under it where the ground is too low. + * + * The valley's width is not a parameter: its walls rise at a fixed shape from the + * channel until they meet the ground, so the same river is a shallow trench on a plain + * and a canyon where it crosses high ground. An embankment is the same idea the other + * way up — flanks falling away at a fixed slope until they meet the ground. + */ +function river(r: CRiver, index: LineIndex, z: number, a: number, o: Sample, near: Nearest): void { + const segs = index.near(z, a) + if (!segs.length) return + nearestOnSegments(r.line, z, a, segs, near) + if (near.d > r.reach) return + const i = near.seg, t = near.t + const lerp = (arr: number[]): number => arr[i]! + (arr[i + 1]! - arr[i]!) * t + const level = lerp(r.level) + const hw = lerp(r.halfWidth) + const bed = lerp(r.bed) + const x = near.d - hw + const floor = level - bed * (1 - Math.min(1, near.d / hw) ** 2) - 0.2 + const cut = x <= 0 ? floor : level + 0.08 * x + (x * x) / 6000 + // The embankment's crest is the bank, a metre above the water, + // with a few metres of level top before the flanks fall away. + const fill = x <= 0 ? floor : level + 1 - Math.max(0, x - 25) * EMBANKMENT_SLOPE + o.h = smin(-smin(-o.h, -fill, 12), cut, 12) + if (x <= 0 && level > o.h) { + o.water = Math.max(o.water, level) + o.waterWidthM = Math.max(o.waterWidthM, 2 * hw) + } +} diff --git a/src/terrain/map-image.ts b/src/terrain/map-image.ts new file mode 100644 index 0000000..18651f8 --- /dev/null +++ b/src/terrain/map-image.ts @@ -0,0 +1,93 @@ +/** + * The map's picture of the world, rendered from the features. See world-generation.md §9. + * + * The map still draws an image under its vectors — but it is made here, live, from the + * same elevation function and the same palette as the 3D views, not painted ahead of + * time. Rows are rendered in bands so the pool can split the work. + * + * The hillshade is a cartographic convention, not Tower's lighting: Tower's sun is a + * line down the axis, which lights nearly shadowlessly. This is for reading the map. + */ +import { CIRCUMFERENCE_M, LENGTH_M, RADIUS_M } from '../canon/constants.js' +import type { Runtime } from './field.js' +import { cylPoint, fbm } from './noise.js' +import { colourOf } from './palette.js' +import { MATERIALS, materialsAt } from './tile.js' + +const HILLSHADE = { azimuthDeg: 315, altitudeDeg: 42, strength: 0.55 } + +/** + * RGBA for rows [y0, y1) of a width × height image of the unrolled surface. Row 0 is + * `thetaOrigin`, where the map is cut (D-013); column 0 is the sun cap. + */ +export function renderMapRows( + rt: Runtime, width: number, height: number, y0: number, y1: number, thetaOrigin: number, +): Uint8ClampedArray { + const mppX = LENGTH_M / width + const mppY = CIRCUMFERENCE_M / height + const detail = 2 * Math.min(mppX, mppY) + const rows = y1 - y0 + 2 + const shown = new Float64Array(rows * width) + const mats = new Float32Array((y1 - y0) * width * MATERIALS.length) + const seed = rt.world.seed + + for (let r = 0; r < rows; r++) { + const y = (((y0 - 1 + r) % height) + height) % height + const theta = thetaOrigin + ((y + 0.5) * mppY) / RADIUS_M + for (let x = 0; x < width; x++) { + const s = rt.sample(theta, (x + 0.5) * mppX, detail) + shown[r * width + x] = Math.max(s.h, s.water) + if (r >= 1 && r <= y1 - y0) materialsAt(s, 0, mats, ((r - 1) * width + x) * MATERIALS.length, Math.max(mppX, mppY)) + } + } + + const az = (HILLSHADE.azimuthDeg * Math.PI) / 180 + const alt = (HILLSHADE.altitudeDeg * Math.PI) / 180 + const lx = Math.cos(alt) * Math.sin(az) + const ly = Math.cos(alt) * Math.cos(az) + const lz = Math.sin(alt) + const out = new Uint8ClampedArray((y1 - y0) * width * 4) + const colour = [0, 0, 0] + const m = new Float32Array(MATERIALS.length) + + for (let r = 1; r <= y1 - y0; r++) { + const y = y0 + r - 1 + const theta = thetaOrigin + ((y + 0.5) * mppY) / RADIUS_M + for (let x = 0; x < width; x++) { + const k = r * width + x + const h = shown[k]! + const nx = (shown[r * width + Math.max(0, x - 1)]! - shown[r * width + Math.min(width - 1, x + 1)]!) / (2 * mppX) + const ny = (shown[k - width]! - shown[k + width]!) / (2 * mppY) + const slope = Math.hypot(nx, ny) + const base = (r - 1) * width + x + for (let i = 0; i < MATERIALS.length; i++) m[i] = mats[base * MATERIALS.length + i]! + m[4] = m[4]! * (1 - Math.min(1, Math.max(0, (slope - 0.55) / 0.55))) + colourOf(m, h, colour) + + let shade = (nx * lx + ny * ly + lz) / Math.hypot(nx, ny, 1) + shade = 1 - HILLSHADE.strength + HILLSHADE.strength * Math.max(0, shade) * 1.35 + if (m[5]! > 0.5) { + shade = 0.93 + 0.07 * shade + } else { + /** + * Two layers of variation, because one was not enough to stop a region reading + * as flat paint (D-052). The broad one dims and brightens; the fine one mottles, + * pushing the channels apart so ground reads as patchy cover over soil. + */ + const p = cylPoint(theta, (x + 0.5) * mppX) + const j = 0.5 + 0.4 * fbm(seed ^ 733, p, 5200, 3, detail) + const mo = 0.4 * fbm(seed ^ 941, p, 1150, 4, detail) * 0.13 + const kk = 0.9 + 0.2 * j + colour[0] = colour[0]! * (kk - mo * 0.55) + colour[1] = colour[1]! * (kk + mo) + colour[2] = colour[2]! * (kk - mo * 0.25) + } + const o = base * 4 + out[o] = colour[0]! * shade + out[o + 1] = colour[1]! * shade + out[o + 2] = colour[2]! * shade + out[o + 3] = 255 + } + } + return out +} diff --git a/src/terrain/noise.ts b/src/terrain/noise.ts new file mode 100644 index 0000000..635075a --- /dev/null +++ b/src/terrain/noise.ts @@ -0,0 +1,115 @@ +/** + * Seeded noise, evaluated on the cylinder itself. See world-generation.md §5.3, §5.5. + * + * **Why 3D noise for a 2D surface.** The surface wraps around theta. v1 sampled 2D + * value noise on the unrolled map and had to snap the lattice to a whole number of + * cells around the circumference, or the terrain tore at the seam (D-019). Sampling + * 3D simplex noise at the actual point on the cylinder, (R cos θ, R sin θ, z), has no + * seam to tear: theta = 0 and theta = 2π are the same point in space, so they get the + * same value by construction. + * + * Everything here is deterministic. There is no Math.random: randomness comes from a + * seed, through `mulberry32`, and the same seed gives the same world on every machine. + */ +import { createNoise3D, type NoiseFunction3D } from 'simplex-noise' +import { RADIUS_M } from '../canon/constants.js' + +/** + * 32-bit integer hash of up to three integers. Math.imul throughout: the obvious + * float version silently loses precision on large products (D-019). + */ +export function hash3(a: number, b: number, c: number): number { + let h = Math.imul(a | 0, 374761393) ^ Math.imul(b | 0, 668265263) ^ Math.imul(c | 0, 1274126177) + h = Math.imul(h ^ (h >>> 15), 2246822519) + h = Math.imul(h ^ (h >>> 13), 3266489917) + return (h ^ (h >>> 16)) >>> 0 +} + +/** A seeded PRNG in [0, 1). Small, fast, and good enough for building permutation tables. */ +export function mulberry32(seed: number): () => number { + let a = seed >>> 0 + return () => { + a = (a + 0x6d2b79f5) | 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +const tables = new Map<number, NoiseFunction3D>() + +/** One simplex field per seed, built once per thread. */ +export function simplex(seed: number): NoiseFunction3D { + let n = tables.get(seed) + if (!n) { + n = createNoise3D(mulberry32(seed)) + tables.set(seed, n) + } + return n +} + +/** A point on the cylinder, in metres, precomputed once per sample. */ +export interface CylPoint { x: number; y: number; z: number } + +export function cylPoint(theta: number, z: number): CylPoint { + return { x: RADIUS_M * Math.cos(theta), y: RADIUS_M * Math.sin(theta), z } +} + +/** + * Octave offsets, so successive octaves of one field do not line up on the same + * lattice. Irrational-ish and large relative to one cell. + */ +const OX = [0, 131.7, -217.3, 311.9, -419.1, 523.3, -631.7, 739.9, -843.1, 953.3, + -1061.7, 1171.9, -1277.3, 1381.1, -1489.7, 1597.3] + +/** + * Fractal simplex noise, in roughly [-1, 1]. + * + * `minWavelengthM` is the level of detail. Octaves shorter than it are skipped, and + * the sum is still normalised by the *full* amplitude, so dropping an octave removes + * detail without shifting the value — a coarse tile and a fine tile of the same + * ground agree to within the octaves the coarse one cannot show anyway. + */ +export function fbm( + seed: number, p: CylPoint, scaleM: number, octaves: number, minWavelengthM = 0, gain = 0.5, +): number { + const n = simplex(seed) + let sum = 0, amp = 1, norm = 0, wl = scaleM + for (let o = 0; o < octaves; o++) { + norm += amp + if (wl >= minWavelengthM) { + const f = 1 / wl + const off = OX[o]! + sum += n(p.x * f + off, p.y * f - off, p.z * f + off * 0.5) * amp + } + amp *= gain + wl /= 2 + } + return sum / norm +} + +/** + * Ridged fractal noise in [0, 1]: absolute-valued and inverted, which makes crests. + * Same level-of-detail rule as `fbm`. + */ +export function ridged( + seed: number, p: CylPoint, scaleM: number, octaves: number, minWavelengthM = 0, +): number { + const n = simplex(seed) + let sum = 0, amp = 1, norm = 0, wl = scaleM + for (let o = 0; o < octaves; o++) { + norm += amp + if (wl >= minWavelengthM) { + const f = 1 / wl + const off = OX[o]! + const v = 1 - Math.abs(n(p.x * f - off, p.y * f + off, p.z * f - off * 0.5)) + sum += v * v * amp + } else { + // A skipped ridged octave still contributes its mean, or coarse tiles sag. + sum += 0.47 * amp + } + amp *= 0.5 + wl /= 2 + } + return sum / norm +} diff --git a/src/terrain/palette.ts b/src/terrain/palette.ts new file mode 100644 index 0000000..4bced05 --- /dev/null +++ b/src/terrain/palette.ts @@ -0,0 +1,97 @@ +/** + * What the ground looks like, from what it is. See world-generation.md §6.2. + * + * Tiles carry material weights, not colours (tile.ts). This turns weights and an + * elevation into colour, twice over from one table: in TypeScript for the map, and as + * GLSL for the 3D views — so the map and the view are the same colours by + * construction rather than by two lists kept in step by hand. + * + * The ramps are v1's hypsometric tints, sRGB 0..255 against elevation in metres. + */ +type Ramp = readonly (readonly [number, readonly [number, number, number]])[] + +export const RAMPS: Record<'plain' | 'desert' | 'forest' | 'rock', Ramp> = { + plain: [[-40, [104, 118, 88]], [120, [118, 130, 92]], [340, [132, 138, 98]], + [700, [140, 138, 106]], [1600, [146, 138, 120]]], + desert: [[400, [198, 178, 132]], [700, [186, 160, 112]], [1000, [166, 138, 96]], + [1400, [148, 120, 86]]], + forest: [[80, [52, 86, 54]], [260, [62, 100, 58]], [500, [76, 108, 64]], [1000, [98, 114, 76]]], + rock: [[600, [104, 106, 96]], [1400, [124, 122, 114]], [2200, [146, 142, 136]], + [2900, [166, 162, 156]]], +} +export const SNOW = [247, 249, 253] as const +/** Shallow water, and how much darker it gets at full depth. */ +export const WATER = [58, 96, 132] as const +export const WATER_DEEPEN = [20, 26, 24] as const + +export function ramp(r: Ramp, h: number, out: number[]): void { + let c = r[0]![1] + if (h > r[0]![0]) { + c = r[r.length - 1]![1] + for (let i = 1; i < r.length; i++) { + if (h <= r[i]![0]) { + const t = (h - r[i - 1]![0]) / (r[i]![0] - r[i - 1]![0]) + const a = r[i - 1]![1], b = r[i]![1] + out[0] = a[0] + (b[0] - a[0]) * t + out[1] = a[1] + (b[1] - a[1]) * t + out[2] = a[2] + (b[2] - a[2]) * t + return + } + } + } + out[0] = c[0]; out[1] = c[1]; out[2] = c[2] +} + +/** + * sRGB 0..255 for a material mix at an elevation. `m` is tile.ts MATERIALS order: + * plain, desert, forest, rock, snow, water, depth. + */ +export function colourOf(m: ArrayLike<number>, h: number, out: number[]): void { + const tmp = [0, 0, 0] + let r = 0, g = 0, b = 0, sum = 0 + const names = ['plain', 'desert', 'forest', 'rock'] as const + for (let i = 0; i < 4; i++) { + const w = m[i]! + if (w <= 0) continue + ramp(RAMPS[names[i]!], h, tmp) + r += tmp[0]! * w; g += tmp[1]! * w; b += tmp[2]! * w + sum += w + } + if (sum > 0) { r /= sum; g /= sum; b /= sum } + const snow = m[4]! + r += (SNOW[0] - r) * snow; g += (SNOW[1] - g) * snow; b += (SNOW[2] - b) * snow + const water = m[5]!, d = m[6]! + r += (WATER[0] - WATER_DEEPEN[0] * d - r) * water + g += (WATER[1] - WATER_DEEPEN[1] * d - g) * water + b += (WATER[2] - WATER_DEEPEN[2] * d - b) * water + out[0] = r; out[1] = g; out[2] = b +} + +const f = (x: number): string => x.toFixed(4) +const vec = (c: readonly number[]): string => `vec3(${f(c[0]! / 255)}, ${f(c[1]! / 255)}, ${f(c[2]! / 255)})` + +/** A ramp as a GLSL function of elevation in metres, returning sRGB 0..1. */ +function glslRamp(name: string, r: Ramp): string { + const lines = [`vec3 ${name}(float h) {`, ` vec3 c = ${vec(r[0]![1])};`] + for (let i = 1; i < r.length; i++) { + const [h0] = r[i - 1]!, [h1, c1] = r[i]! + lines.push(` c = mix(c, ${vec(c1)}, clamp((h - ${f(h0)}) / ${f(h1 - h0)}, 0.0, 1.0));`) + } + lines.push(' return c;', '}') + return lines.join('\n') +} + +/** The palette as GLSL: `groundColour(matA, matB, h)` in sRGB 0..1. */ +export const PALETTE_GLSL = [ + glslRamp('rampPlain', RAMPS.plain), + glslRamp('rampDesert', RAMPS.desert), + glslRamp('rampForest', RAMPS.forest), + glslRamp('rampRock', RAMPS.rock), + `vec3 groundColour(vec4 a, vec4 b, float h) { + float sum = max(a.x + a.y + a.z + a.w, 1e-3); + vec3 c = (rampPlain(h) * a.x + rampDesert(h) * a.y + rampForest(h) * a.z + rampRock(h) * a.w) / sum; + c = mix(c, ${vec(SNOW)}, b.x); + vec3 water = ${vec(WATER)} - ${vec(WATER_DEEPEN)} * b.z; + return mix(c, water, b.y); +}`, +].join('\n\n') diff --git a/src/terrain/service.ts b/src/terrain/service.ts new file mode 100644 index 0000000..38d1c45 --- /dev/null +++ b/src/terrain/service.ts @@ -0,0 +1,200 @@ +/** + * The terrain service: the main thread's handle on world generation. + * See world-generation.md §5. + * + * Owns the worker pool. When the world changes in a way that changes its terrain, it + * has the first worker compile it — step by step, for the status dialog — hands the + * result to every worker, renders the map from it, and tells the views. After that + * the views ask it for tiles, and the observer asks it where the ground is. + * + * There is one of these for the whole app. The map, both 3D views and the panel all + * see the same world at the same generation. + */ +import { type Remote, proxy, wrap } from 'comlink' +import { mapThetaOrigin } from '../canon/geometry.js' +import { type World, terrainSignature } from '../world/schema.js' +import type { CompiledWorld } from './compile.js' +import { type Runtime, prepare } from './field.js' +import type { TileData, TileGrid, TileKey } from './tile.js' +import type { WorkerApi } from './worker.js' + +/** The map image, matching v1's raster so the map reads at the same resolution. */ +export const MAP_WIDTH = 2048 +export const MAP_HEIGHT = 1192 + +export interface Progress { + busy: boolean + /** The line the status dialog shows. */ + label: string + /** Steps finished, and how many there are in all. */ + done: number + total: number + /** When this generation started, performance.now(). */ + startedAt: number +} + +type Listener = () => void + +interface Slot { + api: Remote<WorkerApi> + inFlight: number +} + +export class TerrainService { + private slots: Slot[] + private signature = '' + /** Bumped on every terrain change. Anything built for an older one is thrown away. */ + generation = 0 + compiled: CompiledWorld | null = null + private runtime: Runtime | null = null + /** The map image of the current generation, or null until it is drawn. */ + mapCanvas: HTMLCanvasElement | null = null + progress: Progress = { busy: false, label: '', done: 0, total: 1, startedAt: 0 } + + private worldListeners = new Set<Listener>() + private mapListeners = new Set<Listener>() + private progressListeners = new Set<Listener>() + + constructor() { + const cores = navigator.hardwareConcurrency || 4 + const size = Math.max(1, Math.min(6, cores - 1)) + this.slots = Array.from({ length: size }, () => ({ + api: wrap<WorkerApi>(new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' })), + inFlight: 0, + })) + } + + get workers(): number { + return this.slots.length + } + + /** Called when a new generation is ready for tiles. */ + onWorld(fn: Listener): () => void { + this.worldListeners.add(fn) + return () => this.worldListeners.delete(fn) + } + + onMap(fn: Listener): () => void { + this.mapListeners.add(fn) + return () => this.mapListeners.delete(fn) + } + + onProgress(fn: Listener): () => void { + this.progressListeners.add(fn) + return () => this.progressListeners.delete(fn) + } + + private setProgress(p: Partial<Progress>): void { + this.progress = { ...this.progress, ...p } + for (const fn of this.progressListeners) fn() + } + + /** + * Generate the world, if its terrain has changed. A rename, a blurb or a note does + * not change the terrain and does nothing here (schema.ts `terrainSignature`). + */ + async setWorld(world: World): Promise<void> { + const signature = terrainSignature(world) + if (signature === this.signature) return + this.signature = signature + const gen = ++this.generation + + // Every compile step, plus drawing the map. + const counted = world.features.filter((f) => + ['desert', 'forest', 'mountains', 'lake', 'sea', 'river'].includes(f.kind)).length + const total = 1 + counted + 1 + let done = 0 + this.setProgress({ busy: true, label: 'Reading the plans', done: 0, total, startedAt: performance.now() }) + + const compiled = await this.slots[0]!.api.compile(world, proxy((label: string) => { + if (gen !== this.generation) return + this.setProgress({ label, done: done++ }) + })) + if (gen !== this.generation) return + + await Promise.all(this.slots.map((s) => s.api.load(compiled, gen))) + if (gen !== this.generation) return + this.compiled = compiled + this.runtime = prepare(compiled) + for (const fn of this.worldListeners) fn() + + this.setProgress({ label: 'Drawing the map', done: total - 1 }) + const canvas = await this.renderMap(gen) + if (gen !== this.generation || !canvas) return + this.mapCanvas = canvas + for (const fn of this.mapListeners) fn() + this.setProgress({ busy: false, done: total }) + } + + /** The map image, rendered in bands across the whole pool. */ + private async renderMap(gen: number): Promise<HTMLCanvasElement | null> { + const bands = this.slots.length * 4 + const per = Math.ceil(MAP_HEIGHT / bands) + const origin = mapThetaOrigin() + const jobs: Promise<{ y0: number; rows: Uint8ClampedArray | null }>[] = [] + for (let b = 0; b < bands; b++) { + const y0 = b * per + const y1 = Math.min(MAP_HEIGHT, y0 + per) + if (y0 >= y1) break + jobs.push(this.run((api) => api.mapRows(gen, MAP_WIDTH, MAP_HEIGHT, y0, y1, origin)) + .then((rows) => ({ y0, rows }))) + } + const parts = await Promise.all(jobs) + if (gen !== this.generation || parts.some((p) => !p.rows)) return null + const canvas = document.createElement('canvas') + canvas.width = MAP_WIDTH + canvas.height = MAP_HEIGHT + const ctx = canvas.getContext('2d') + if (!ctx) return null + for (const { y0, rows } of parts) { + ctx.putImageData(new ImageData(rows as Uint8ClampedArray<ArrayBuffer>, MAP_WIDTH), 0, y0) + } + return canvas + } + + /** Run a job on the least busy worker. */ + private async run<T>(job: (api: Remote<WorkerApi>) => Promise<T>): Promise<T> { + const slot = this.slots.reduce((a, b) => (b.inFlight < a.inFlight ? b : a)) + slot.inFlight++ + try { + return await job(slot.api) + } finally { + slot.inFlight-- + } + } + + /** How many jobs are queued or running across the pool. */ + get inFlight(): number { + return this.slots.reduce((n, s) => n + s.inFlight, 0) + } + + /** A tile of the current generation, or null if the world changed before it was built. */ + tile(t: TileKey, n: TileGrid): Promise<TileData | null> { + const gen = this.generation + return this.run((api) => api.tile(gen, t, n)) + } + + /** + * The surface under a point, in metres: the ground, or the water standing on it. + * Full detail, since this is where somebody's feet are. Zero until the first world + * has compiled, which is standing on the datum. + */ + surfaceM(theta: number, z: number): number { + if (!this.runtime) return 0 + const s = this.runtime.sample(theta, z, 1) + return Math.max(s.h, s.water) + } +} + +let instance: TerrainService | null = null + +/** The one service. Built on first use, so nothing pays for workers it never asked for. */ +export function terrain(): TerrainService { + if (!instance) { + instance = new TerrainService() + // A handle for the console, beside the interior's `tower` and `towerDump` (D-058): + // what generation the world is at, and what the pool is doing. + ;(window as unknown as Record<string, unknown>)['towerTerrain'] = instance + } + return instance +} diff --git a/src/terrain/shapes.ts b/src/terrain/shapes.ts new file mode 100644 index 0000000..4a81c93 --- /dev/null +++ b/src/terrain/shapes.ts @@ -0,0 +1,211 @@ +/** + * Shapes on the unrolled surface, for the generators. See world-generation.md §5.3. + * + * Everything here works in (z, a): z metres along the axis and a = θR metres around + * it. The unrolled plane is exact — unrolling a cylinder preserves lengths — so a + * distance here is a distance walked on the floor. The only complication is that + * `a` wraps every circumference, and every function below handles it the same way: + * a shape is stored unwrapped around its own first vertex, and a query point is + * moved by whole circumferences to the copy of itself nearest the shape. + * + * All of this is plain data, so a compiled world can be posted to a worker. + */ +import { CIRCUMFERENCE_M, RADIUS_M } from '../canon/constants.js' +import type { Geometry } from '../world/schema.js' + +const C = CIRCUMFERENCE_M + +/** Bring `a` to within half a circumference of `ref`. */ +export function nearestA(a: number, ref: number): number { + return a - Math.round((a - ref) / C) * C +} + +export interface Box { z0: number; z1: number; a0: number; a1: number } + +export type Shape = + | { type: 'band'; zFrom: number; zTo: number } + /** Flat [z, a, z, a, ...] with `a` unwrapped so no edge jumps the seam. */ + | { type: 'polygon'; pts: number[]; box: Box } + +export interface Line { + /** Flat [z, a, ...], unwrapped, dense enough to treat as straight segments. */ + pts: number[] + /** Distance along the line at each point, metres. */ + s: number[] + box: Box +} + +/** Unwrap a list of [theta, z] into flat [z, a] with no jumps across the seam. */ +function unwrap(points: readonly [number, number][]): number[] { + const out: number[] = [] + let prevA = 0 + points.forEach(([theta, z], i) => { + let a = theta * RADIUS_M + if (i > 0) a = nearestA(a, prevA) + out.push(z, a) + prevA = a + }) + return out +} + +function boxOf(pts: readonly number[]): Box { + const b = { z0: Infinity, z1: -Infinity, a0: Infinity, a1: -Infinity } + for (let i = 0; i < pts.length; i += 2) { + b.z0 = Math.min(b.z0, pts[i]!); b.z1 = Math.max(b.z1, pts[i]!) + b.a0 = Math.min(b.a0, pts[i + 1]!); b.a1 = Math.max(b.a1, pts[i + 1]!) + } + return b +} + +export function shapeOf(g: Geometry): Shape | null { + if (g.type === 'band') return { type: 'band', zFrom: g.zFrom, zTo: g.zTo } + if (g.type === 'polygon') { + const pts = unwrap(g.points) + return { type: 'polygon', pts, box: boxOf(pts) } + } + return null +} + +/** + * Signed distance to a shape's boundary: positive inside, negative outside, metres. + * The generators all key off this, so a lake or a desert can be any outline, not + * only the ellipses and rectangles v1 knew. + */ +export function signedDistance(shape: Shape, z: number, a: number): number { + if (shape.type === 'band') return Math.min(z - shape.zFrom, shape.zTo - z) + const { pts, box } = shape + const q = nearestA(a, (box.a0 + box.a1) / 2) + const n = pts.length / 2 + let inside = false + let best = Infinity + for (let i = 0, j = n - 1; i < n; j = i++) { + const zi = pts[2 * i]!, ai = pts[2 * i + 1]! + const zj = pts[2 * j]!, aj = pts[2 * j + 1]! + if ((ai > q) !== (aj > q) && z < ((zj - zi) * (q - ai)) / (aj - ai) + zi) inside = !inside + const ez = zj - zi, ea = aj - ai + const len2 = ez * ez + ea * ea || 1 + const t = Math.max(0, Math.min(1, ((z - zi) * ez + (q - ai) * ea) / len2)) + const d = Math.hypot(z - (zi + ez * t), q - (ai + ea * t)) + if (d < best) best = d + } + return inside ? best : -best +} + +/** Rough lower bound on the distance from a box to a shape, for rejecting tiles. */ +export function shapeBox(shape: Shape): Box { + if (shape.type === 'band') return { z0: shape.zFrom, z1: shape.zTo, a0: -Infinity, a1: Infinity } + return shape.box +} + +/** True if two boxes, each grown by `margin`, overlap anywhere around the circumference. */ +export function boxesOverlap(p: Box, q: Box, margin: number): boolean { + if (p.z1 + margin < q.z0 || q.z1 + margin < p.z0) return false + if (!Number.isFinite(p.a0) || !Number.isFinite(q.a0)) return true + // Try q shifted to the copy nearest p. + const shift = nearestA((q.a0 + q.a1) / 2, (p.a0 + p.a1) / 2) - (q.a0 + q.a1) / 2 + return !(p.a1 + margin < q.a0 + shift || q.a1 + shift + margin < p.a0) +} + +/** + * Catmull–Rom through authored waypoints, resampled every `stepM` metres. A river + * drawn as eighteen clicks should curve, not zigzag. + */ +export function smoothLine(points: readonly [number, number][], stepM: number): Line { + const raw = unwrap(points) + const n = raw.length / 2 + const at = (i: number, k: 0 | 1): number => raw[2 * Math.max(0, Math.min(n - 1, i)) + k]! + const dense: number[] = [] + const PER = 24 + for (let i = 0; i < n - 1; i++) { + for (let s = 0; s < PER; s++) { + const u = s / PER, u2 = u * u, u3 = u2 * u + for (const k of [0, 1] as const) { + const p0 = at(i - 1, k), p1 = at(i, k), p2 = at(i + 1, k), p3 = at(i + 2, k) + dense.push(0.5 * (2 * p1 + (p2 - p0) * u + (2 * p0 - 5 * p1 + 4 * p2 - p3) * u2 + + (-p0 + 3 * p1 - 3 * p2 + p3) * u3)) + } + } + } + dense.push(at(n - 1, 0), at(n - 1, 1)) + + // Resample at an even spacing, so distance along the line is simple to reason about. + const pts: number[] = [dense[0]!, dense[1]!] + const s: number[] = [0] + let carried = 0 + let total = 0 + for (let i = 2; i < dense.length; i += 2) { + const z0 = dense[i - 2]!, a0 = dense[i - 1]!, z1 = dense[i]!, a1 = dense[i + 1]! + const seg = Math.hypot(z1 - z0, a1 - a0) + let along = stepM - carried + while (along <= seg) { + const t = along / seg + pts.push(z0 + (z1 - z0) * t, a0 + (a1 - a0) * t) + s.push(total + along) + along += stepM + } + carried = seg - (along - stepM) + total += seg + } + const lastZ = dense[dense.length - 2]!, lastA = dense[dense.length - 1]! + if (Math.hypot(lastZ - pts[pts.length - 2]!, lastA - pts[pts.length - 1]!) > 1) { + pts.push(lastZ, lastA) + s.push(total) + } + return { pts, s, box: boxOf(pts) } +} + +/** Nearest point on a line: distance to it, and which segment and how far along. */ +export interface Nearest { d: number; seg: number; t: number } + +/** + * Distance from (z, a) to a line. Linear in the number of segments, which is why + * callers go through a `LineIndex` when they have many samples to take. + */ +export function nearestOnSegments( + line: Line, z: number, a: number, segs: Iterable<number>, out: Nearest, +): Nearest { + const q = nearestA(a, (line.box.a0 + line.box.a1) / 2) + out.d = Infinity + for (const i of segs) { + const z0 = line.pts[2 * i]!, a0 = line.pts[2 * i + 1]! + const ez = line.pts[2 * i + 2]! - z0, ea = line.pts[2 * i + 3]! - a0 + const len2 = ez * ez + ea * ea || 1 + const t = Math.max(0, Math.min(1, ((z - z0) * ez + (q - a0) * ea) / len2)) + const d = Math.hypot(z - (z0 + ez * t), q - (a0 + ea * t)) + if (d < out.d) { out.d = d; out.seg = i; out.t = t } + } + return out +} + +/** + * A bucket grid over a line's segments, built per thread (it is not plain data and + * never needs to travel). Turns thousands of distance tests per sample into a few. + */ +export class LineIndex { + private cells = new Map<number, number[]>() + private readonly cell: number + constructor(readonly line: Line, reach: number) { + this.cell = Math.max(500, reach) + const nSeg = line.pts.length / 2 - 1 + for (let i = 0; i < nSeg; i++) { + const z0 = Math.min(line.pts[2 * i]!, line.pts[2 * i + 2]!) - reach + const z1 = Math.max(line.pts[2 * i]!, line.pts[2 * i + 2]!) + reach + const a0 = Math.min(line.pts[2 * i + 1]!, line.pts[2 * i + 3]!) - reach + const a1 = Math.max(line.pts[2 * i + 1]!, line.pts[2 * i + 3]!) + reach + for (let cz = Math.floor(z0 / this.cell); cz <= Math.floor(z1 / this.cell); cz++) { + for (let ca = Math.floor(a0 / this.cell); ca <= Math.floor(a1 / this.cell); ca++) { + const key = cz * 100_003 + ca + let list = this.cells.get(key) + if (!list) this.cells.set(key, (list = [])) + list.push(i) + } + } + } + } + + /** Segments that could be within `reach` of the point, or none. */ + near(z: number, a: number): readonly number[] { + const q = nearestA(a, (this.line.box.a0 + this.line.box.a1) / 2) + return this.cells.get(Math.floor(z / this.cell) * 100_003 + Math.floor(q / this.cell)) ?? [] + } +} diff --git a/src/terrain/tile.ts b/src/terrain/tile.ts new file mode 100644 index 0000000..37739b1 --- /dev/null +++ b/src/terrain/tile.ts @@ -0,0 +1,239 @@ +/** + * Terrain tiles: the quadtree scheme and the mesh data for one tile. + * See world-generation.md §6.1. + * + * The unrolled surface is cut into a quadtree. Level 0 is 16 tiles along the axis by + * 10 around it — 33.75 × 31.4 km each — and each level halves them, down to level 10 + * at 33 × 31 m. Every tile is the same grid of vertices, so a near tile has fine + * spacing and a far one coarse. + * + * A tile is built in a worker and handed back as typed arrays. Its vertices are + * already bent onto the cylinder and given **relative to the tile's own centre**, in + * kilometres. That is a precision decision, not a convenience: float32 at 50 km is + * about 6 mm, which is visible jitter on anything small, while relative to a centre + * at most 24 km away it is under a millimetre, and at level 10 it is nothing. + */ +import { CIRCUMFERENCE_M, LENGTH_M, RADIUS_M } from '../canon/constants.js' +import type { Runtime, Sample } from './field.js' + +export const ROOTS_ALONG = 16 +export const ROOTS_AROUND = 10 +export const MAX_LEVEL = 10 + +export interface TileKey { level: number; i: number; j: number } + +export const tileKey = (t: TileKey): string => `${t.level}/${t.i}/${t.j}` + +/** Tile size in metres along the axis and around it. */ +export function tileSize(level: number): { along: number; around: number } { + const n = 2 ** level + return { along: LENGTH_M / (ROOTS_ALONG * n), around: CIRCUMFERENCE_M / (ROOTS_AROUND * n) } +} + +/** The tile's centre on the datum cylinder, in kilometres, tower frame. */ +export function tileCentreKm(t: TileKey): [number, number, number] { + const s = tileSize(t.level) + const z = (t.i + 0.5) * s.along + const theta = ((t.j + 0.5) * s.around) / RADIUS_M + const r = RADIUS_M / 1000 + return [r * Math.cos(theta), r * Math.sin(theta), z / 1000] +} + +/** + * Materials, in the order the shader reads them. Weights, not colours: the fragment + * shader turns them into colour, so a tile carries what the ground *is* and the look + * of it can change without regenerating anything. + */ +export const MATERIALS = ['plain', 'desert', 'forest', 'rock', 'snow', 'water', 'depth', 'unused'] as const + +export interface TileData { + key: string + /** Vertices relative to the tile centre, km, tower frame. Grid first, then skirts. */ + positions: Float32Array + /** Unit normals, tower frame, as signed bytes: a normal needs no more than 1/127. */ + normals: Int8Array + /** MATERIALS.length weights per vertex, 0..255. */ + materials: Uint8Array + /** Lowest and highest displayed elevation on the tile, metres. */ + minM: number + maxM: number +} + +/** Vertices per side of a tile's grid. */ +export type TileGrid = 33 | 65 + +const smoothstep = (e0: number, e1: number, x: number): number => { + const t = Math.min(1, Math.max(0, (x - e0) / (e1 - e0))) + return t * t * (3 - 2 * t) +} + +/** + * Surface material weights from a sample and its slope. Also used by the map, so + * the map and the view cannot disagree about what the ground is. + * + * `out` receives MATERIALS.length values in 0..1. + */ +export function materialsAt( + s: Sample, slope: number, out: Float32Array | number[], offset = 0, spacingM = 0, +): void { + const shown = Math.max(s.h, s.water) + /** + * Water covers a vertex only as far as it covers the ground that vertex stands for. + * An 80 m river under a vertex of a 500 m tile would otherwise paint a 500 m blue + * diamond, and a line of them down every river on the far side of the world. + */ + const water = s.water > s.h ? Math.min(1, s.waterWidthM / Math.max(1, spacingM)) : 0 + const rock = smoothstep(1300, 1900, s.relief) + const desert = s.desert * (1 - rock) + const forest = s.forest * (1 - rock) * (1 - desert) + const plain = Math.max(0, 1 - rock - desert - forest) + // 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 holds = 1 - smoothstep(0.55, 1.1, slope) + const snow = water ? 0 : smoothstep(s.snowlineM, s.snowlineM + 550, shown) * holds + out[offset] = plain + out[offset + 1] = desert + out[offset + 2] = forest + out[offset + 3] = rock + out[offset + 4] = snow + out[offset + 5] = water + // Water deepens to its darkest over the first 90 m, as the v1 map did. + out[offset + 6] = water ? Math.min(1, (s.water - s.h) / 90) : 0 + out[offset + 7] = 0 +} + +/** + * Build one tile. Samples an (N+2)² grid — one ring beyond the edge — so that normals + * at the edge are taken from the real neighbouring ground, and match the tile next + * door instead of creasing along every seam. + */ +export function buildTile(rt: Runtime, t: TileKey, n: TileGrid): TileData { + const size = tileSize(t.level) + const z0 = t.i * size.along + const a0 = t.j * size.around + const dz = size.along / (n - 1) + const da = size.around / (n - 1) + // Shortest wavelength worth computing: twice the vertex spacing. + const detail = 2 * Math.min(dz, da) + const W = n + 2 + + const shown = new Float64Array(W * W) + const mats = new Float32Array(W * W * MATERIALS.length) + for (let v = 0; v < W; v++) { + for (let u = 0; u < W; u++) { + // z is clamped to the world: the ring beyond a tile at either cap samples the + // edge again rather than the space beyond the end of the cylinder. + const z = Math.min(LENGTH_M, Math.max(0, z0 + (u - 1) * dz)) + const theta = (a0 + (v - 1) * da) / RADIUS_M + const s = rt.sample(theta, z, detail) + const k = v * W + u + shown[k] = Math.max(s.h, s.water) + // Slope is not known until the neighbours are, so snow is thinned by it below. + materialsAt(s, 0, mats, k * MATERIALS.length, Math.max(dz, da)) + } + } + + const vertCount = n * n + 4 * n + const positions = new Float32Array(vertCount * 3) + const normals = new Int8Array(vertCount * 3) + const materials = new Uint8Array(vertCount * MATERIALS.length) + const [cx, cy, cz] = tileCentreKm(t) + let minM = Infinity, maxM = -Infinity + + const write = (index: number, u: number, v: number, drop: number): void => { + const k = (v + 1) * W + (u + 1) + const z = Math.min(LENGTH_M, Math.max(0, z0 + u * dz)) + const theta = (a0 + v * da) / RADIUS_M + const cos = Math.cos(theta), sin = Math.sin(theta) + const h = shown[k]! + const r = (RADIUS_M - h + drop) / 1000 + positions[3 * index] = r * cos - cx + positions[3 * index + 1] = r * sin - cy + positions[3 * index + 2] = z / 1000 - cz + + // Rise per metre of run along the axis and around it, from the neighbours. + const gz = (shown[k + 1]! - shown[k - 1]!) / (2 * dz) + const ga = (shown[k + W]! - shown[k - W]!) / (2 * da) + // up = -radial; deepward = +z; spinward = (-sin, cos, 0). + let nx = -cos + ga * sin + let ny = -sin - ga * cos + let nz = -gz + const len = Math.hypot(nx, ny, nz) + nx /= len; ny /= len; nz /= len + normals[3 * index] = Math.round(nx * 127) + normals[3 * index + 1] = Math.round(ny * 127) + normals[3 * index + 2] = Math.round(nz * 127) + + // Slope matters for snow, and only now is it known. + const slope = Math.hypot(gz, ga) + const base = k * MATERIALS.length + const holds = 1 - smoothstep(0.55, 1.1, slope) + for (let m = 0; m < MATERIALS.length; m++) { + const w = m === 4 ? mats[base + m]! * holds : mats[base + m]! + materials[index * MATERIALS.length + m] = Math.round(w * 255) + } + if (!drop) { + minM = Math.min(minM, h) + maxM = Math.max(maxM, h) + } + } + + for (let v = 0; v < n; v++) for (let u = 0; u < n; u++) write(v * n + u, u, v, 0) + + /** + * Skirts: a curtain hung from every edge, reaching down past any crack a coarser + * neighbour could open. Deep enough for the height error one level coarser, which + * scales with the tile, and never less than a few metres. + */ + const drop = Math.max(8, 0.04 * Math.max(size.along, size.around)) + let idx = n * n + for (let e = 0; e < n; e++) write(idx++, e, 0, drop) + for (let e = 0; e < n; e++) write(idx++, n - 1, e, drop) + for (let e = 0; e < n; e++) write(idx++, n - 1 - e, n - 1, drop) + for (let e = 0; e < n; e++) write(idx++, 0, n - 1 - e, drop) + + return { key: tileKey(t), positions, normals, materials, minM, maxM } +} + +const indexCache = new Map<number, Uint16Array>() + +/** + * Triangle indices for a tile, shared by every tile of the same grid. Wound so the + * face points up — toward the axis — which is the side anyone ever sees. + * + * At vertex (u, v), +u is deepward and +v spinward, and deepward × spinward is up. + * So (u,v) → (u+1,v) → (u,v+1) is counter-clockwise seen from above: a front face. + */ +export function tileIndices(n: TileGrid): Uint16Array { + let idx = indexCache.get(n) + if (idx) return idx + const out: number[] = [] + const at = (u: number, v: number): number => v * n + u + for (let v = 0; v < n - 1; v++) { + for (let u = 0; u < n - 1; u++) { + const a = at(u, v), b = at(u + 1, v), c = at(u, v + 1), d = at(u + 1, v + 1) + out.push(a, b, c, b, d, c) + } + } + // Skirts: each edge vertex joined to its dropped copy, following the edge. + const edge = (e: number): [number, number] => { + // Grid vertex and skirt vertex for step e along the perimeter, in skirt order. + const side = Math.floor(e / n), k = e % n + const grid = side === 0 ? at(k, 0) + : side === 1 ? at(n - 1, k) + : side === 2 ? at(n - 1 - k, n - 1) + : at(0, n - 1 - k) + return [grid, n * n + e] + } + for (let e = 0; e < 4 * n; e++) { + if (e % n === n - 1) continue + const [g0, s0] = edge(e) + const [g1, s1] = edge(e + 1) + // Both windings: a skirt is seen from whichever side the crack is on. + out.push(g0, s0, g1, g1, s0, s1, g0, g1, s0, g1, s1, s0) + } + // 65² + 4·65 vertices fits in 16 bits with room to spare. + idx = new Uint16Array(out) + indexCache.set(n, idx) + return idx +} diff --git a/src/terrain/worker.ts b/src/terrain/worker.ts new file mode 100644 index 0000000..2d16544 --- /dev/null +++ b/src/terrain/worker.ts @@ -0,0 +1,43 @@ +/** + * A terrain worker. See world-generation.md §5. + * + * Holds one compiled world and answers questions about it: build this tile, render + * these rows of the map. The first worker in the pool also compiles, reporting each + * step as it starts so the status dialog can say what is being made. + */ +import { expose, transfer } from 'comlink' +import type { World } from '../world/schema.js' +import { type CompiledWorld, compileWorld } from './compile.js' +import { type Runtime, prepare } from './field.js' +import { renderMapRows } from './map-image.js' +import { type TileData, type TileGrid, type TileKey, buildTile } from './tile.js' + +let runtime: Runtime | null = null +let generation = -1 + +const api = { + compile(world: World, onStep: (label: string) => void): CompiledWorld { + return compileWorld(world, onStep) + }, + + load(world: CompiledWorld, gen: number): void { + runtime = prepare(world) + generation = gen + }, + + /** Null when the world has moved on since the request was made: the tile is stale. */ + tile(gen: number, t: TileKey, n: TileGrid): TileData | null { + if (!runtime || gen !== generation) return null + const data = buildTile(runtime, t, n) + return transfer(data, [data.positions.buffer, data.normals.buffer, data.materials.buffer]) + }, + + mapRows(gen: number, width: number, height: number, y0: number, y1: number, origin: number): Uint8ClampedArray | null { + if (!runtime || gen !== generation) return null + const rows = renderMapRows(runtime, width, height, y0, y1, origin) + return transfer(rows, [rows.buffer]) + }, +} + +export type WorkerApi = typeof api +expose(api) diff --git a/src/ui/app.ts b/src/ui/app.ts index 86f0a5a..73b71bc 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -9,8 +9,10 @@ import { landStripWidth, slatCount, slatWidth } from '../canon/slats.js' import { type SurfacePoint, landStripIndex, isSlat, surfaceDistance } from '../canon/geometry.js' import { sightline } from '../world/instruments.js' import { - type Place, type PlaceKind, PLACE_KINDS, isAreaKind, isAutoId, isTracedKind, makeId, parseWorld, + type Feature, type FeatureKind, FEATURE_KINDS, anchorOf, geometryTypesFor, isAreaKind, isAutoId, + isTracedKind, makeId, newSeed, parseWorld, withKind, } from '../world/schema.js' +import { terrain } from '../terrain/service.js' import { KIND_COLOR } from '../views/palette.js' import { WorldStore, backupFilename, download } from '../world/store.js' import { TowerMap } from '../views/map.js' @@ -19,7 +21,7 @@ import type { InteriorView, Lighting } from '../views/interior.js' import { G0, LENGTH_M, SPIN_PERIOD_S, gravityAtHeight } from '../canon/constants.js' import { eyeHeightFromSlider, sliderFromEyeHeight } from './scales.js' -type Tool = 'select' | PlaceKind +type Tool = 'select' | FeatureKind type ViewName = 'map' | 'exterior' | 'interior' const VIEW_KEY = 'tower.view' /** The exterior's hull reading, remembered across reloads like the view itself (D-061). */ @@ -48,20 +50,17 @@ const pathLength = (pts: readonly [number, number][]): number => { } return total } -/** A single representative point for a place, whatever shape it is. */ -const pointOf = (p: Place): SurfacePoint => { - const g = p.geometry - if (g.type === 'point') return { theta: g.theta, z: g.z } - if (g.type === 'band') return { theta: 0, z: (g.zFrom + g.zTo) / 2 } - return { theta: g.points[0]![0], z: g.points[0]![1] } -} +/** How long a compile runs before the status dialog appears (world-generation.md §10.1). */ +const STATUS_DELAY_MS = 400 +/** How long each line stays up at least, so short steps read as a sequence, not a flicker. */ +const STATUS_LINE_MS = 140 /** * Where to look on arrival. A place with extent gets its far end, so standing at * the head of a river looks down it; a point has no direction of its own, so the * heading is left alone. */ -const facingOf = (p: Place): SurfacePoint | null => { +const facingOf = (p: Feature): SurfacePoint | null => { const g = p.geometry if (g.type !== 'polyline' && g.type !== 'polygon') return null const n = g.points.length @@ -125,6 +124,7 @@ export class App { // A close mid-debounce must not lose the last edit. window.addEventListener('pagehide', () => this.store.flush()) this.store.subscribe(() => this.render()) + this.wireTerrain() this.map.fit() this.readHash() /** @@ -138,6 +138,67 @@ export class App { void this.setView(this.view) } + // --------------------------------------------------------------- terrain + + /** + * Keep the generated world in step with the stored one, and show the status dialog + * while it is being made. The service ignores changes that do not touch terrain, so + * this can run on every store change without regenerating on a rename. + */ + private wireTerrain(): void { + const service = terrain() + const dialog = $('status') + const line = $('status-line') + const fill = $('status-fill') + /** Lines waiting their turn. Steps can finish faster than a line can be read. */ + const queue: string[] = [] + let lastLine = 0 + /** Lines shown this generation: the bar follows them, so it never runs ahead of the words. */ + let told = 0 + let timer: ReturnType<typeof setTimeout> | null = null + + const pump = (): void => { + timer = null + const p = service.progress + const now = performance.now() + if (dialog.hidden) { + // Quick compiles never show the dialog at all, and their lines are dropped. + if (!p.busy) { queue.length = 0; told = 0; return } + const waited = now - p.startedAt + if (waited < STATUS_DELAY_MS) { + timer = setTimeout(pump, STATUS_DELAY_MS - waited) + return + } + dialog.hidden = false + } + // Once the world is ready, the lines still waiting run through quickly: they are + // a record of what was made, and nobody should wait on the telling of it. + const lineMs = p.busy ? STATUS_LINE_MS : STATUS_LINE_MS / 4 + if (queue.length && now - lastLine >= lineMs) { + line.textContent = `${queue.shift()}…` + lastLine = now + told++ + } + const shown = !p.busy && !queue.length ? p.total : Math.min(p.done, told) + fill.style.width = `${Math.round((100 * shown) / Math.max(1, p.total))}%` + // Once up, it stays until every line has had its moment, then goes. + if (!p.busy && !queue.length && now - lastLine >= lineMs) { + dialog.hidden = true + told = 0 + return + } + timer = setTimeout(pump, lineMs) + } + + service.onProgress(() => { + const { label, busy } = service.progress + if (busy && label && label !== queue.at(-1)) queue.push(label) + if (!timer) pump() + }) + void service.setWorld(this.store.current) + this.store.subscribe(() => { void service.setWorld(this.store.current) }) + } + // ----------------------------------------------------------------- views /** @@ -183,18 +244,13 @@ export class App { */ private buildInteriorControls(): void { const eye = $<HTMLInputElement>('f-eye') - const showEye = (m: number) => { - $('eye-value').textContent = - `— ${m < 1000 ? `${m.toFixed(1)} m` : `${(m / 1000).toFixed(1)} km`}, ` + - `${(gravityAtHeight(m) / G0).toFixed(3)} g` - } eye.oninput = () => { const m = eyeHeightFromSlider(Number(eye.value) / 1000) - showEye(m) + this.showEye(m) this.interior?.setEyeHeight(m) this.renderStanding() } - showEye(eyeHeightFromSlider(Number(eye.value) / 1000)) + this.showEye(eyeHeightFromSlider(Number(eye.value) / 1000)) /** World seconds per real second, slowest first: a Tower day in 24 min at 60x. */ const RATES = [1, 60, 900, 3600, 14400] @@ -318,7 +374,7 @@ export class App { $<HTMLSelectElement>('f-light').disabled = this.interior.cycling } this.interior.resize() - this.interior.setPlaces(this.store.places.filter((p) => p.geometry.type !== 'band')) + this.interior.setPlaces(this.store.features.filter((p) => p.geometry.type !== 'band')) this.interior.start() this.render() } @@ -411,7 +467,7 @@ export class App { private focusOn(id: string): void { const p = this.store.get(id) if (!p) return - const point = pointOf(p) + const point = anchorOf(p) this.map.centreOn(point) if (this.view === 'exterior') this.exterior?.lookAt(point) // Each view does what "focus" means for it: the map centres, the exterior @@ -436,7 +492,7 @@ export class App { return b } mk('select', 'Select') - for (const k of PLACE_KINDS) mk(k, k[0]!.toUpperCase() + k.slice(1)) + for (const k of FEATURE_KINDS) mk(k, k[0]!.toUpperCase() + k.slice(1)) } private setTool(tool: Tool): void { @@ -450,16 +506,16 @@ export class App { tool === 'select' ? 'Click a marker to select. Drag to move. Shift-click two points to measure a sightline.' : isAreaKind(tool) - ? `Click to trace the outline of a ${tool}. Enter closes it; Esc cancels.` + ? `Click to trace the outline of ${tool === 'mountains' ? 'a range' : `a ${tool}`}. Enter closes it; Esc cancels.` : isTracedKind(tool) - ? `Click to trace the course of a ${tool}. Enter finishes it; Esc cancels.` + ? `Click to trace the course of a ${tool}, ${tool === 'river' ? 'source first' : 'start first'}. Enter finishes it; Esc cancels.` : `Click the map to place a ${tool}.` this.render() } // -------------------------------------------------------------- map handlers - private onPick(p: SurfacePoint, hit: Place | null, shift: boolean): void { + private onPick(p: SurfacePoint, hit: Feature | null, shift: boolean): void { if (shift) return this.onProbe(p) if (this.tool === 'select') { @@ -471,7 +527,7 @@ export class App { this.map.draftPolygon = this.draft return this.render() } - this.createPlace(this.tool, { type: 'point', theta: p.theta, z: p.z }) + this.createFeature(this.tool, { type: 'point', theta: p.theta, z: p.z }) } private onProbe(p: SurfacePoint): void { @@ -526,10 +582,10 @@ export class App { private onKey(e: KeyboardEvent): void { if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return if (e.key === 'Escape') return this.setTool('select') - if (e.key === 'Enter' && isTracedKind(this.tool as PlaceKind)) { - const area = isAreaKind(this.tool as PlaceKind) + if (e.key === 'Enter' && isTracedKind(this.tool as FeatureKind)) { + const area = isAreaKind(this.tool as FeatureKind) if (this.draft.length >= (area ? 3 : 2)) { - this.createPlace(this.tool as PlaceKind, + this.createFeature(this.tool as FeatureKind, area ? { type: 'polygon', points: this.draft } : { type: 'polyline', points: this.draft }) this.draft = [] this.map.draftPolygon = [] @@ -538,15 +594,21 @@ export class App { if ((e.key === 'Backspace' || e.key === 'Delete') && this.selectedId) { this.deleteSelected() } - if (e.key === 'f') this.map.fit() + // F frames the map, but not while flying: it sits beside W A S D. + if (e.key === 'f' && this.view === 'map') this.map.fit() } - private createPlace(kind: PlaceKind, geometry: Place['geometry']): void { - const name = `New ${kind}` - const place: Place = { + /** + * A new feature, with every parameter at its kind's default and a seed of its own. + * This is the one moment a random number enters the world (world-generation.md §5.5): + * the seed is saved with the feature, so it generates the same forever after. + */ + private createFeature(kind: FeatureKind, geometry: Feature['geometry']): void { + const name = kind === 'mountains' ? 'New range' : `New ${kind}` + const place = withKind({ id: makeId(name, this.store.takenIds), - name, kind, geometry, blurb: '', notes: '', public: true, - } + name, geometry, blurb: '', notes: '', public: true, seed: newSeed(), + }, kind) this.store.add(place) this.selectedId = place.id this.setTool('select') @@ -585,7 +647,7 @@ export class App { private buildKindSelect(): void { const sel = $<HTMLSelectElement>('f-kind') - for (const k of PLACE_KINDS) { + for (const k of FEATURE_KINDS) { const o = document.createElement('option') o.value = k o.textContent = k @@ -594,14 +656,22 @@ export class App { } private wireEditor(): void { - const patch = (p: Partial<Omit<Place, 'id'>>) => { + const patch = (p: Partial<Omit<Feature, 'id'>>) => { if (this.selectedId) this.store.update(this.selectedId, p) } $<HTMLInputElement>('f-name').oninput = (e) => this.rename((e.target as HTMLInputElement).value) $<HTMLInputElement>('f-id').oninput = (e) => this.setId((e.target as HTMLInputElement).value) - $<HTMLSelectElement>('f-kind').onchange = (e) => - patch({ kind: (e.target as HTMLSelectElement).value as PlaceKind }) + /** + * A kind is a set of parameters, not a label, so changing it swaps the parameters + * for the new kind's defaults. The name, notes, shape and seed carry over. + */ + $<HTMLSelectElement>('f-kind').onchange = (e) => { + const f = this.selectedId ? this.store.get(this.selectedId) : undefined + if (!f) return + const kind = (e.target as HTMLSelectElement).value as FeatureKind + this.store.replace(f.id, withKind(f, kind)) + } $<HTMLTextAreaElement>('f-blurb').oninput = (e) => patch({ blurb: (e.target as HTMLTextAreaElement).value }) $<HTMLTextAreaElement>('f-notes').oninput = (e) => @@ -664,8 +734,8 @@ export class App { download('world.json', this.store.publicJSON()) } $('reseed').onclick = async () => { - const n = this.store.places.length - if (n && !confirm(`Replace ${n} local place(s) with the published world?`)) return + const n = this.store.features.length + if (n && !confirm(`Replace ${n} local feature(s) with the published world?`)) return const loaded = await this.store.seedFrom(`${import.meta.env.BASE_URL}world.json`) this.selectedId = null this.store.flush() @@ -680,8 +750,8 @@ export class App { if (!f) return try { const world = parseWorld(JSON.parse(await f.text())) - const n = this.store.places.length - if (n && !confirm(`Replace ${n} place(s) with ${world.places.length} from ${f.name}?`)) return + const n = this.store.features.length + if (n && !confirm(`Replace ${n} feature(s) with ${world.features.length} from ${f.name}?`)) return this.store.replaceAll(world) this.selectedId = null } catch (err) { @@ -699,21 +769,21 @@ export class App { b.classList.toggle('active', b.dataset['tool'] === this.tool) } - this.map.places = this.store.places + this.map.places = this.store.features this.map.selectedId = this.selectedId this.map.dragEnabled = this.tool === 'select' if (this.view === 'exterior') { this.renderSpinReadout() } if (this.view === 'interior') { - this.interior?.setPlaces(this.store.places.filter((p) => p.geometry.type !== 'band')) + this.interior?.setPlaces(this.store.features.filter((p) => p.geometry.type !== 'band')) this.renderStanding() } this.map.draw() - const total = this.store.places.length + const total = this.store.features.length const pub = this.store.publicCount() - $('counts').textContent = `— ${total} place${total === 1 ? '' : 's'}, ${pub} public` + $('counts').textContent = `— ${total} feature${total === 1 ? '' : 's'}, ${pub} public` this.renderList() this.renderEditor() @@ -724,7 +794,7 @@ export class App { private renderList(): void { const ul = $('list') ul.replaceChildren() - for (const p of [...this.store.places].sort((a, b) => a.name.localeCompare(b.name))) { + for (const p of [...this.store.features].sort((a, b) => a.name.localeCompare(b.name))) { const li = document.createElement('li') li.className = (p.id === this.selectedId ? 'sel ' : '') + (p.public ? '' : 'private') li.onclick = () => { @@ -782,7 +852,12 @@ export class App { if (document.activeElement !== name) name.value = p.name const id = $<HTMLInputElement>('f-id') if (document.activeElement !== id) id.value = p.id - $<HTMLSelectElement>('f-kind').value = p.kind + // Only the kinds this shape can be: a river cannot become a lake without a shore. + const kinds = $<HTMLSelectElement>('f-kind') + for (const o of kinds.options) { + o.hidden = !geometryTypesFor(o.value as FeatureKind).includes(p.geometry.type) + } + kinds.value = p.kind const blurb = $<HTMLTextAreaElement>('f-blurb') if (document.activeElement !== blurb) blurb.value = p.blurb const notes = $<HTMLTextAreaElement>('f-notes') @@ -793,7 +868,7 @@ export class App { } /** Human-readable location, shared by the editor and the reader's detail card. */ - private whereText(p: Place): string { + private whereText(p: Feature): string { const g = p.geometry if (g.type === 'point') { const where = isSlat(g.theta) @@ -834,6 +909,28 @@ export class App { * progress, and it writes nothing when the value has not changed: `input` on a * range is not free, and the label rebuild would otherwise run every frame. */ + /** + * The eye-height slider follows the view, as the clock does: keyboard flight changes + * the height from inside the view, and a slider left where it was would be a second + * height in the panel disagreeing with the first. Same rules as `syncTime`. + */ + private syncEye(): void { + const v = this.interior + if (!v) return + const eye = $<HTMLInputElement>('f-eye') + if (document.activeElement === eye) return + const pos = String(Math.round(sliderFromEyeHeight(v.eyeM) * 1000)) + if (eye.value === pos) return + eye.value = pos + this.showEye(v.eyeM) + } + + private showEye(m: number): void { + $('eye-value').textContent = + `— ${m < 1000 ? `${m.toFixed(1)} m` : `${(m / 1000).toFixed(1)} km`}, ` + + `${(gravityAtHeight(m) / G0).toFixed(3)} g` + } + private syncTime(): void { const v = this.interior if (!v) return @@ -855,6 +952,7 @@ export class App { const v = this.interior if (!v) return this.syncTime() + this.syncEye() const here = v.at const opposite = { theta: here.theta + Math.PI, z: here.z } const across = sightline(here, opposite) @@ -867,7 +965,7 @@ export class App { const span = Math.min(100_000, deepward ? LENGTH_M - here.z : here.z) const along = sightline(here, { theta: here.theta, z: here.z + (deepward ? span : -span) }) const rep = v.report() - const name = this.store.places.find((p) => + const name = this.store.features.find((p) => p.geometry.type === 'point' && Math.abs(p.geometry.z - here.z) < 2000 && Math.abs(p.geometry.theta - here.theta) < 0.04)?.name diff --git a/src/ui/scales.ts b/src/ui/scales.ts index 9283f88..2e4aa3e 100644 --- a/src/ui/scales.ts +++ b/src/ui/scales.ts @@ -9,8 +9,8 @@ /** Standing eye height. */ export const EYE_MIN_M = 1.7 -/** High enough to be a fifth of the way to the axis, and see the world as a tube. */ -export const EYE_MAX_M = 25_000 +/** Most of the way to the axis: the whole world seen as a tube from inside its air (world-generation.md §6.4). */ +export const EYE_MAX_M = 45_000 /** Eye height from a 0..1 slider position. Logarithmic, so 1.7 m is reachable. */ export const eyeHeightFromSlider = (v: number): number => diff --git a/src/views/exterior.ts b/src/views/exterior.ts index 2c0d288..5b5ea4b 100644 --- a/src/views/exterior.ts +++ b/src/views/exterior.ts @@ -12,7 +12,7 @@ import { DirectionalLight, DoubleSide, Float32BufferAttribute, FrontSide, Group, LineBasicMaterial, LineSegments, Matrix4, Mesh, MeshBasicMaterial, MeshStandardMaterial, PerspectiveCamera, Raycaster, RepeatWrapping, RingGeometry, SRGBColorSpace, Scene, ShaderMaterial, - SphereGeometry, TextureLoader, Vector2, Vector3, WebGLRenderer, AdditiveBlending, + SphereGeometry, CanvasTexture, Vector2, Vector3, WebGLRenderer, AdditiveBlending, } from 'three' import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js' import { @@ -20,6 +20,7 @@ import { OMEGA_RAD_S, RADIUS_M, } from '../canon/constants.js' import { type SurfacePoint, mapThetaOrigin, normalizeTheta } from '../canon/geometry.js' +import { terrain } from '../terrain/service.js' import { InteriorModel, STAR_GLSL } from './interior-model.js' const KM = 1000 @@ -161,8 +162,10 @@ export class ExteriorView { this.hullMat = new MeshStandardMaterial() this.hull = this.buildHull() + // Seen through a window from outside, the ground never needs more than a few + // levels: nothing finer survives 100 km of glass and haze. this.model = new InteriorModel({ - seeThrough: true, anisotropy: this.renderer.capabilities.getMaxAnisotropy(), + seeThrough: true, lod: { grid: 33, maxLevel: 3, split: 2.4, budget: 400 }, }) this.tower.add(this.hull, ...this.model.meshes) this.scene.add(this.tower) @@ -230,7 +233,10 @@ export class ExteriorView { for (let i = 0; i < uv.count; i++) { const around = uv.getX(i) const along = uv.getY(i) - uv.setXY(i, 1 - along, around - shift) + // Three's v is 1 at the cylinder's +y end, which rotateX carries to z = L, the far + // cap — so v already runs sun cap to far cap, as the image's x does. It used to be + // flipped here, which put the Girdle's snow at the far cap end of the x-ray. + uv.setXY(i, along, around - shift) } uv.needsUpdate = true } @@ -241,21 +247,24 @@ export class ExteriorView { * *inside*. It is an x-ray, and useful precisely because the hull is opaque. */ private loadTerrain(): void { - new TextureLoader().load( - `${import.meta.env.BASE_URL}terrain-color.png`, - (tex) => { - tex.colorSpace = SRGBColorSpace - tex.wrapS = RepeatWrapping - tex.wrapT = RepeatWrapping - // uv.y is computed, not sampled from the file's row order, so no flip. - tex.flipY = false - tex.anisotropy = this.renderer.capabilities.getMaxAnisotropy() - this.terrainMap = tex - this.applyTerrain() - }, - undefined, - () => { /* no raster yet: the plain hull is a fine fallback */ }, - ) + const adopt = (): void => { + const canvas = terrain().mapCanvas + if (!canvas) return + this.terrainMap?.dispose() + const tex = new CanvasTexture(canvas) + tex.colorSpace = SRGBColorSpace + tex.wrapS = RepeatWrapping + tex.wrapT = RepeatWrapping + // uv.y is computed, not sampled from the image's row order, so no flip. + tex.flipY = false + tex.anisotropy = this.renderer.capabilities.getMaxAnisotropy() + this.terrainMap = tex + this.applyTerrain() + } + // The picture is the map's own, rendered live from the world (world-generation.md + // §9), so it follows every edit. Until the first one is drawn the hull stays bare. + terrain().onMap(adopt) + adopt() } private applyTerrain(): void { diff --git a/src/views/interior-model.ts b/src/views/interior-model.ts index 69eb057..c310b96 100644 --- a/src/views/interior-model.ts +++ b/src/views/interior-model.ts @@ -12,23 +12,20 @@ * * Scene units are kilometres, so the numbers in the code are the numbers in canon. */ -import type { Texture } from 'three' +import type { Group, Object3D, Texture } from 'three' import { AdditiveBlending, BackSide, CircleGeometry, Color, CylinderGeometry, DoubleSide, - LinearFilter, Matrix4, Mesh, RepeatWrapping, RingGeometry, ShaderMaterial, - TextureLoader, Vector3, + FrontSide, Matrix4, Mesh, RingGeometry, ShaderMaterial, Vector3, } from 'three' import { BEAM_RADIUS_M, COLLECTOR_DIAMETER_M, COLLECTOR_RIM_M, COLLECTOR_STANDOFF_M, HULL_DEPTH_M, LENGTH_M, OMEGA_RAD_S, RADIUS_M, RAYLEIGH_FLOOR_PER_M, SPECIFIC_GAS_CONSTANT, TEMPERATURE_K, } from '../canon/constants.js' -import { mapThetaOrigin } from '../canon/geometry.js' +import { PALETTE_GLSL } from '../terrain/palette.js' +import { terrain } from '../terrain/service.js' 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, -} from './raster.js' +import { TerrainLOD, type TerrainLODOptions } from './terrain-lod.js' const KM = 1000 const R = RADIUS_M / KM @@ -69,9 +66,13 @@ const DENSITY_A = ((OMEGA_RAD_S ** 2 / (2 * SPECIFIC_GAS_CONSTANT * TEMPERATURE_ export const HAZE_DAY = 0x9aa3ad export const HAZE_DUSK = 0x8d8792 -/** Shell tessellation. 0.7 km triangles: distant relief is real, your feet are not. */ -export const SEG_AROUND = 512 -export const SEG_ALONG = 768 +/** + * The lowest the ground is expected to go, metres. Sea and lake floors are cut below + * the datum, and the haze integral's air has to reach down past them. Generated + * terrain has no hard floor, so this is a margin rather than a measurement: the + * deepest basin today is about 330 m under The Deepward Sea's 90 m surface. + */ +const LOWEST_GROUND_M = -1000 // ---------------------------------------------------------------------- shaders @@ -99,7 +100,7 @@ uniform vec3 uHaze; uniform float uExposure; const float A = ${DENSITY_A.toExponential(8)}; const float RR = ${(R * R).toFixed(4)}; -const float RR_AIR = ${((R - RASTER_MIN_HEIGHT_M / KM + 0.05) ** 2).toFixed(4)}; +const float RR_AIR = ${((R - LOWEST_GROUND_M / KM + 0.05) ** 2).toFixed(4)}; const float Z_AIR0 = 0.0; const float Z_AIR1 = ${(L + HULL_D).toFixed(4)}; @@ -161,57 +162,28 @@ vec4 windowColour(vec3 face, vec3 solid, float cover) { } ` +/** + * A terrain tile (world-generation.md §6). Vertices arrive already on the cylinder, + * relative to the tile's centre, with normals in the tower's frame and material + * weights beside them — so this does nothing but place them and pass them on. + */ const GROUND_VERT = /* glsl */` #include <common> #include <logdepthbuf_pars_vertex> -uniform sampler2D uHeight; -uniform vec2 uTexel; -uniform float uMppKm; -varying vec2 vUv; +uniform mat4 uFrame; +attribute vec4 aMatA; +attribute vec4 aMatB; varying vec3 vWorld; varying vec3 vNormal2; - -/** - * The raster is stretched across a declared working range, not 0..anything. - * - * This fetch is in a *vertex* shader, and that is why uHeight is built with no - * mipmaps at all (loadRasters). A vertex shader has no derivatives, so there is - * nothing for texture2D to pick a mip level from — the level is left to the - * driver, and a driver is entitled to pick any of them. On one that picks badly - * the shell gets displaced by a downsampled height field: the terrain flattens - * into blocks with the cell seams showing as creases, and it comes back on a - * reload, which is what made it look intermittent rather than wrong. Naming the - * level here instead would be the obvious guard and is not available — the - * vertex-only texture2DLod does not survive Three's WebGL2 shader prefix — so - * the ambiguity is removed at the other end, by giving the sampler exactly one - * level to choose between. - */ -float elevation(vec2 at) { - return ${(RASTER_MIN_HEIGHT_M / KM).toFixed(6)} - + texture2D(uHeight, at).r * ${(RASTER_HEIGHT_SPAN_M / KM).toFixed(6)}; -} +varying vec4 vMatA; +varying vec4 vMatB; void main() { - vUv = uv; - vec3 radial = normalize(vec3(position.xy, 0.0)); - vec3 up = -radial; - // Spinward: z-hat x s-hat. The floor's other tangent is the axis itself. - vec3 spinward = vec3(-radial.y, radial.x, 0.0); - vec3 deepward = vec3(0.0, 0.0, 1.0); - - float h = elevation(uv); - // Central differences on the raster, in km of rise per km of run. - float dz = (elevation(uv + vec2(uTexel.x, 0.0)) - elevation(uv - vec2(uTexel.x, 0.0))) - / (2.0 * uMppKm); - float dt = (elevation(uv + vec2(0.0, uTexel.y)) - elevation(uv - vec2(0.0, uTexel.y))) - / (2.0 * uMppKm); - vNormal2 = normalize(up - deepward * dz - spinward * dt); - - // The ground mesh sits untransformed in the tower's frame in both views, so its - // own coordinates already are that frame and uFrame is not needed here. - vec3 p = position + up * h; - vWorld = p; - gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0); + vWorld = (uFrame * modelMatrix * vec4(position, 1.0)).xyz; + vNormal2 = normal; + vMatA = aMatA; + vMatB = aMatB; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); #include <logdepthbuf_vertex> } ` @@ -219,17 +191,18 @@ void main() { const GROUND_FRAG = /* glsl */` #include <common> #include <logdepthbuf_pars_fragment> -uniform sampler2D uColor; uniform float uFrontZ; uniform float uFrontWidth; uniform float uBelowFront; uniform float uAboveFront; -varying vec2 vUv; varying vec3 vWorld; varying vec3 vNormal2; +varying vec4 vMatA; +varying vec4 vMatB; ${HAZE_GLSL} +${PALETTE_GLSL} -/** Cheap value noise, to keep the ground from being flat colour underfoot. */ +/** Cheap value noise, to keep the ground from being flat colour. */ float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); } @@ -242,37 +215,46 @@ float vnoise(vec2 p) { void main() { #include <logdepthbuf_fragment> - // The raster is an sRGB image and every uniform Colour here is already linear, - // so the texture is the one thing that has to be brought into the working space. - // Three inserts that decode into its own materials and never into a custom one. - vec3 base = pow(texture2D(uColor, vUv).rgb, vec3(2.2)); + // Elevation from the radius, since the tile knows where it is and nothing else does. + float elevM = (${R.toFixed(1)} - length(vWorld.xy)) * 1000.0; + // The palette is sRGB; every uniform colour here is linear. Three inserts that + // decode into its own materials and never into a custom one. + vec3 base = pow(groundColour(vMatA, vMatB, elevM), vec3(2.2)); + // Unrolled surface coordinates, km. World xyz shears a pattern into radial streaks + // exactly where the ground is most grazing, which is most of the view. + vec2 surf = vec2(vWorld.z, atan(vWorld.y, vWorld.x) * ${R.toFixed(3)}); /** - * The raster is 264 m per pixel, so within a few hundred metres of the observer - * one texel fills the screen. This is texture, not terrain: it breaks up the - * flat block without claiming there is anything there. It fades out past 4 km, - * where the raster is carrying real detail again. + * Mottling, as the map has it (D-052): a broad layer that dims and brightens, and a + * fine one that pushes the channels apart so ground reads as patchy cover over + * soil rather than one colour turned up and down. Water is left alone. + */ + float land = 1.0 - vMatB.y; + float broad = vnoise(surf / 5.2); + float fine = vnoise(surf / 1.15 + 17.0); + float k = 0.9 + 0.2 * broad; + float mo = (fine - 0.5) * 0.13; + base *= mix(vec3(1.0), vec3(k - mo * 0.55, k + mo, k - mo * 0.25), land); + + /** + * Grain near the observer. Tiles near the eye have metre spacing, but a metre is + * still flat colour at your feet. This is texture, not terrain: it breaks up the + * block without claiming there is anything there, and fades out past 4 km. */ float near = 1.0 - smoothstep(0.4, 4.0, distance(uCam, vWorld)); - // Unrolled surface coordinates. World xyz shears the pattern into radial streaks - // exactly where the ground is most grazing, which is most of the view. - vec2 surf = vec2(vWorld.z, atan(vWorld.y, vWorld.x) * ${R.toFixed(3)}); - // Rotated and off-harmonic octaves. Axis-aligned value noise at one scale reads - // as a woven diamond lattice, which is worse than the flat colour it replaced. + // Rotated and off-harmonic octaves. Axis-aligned value noise at one scale reads as + // a woven diamond lattice, which is worse than the flat colour it replaced. vec2 rot = vec2(surf.x * 0.86 - surf.y * 0.51, surf.x * 0.51 + surf.y * 0.86); float grain = vnoise(surf * 173.0) * 0.55 + vnoise(rot * 611.0) * 0.45; - base *= 1.0 + (grain - 0.5) * 0.12 * near; + base *= 1.0 + (grain - 0.5) * 0.12 * near * land; // The beam is a line source on the axis, so the light comes from straight up. vec3 radial = normalize(vec3(vWorld.xy, 0.0)); - float lambert = clamp(dot(vNormal2, -radial), 0.0, 1.0); + float lambert = clamp(dot(normalize(vNormal2), -radial), 0.0, 1.0); /** * The dawn front (D-053). The beam's own glow comes up everywhere at once — it is * one beam — but the diffusers hand the light down the axis, so the *ground* - * lights as a line running deepward at 225 m/s. Below the front and above it are - * different times of day, and this is the only place in Tower where that is true. - * - * Outside dawn and dusk both sides are 1.0 and this costs a mix. + * lights as a line running deepward at 225 m/s. */ float side = smoothstep(uFrontZ - uFrontWidth, uFrontZ, vWorld.z); float daylight = mix(uBelowFront, uAboveFront, side); @@ -583,42 +565,39 @@ void main() { // ------------------------------------------------------------------------ model export interface InteriorModelOptions { + /** How finely the ground is tiled. The exterior never needs more than the roots. */ + lod: TerrainLODOptions /** * True when the model is looked at from outside, through its windows: the panes * go clear instead of painting the sky, and the caps blend with what is behind. */ seeThrough: boolean - /** The renderer's anisotropy ceiling, for the colour raster. */ - anisotropy: number } export class InteriorModel { /** Every material here, so a caller can set a shared uniform in one loop. */ readonly materials: ShaderMaterial[] = [] readonly groundMat: ShaderMaterial - readonly ground: Mesh + /** The generated ground: a quadtree of tiles, refined around the eye. */ + readonly lod: TerrainLOD + readonly ground: Group /** Everything to add to a scene, the ground included. */ - readonly meshes: Mesh[] - /** Resolves with the height raster once both rasters are on the ground, or null. */ - readonly rasters: Promise<Texture | null> + readonly meshes: Object3D[] constructor(private opts: InteriorModelOptions) { - this.groundMat = this.material(GROUND_VERT, GROUND_FRAG, { - side: BackSide, - uniforms: { - uColor: { value: null }, uHeight: { value: null }, - uTexel: { value: [1 / RASTER_WIDTH, 1 / RASTER_HEIGHT] }, - uMppKm: { value: L / RASTER_WIDTH }, - }, - }) - this.ground = this.buildGround() + this.groundMat = this.material(GROUND_VERT, GROUND_FRAG, { side: FrontSide, uniforms: {} }) + this.lod = new TerrainLOD(terrain(), this.groundMat, opts.lod) + this.ground = this.lod.group this.meshes = [this.ground, ...this.buildCaps(), this.buildBeam()] - this.rasters = this.loadRasters() } - /** The eye, in the tower's frame. */ + /** + * The eye, in the tower's frame. Call every frame: it is also what chooses which + * tiles of ground to draw. + */ setCamera(eye: Vector3): void { for (const m of this.materials) (m.uniforms['uCam']!.value as Vector3).copy(eye) + this.lod.update(eye) } /** World to tower coordinates. Only the exterior, which turns the hull, needs it. */ @@ -628,7 +607,7 @@ export class InteriorModel { private material( vertexShader: string, fragmentShader: string, - params: { side: typeof BackSide | typeof DoubleSide; uniforms: Record<string, { value: unknown }>; additive?: boolean }, + params: { side: typeof BackSide | typeof DoubleSide | typeof FrontSide; uniforms: Record<string, { value: unknown }>; additive?: boolean }, ): ShaderMaterial { const m = new ShaderMaterial({ vertexShader, fragmentShader, side: params.side, @@ -661,76 +640,6 @@ export class InteriorModel { } } - private buildGround(): Mesh { - const g = new CylinderGeometry(R, R, L, SEG_AROUND, SEG_ALONG, true) - g.rotateX(Math.PI / 2) - g.translate(0, 0, L / 2) - /** - * The rasters run x = z and y = theta from the map's cut; Three's cylinder runs - * u around and v along. Deriving theta from vertex positions instead would smear - * a column of triangles across the image, because the seam vertices are - * duplicated and atan2 gives both the same angle (D-023). - * - * Three builds the ring as x = R sin t, z = R cos t, so after rotateX the angle - * in the XY plane is t - pi/2; canon theta is measured from +X. - */ - const uv = g.attributes['uv']! - const shift = 0.25 + mapThetaOrigin() / (2 * Math.PI) - for (let i = 0; i < uv.count; i++) { - uv.setXY(i, uv.getY(i), uv.getX(i) - shift) - } - uv.needsUpdate = true - const mesh = new Mesh(g, this.groundMat) - // Named for the per-surface probe in the interior's diagnose(), which asks each - // one in turn whether it is the one that lost its colour. - mesh.name = 'ground' - return mesh - } - - /** - * Load a raster. - * - * `mipped` is the whole reason this takes an argument. The colour raster is read - * in the fragment shader, at every grazing angle the inside of a cylinder can - * offer, so it wants mipmaps and all the anisotropy the card will give. The - * height raster is read in the *vertex* shader, once per cell corner, at a fixed - * rate that has nothing to do with how the cell lands on screen — mips and - * anisotropy there are not merely wasted, they are a level the vertex stage has - * no way to choose between (see elevation()). So it is built with exactly one. - */ - private async loadRasters(): Promise<Texture | null> { - const load = (name: string, mipped: boolean): Promise<Texture> => - new Promise((resolve, reject) => { - new TextureLoader().load(`${import.meta.env.BASE_URL}${name}`, (tex) => { - tex.wrapS = RepeatWrapping - tex.wrapT = RepeatWrapping - // uv.y is computed rather than read from the file's row order, so no flip. - tex.flipY = false - if (mipped) { - tex.anisotropy = this.opts.anisotropy - } else { - tex.generateMipmaps = false - tex.minFilter = LinearFilter - tex.magFilter = LinearFilter - tex.anisotropy = 1 - } - resolve(tex) - }, undefined, reject) - }) - try { - const [color, height] = await Promise.all([ - load(COLOR_RASTER, true), load(HEIGHT_RASTER, false), - ]) - this.groundMat.uniforms['uColor']!.value = color - this.groundMat.uniforms['uHeight']!.value = height - this.groundMat.needsUpdate = true - return height - } catch { - // No rasters yet. The shell still renders; it is simply blank. - return null - } - } - /** * Which caps are windows, and how they blend. Inside, a window is opaque to the * renderer — the sky it shows is computed, and nothing is drawn behind it. From diff --git a/src/views/interior.ts b/src/views/interior.ts index 900b983..636bbd5 100644 --- a/src/views/interior.ts +++ b/src/views/interior.ts @@ -17,22 +17,20 @@ * - the beam, a self-luminous shaft 3.65° wide and the only thing in the sky that * is not land. */ -import type { Texture } from 'three' import { - Color, Mesh, PerspectiveCamera, Scene, ShaderMaterial, Vector3, WebGLRenderer, + Color, Group, Mesh, PerspectiveCamera, Scene, ShaderMaterial, Vector3, WebGLRenderer, } from 'three' import { BEAM_RADIUS_M, BRIGHTNESS_FULL_W_M2, DAY_LENGTH_S, DAY_SWEEP_FRACTION, BRIGHTNESS_NIGHT_W_M2, BRIGHTNESS_TEMPERATE_W_M2, BRIGHTNESS_TWILIGHT_W_M2, G0, LENGTH_M, OMEGA_RAD_S, RADIUS_M, gravityAtHeight, } from '../canon/constants.js' -import { type SurfacePoint, mapThetaOrigin, normalizeTheta } from '../canon/geometry.js' -import type { Place } from '../world/schema.js' +import { type SurfacePoint, normalizeTheta } from '../canon/geometry.js' +import { type Feature, anchorOf } from '../world/schema.js' +import { terrain } from '../terrain/service.js' import { EYE_MAX_M, EYE_MIN_M } from '../ui/scales.js' -import { - HAZE_DAY, HAZE_DUSK, InteriorModel, K_GREEN, SEG_ALONG, SEG_AROUND, -} from './interior-model.js' -import { RASTER_HEIGHT_SPAN_M, RASTER_MIN_HEIGHT_M } from './raster.js' +import { HAZE_DAY, HAZE_DUSK, InteriorModel, K_GREEN } from './interior-model.js' +import { DESKTOP_LOD, PHONE_LOD } from './terrain-lod.js' const KM = 1000 const R = RADIUS_M / KM @@ -110,6 +108,16 @@ interface Probe { b: number } +/** + * Keyboard flight (world-generation.md §6.4). Held keys, and what each does: forward + * and back along the heading, sideways, and down and up. + */ +const FLIGHT_KEYS: Record<string, [forward: number, right: number, up: number]> = { + KeyW: [1, 0, 0], KeyS: [-1, 0, 0], KeyD: [0, 1, 0], KeyA: [0, -1, 0], + KeyE: [0, 0, 1], KeyQ: [0, 0, -1], + ArrowUp: [1, 0, 0], ArrowDown: [-1, 0, 0], ArrowRight: [0, 1, 0], ArrowLeft: [0, -1, 0], +} + interface TraceFrame { /** Milliseconds since the page loaded, so samples line up with the console. */ t: number @@ -121,6 +129,8 @@ interface TraceFrame { theta: number calls: number tris: number + /** Terrain tiles drawn this frame. */ + tiles: number /** * A block of the rendered frame, averaged, 0..255 — or null on the frames * between probes. Colour is what tells a good frame from a bad one here: the @@ -163,8 +173,9 @@ export class InteriorView { private trace: TraceFrame[] = [] private frames = 0 private probeEvery = 15 - /** The height raster, read back so the observer can stand on the ground. */ - private heights: { data: Uint8ClampedArray; w: number; h: number } | null = null + /** Flight keys held down right now. */ + private held = new Set<string>() + private boost = false /** Where the observer stands. Default is the capital, at the centre of the world. */ at: SurfacePoint = { theta: 0, z: LENGTH_M / 2 } @@ -196,7 +207,7 @@ export class InteriorView { */ clockDriven = false - private labels: { place: Place; el: HTMLSpanElement }[] = [] + private labels: { place: Feature; el: HTMLSpanElement }[] = [] constructor( private canvas: HTMLCanvasElement, @@ -213,17 +224,16 @@ export class InteriorView { */ this.camera = new PerspectiveCamera(this.fovDeg, 1, 0.0005, 1500) - this.model = new InteriorModel({ - seeThrough: false, anisotropy: this.renderer.capabilities.getMaxAnisotropy(), - }) + // A phone gets coarser tiles and a smaller budget, not a thinner world (§8.4). + const phone = matchMedia('(max-width: 720px)').matches + this.model = new InteriorModel({ seeThrough: false, lod: phone ? PHONE_LOD : DESKTOP_LOD }) this.shared = this.model.materials this.groundMat = this.model.groundMat this.scene.add(...this.model.meshes) - void this.model.rasters.then((height) => { - if (!height) return - this.readHeights(height) + // A new world moves the ground under the observer: stand on the new ground, and + // redraw the elevation readout that was drawn against the old one. + terrain().onWorld(() => { this.place() - // The panel's elevation readout was drawn before any of this existed. this.cb.onLook() }) @@ -270,73 +280,22 @@ export class InteriorView { // ------------------------------------------------------------------ geometry /** - * Read the height raster back onto the CPU. + * Height of the surface under a point, in km: the ground, or the water standing on + * it. * - * The shell is displaced on the GPU, which is fine until somebody has to stand on - * it: the camera would otherwise sit 1.7 m above the *undisplaced* cylinder, which - * is underground everywhere the land is higher than that — which is everywhere. - * From inside the rock the ground is invisible (its underside is a front face, and - * the shell is drawn back-side only), so the view showed sky below the horizon and - * nothing else. There is no way to ask the GPU where the ground is, so the answer - * has to be computed here from the same data the shader displaces by. + * This asks the same elevation function the tiles were built from, at full detail, + * so standing on the ground no longer needs D-035's readback of a raster through a + * canvas, nor its guesses about how the mesh differed from the data. What can still + * differ is the tile drawn under the observer, while a finer one is on its way: for + * that moment the eye can sit a little under a coarse tile's facet. */ - private readHeights(tex: Texture): void { - const img = tex.image as CanvasImageSource & { width: number; height: number } - const canvas = document.createElement('canvas') - canvas.width = img.width - canvas.height = img.height - const ctx = canvas.getContext('2d', { willReadFrequently: false }) - if (!ctx) return - ctx.drawImage(img, 0, 0) - try { - const { data } = ctx.getImageData(0, 0, img.width, img.height) - this.heights = { data, w: img.width, h: img.height } - } catch { - // A tainted canvas would only mean standing on the reference cylinder again. - } - } - - /** Bilinear sample of the height raster, in km. Wraps in theta, clamps in z. */ - private sampleHeightKm(u: number, v: number): number { - const src = this.heights - if (!src) return 0 - const x = Math.min(src.w - 1.001, Math.max(0, u * src.w - 0.5)) - const y = ((v * src.h - 0.5) % src.h + src.h) % src.h - const x0 = Math.floor(x), y0 = Math.floor(y) - const fx = x - x0, fy = y - y0 - const at = (px: number, py: number): number => - src.data[((py % src.h) * src.w + Math.min(src.w - 1, px)) * 4]! / 255 - const top = at(x0, y0) * (1 - fx) + at(x0 + 1, y0) * fx - const bot = at(x0, y0 + 1) * (1 - fx) + at(x0 + 1, y0 + 1) * fx - return (RASTER_MIN_HEIGHT_M + (top * (1 - fy) + bot * fy) * RASTER_HEIGHT_SPAN_M) / KM + private groundKm(p: SurfacePoint): number { + return terrain().surfaceM(p.theta, p.z) / KM } - /** - * Height of the rendered ground under a surface point, in km. - * - * This has to agree with the *mesh*, not with the raster: the shell's cells are - * 0.7 km across and the raster's are 0.26 km, so the surface actually drawn is the - * raster sampled at the cell corners and stretched flat between them. Sampling the - * raster directly would put the observer's feet a few tens of metres off in rough - * country, and underground is a far worse error than hovering — so the four corners - * are interpolated, and half the spread across the cell is added as clearance. On - * the plains that clearance is nil; on a ridge it lifts you clear of the facet. - */ - private groundKm(p: SurfacePoint): number { - if (!this.heights) return 0 - const shift = 0.25 + mapThetaOrigin() / (2 * Math.PI) - const jf = (p.z / LENGTH_M) * SEG_ALONG - const iF = (normalizeTheta(p.theta - mapThetaOrigin()) / (2 * Math.PI) + shift) * SEG_AROUND - const j0 = Math.max(0, Math.min(SEG_ALONG - 1, Math.floor(jf))) - const i0 = Math.floor(iF) - const fj = Math.min(1, Math.max(0, jf - j0)) - const fi = iF - i0 - const corner = (di: number, dj: number): number => - this.sampleHeightKm((j0 + dj) / SEG_ALONG, ((i0 + di) / SEG_AROUND - shift)) - const c = [corner(0, 0), corner(1, 0), corner(0, 1), corner(1, 1)] - const lo = c[0]! * (1 - fj) + c[2]! * fj - const hi = c[1]! * (1 - fj) + c[3]! * fj - return lo * (1 - fi) + hi * fi + (Math.max(...c) - Math.min(...c)) / 2 + /** True once every tile of ground the view wants has arrived. The screenshot tour waits on it. */ + get settled(): boolean { + return this.model.lod.settled } /** Ground elevation under the observer, in metres. Reported by the panel. */ @@ -549,7 +508,7 @@ export class InteriorView { // ------------------------------------------------------------------- labels - setPlaces(places: readonly Place[]): void { + setPlaces(places: readonly Feature[]): void { if (!this.labelHost) return this.labelHost.replaceChildren() this.labels = places.map((place) => { @@ -563,12 +522,8 @@ export class InteriorView { }) } - private placePoint(p: Place): SurfacePoint | null { - const g = p.geometry - if (g.type === 'point') return { theta: g.theta, z: g.z } - if (g.type === 'band') return null - const first = g.points[0] - return first ? { theta: first[0], z: first[1] } : null + private placePoint(p: Feature): SurfacePoint | null { + return p.geometry.type === 'band' ? null : anchorOf(p) } /** @@ -732,6 +687,68 @@ export class InteriorView { this.queueSave() }, { passive: false }) window.addEventListener('pagehide', () => this.save()) + + /** + * Keys are listened for on the window, since the canvas never has focus, and + * ignored while typing — the panel's name and notes fields share the keyboard. + */ + const typing = (e: KeyboardEvent): boolean => + e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement + || e.target instanceof HTMLSelectElement + window.addEventListener('keydown', (e) => { + if (!this.running || typing(e) || e.metaKey || e.ctrlKey || e.altKey) return + this.boost = e.shiftKey + if (!(e.code in FLIGHT_KEYS)) return + this.held.add(e.code) + e.preventDefault() + }) + window.addEventListener('keyup', (e) => { + this.boost = e.shiftKey + this.held.delete(e.code) + }) + // A key released while another window had focus never sends its keyup. + window.addEventListener('blur', () => this.held.clear()) + } + + /** + * Move by the held keys for `dt` seconds. + * + * Speed scales with height above the ground: 1.7 m up is a brisk walk, and every + * doubling of height doubles it, so crossing a sea from 10 km up takes seconds and + * nothing in between needs a control of its own. Shift is five times that. Up and + * down are the same rule applied to the height itself — a fixed factor per second — + * so climbing is quick up high and careful near the ground. + */ + private fly(dt: number): void { + if (!this.held.size) return + let forward = 0, right = 0, up = 0 + for (const code of this.held) { + const k = FLIGHT_KEYS[code] + if (!k) continue + forward += k[0]; right += k[1]; up += k[2] + } + const boost = this.boost ? 5 : 1 + const speed = Math.max(2, this.eyeM * 1.2) * boost + const move = speed * dt + if (forward || right) { + const len = Math.hypot(forward, right) + const along = ((forward * Math.cos(this.yaw) - right * Math.sin(this.yaw)) / len) * move + const around = ((forward * Math.sin(this.yaw) + right * Math.cos(this.yaw)) / len) * move + const radius = Math.max(1, RADIUS_M - this.elevationM) + this.at = { + theta: normalizeTheta(this.at.theta + around / radius), + z: Math.min(LENGTH_M, Math.max(0, this.at.z + along)), + } + } + if (up) { + // Doubling height about every second, or every third of one with shift. The + // horizontal boost of five would be ×400 a second here: 100 m to 40 km at a tap. + const rate = this.boost ? 2 : 0.7 + this.eyeM = Math.max(EYE_MIN_M, Math.min(EYE_MAX_M, this.eyeM * Math.exp(up * rate * dt))) + } + this.place() + this.cb.onLook() + this.queueSave() } setEyeHeight(m: number): void { @@ -855,14 +872,15 @@ export class InteriorView { * each. Restores what it found; this runs on demand, never in the loop. * * The point is to name the culprit rather than infer it. A flat, colourless - * frame could be the ground shader losing its raster, or the ground missing + * frame could be the ground shader failing, or the ground missing * altogether with a cap or the haze behind it filling the view — those look * alike on screen and are entirely different faults. Asking each surface on its * own separates them in one dump: the ground alone should measure strongly * coloured, and if it does not, the fault is in the ground. */ private probeSurfaces(): Record<string, unknown>[] { - const meshes = this.scene.children.filter((c) => c instanceof Mesh) as Mesh[] + // The ground is a group of tiles rather than one mesh; it is probed as a whole. + const meshes = this.scene.children.filter((c) => c instanceof Mesh || c instanceof Group) as (Mesh | Group)[] const was = meshes.map((m) => m.visible) const out: Record<string, unknown>[] = [] for (const m of meshes) { @@ -887,31 +905,11 @@ export class InteriorView { * * Deliberately broad rather than aimed: the point of a dump is that it is taken * while the fault is on screen, and a second chance to ask a question that was - * left out may not come. The texture entries are the ones that matter most — - * `uploaded` says whether Three still has a live GL texture for the raster, and - * `mips`/`minFilter`/`aniso` say whether the sampler is the one D-057 asked for. + * left out may not come. The terrain entry says which generation of the world + * the ground is drawing and whether its tiles have all arrived. */ diagnose(): Record<string, unknown> { const gl = this.renderer.getContext() - const tex = (name: string): Record<string, unknown> | null => { - const t = this.groundMat.uniforms[name]?.value as Texture | null - if (!t) return null - const gpu = (this.renderer.properties.get(t) as { __webglTexture?: WebGLTexture }) - .__webglTexture - const img = t.image as { width?: number; height?: number } | undefined - return { - size: [img?.width ?? null, img?.height ?? null], - mips: t.generateMipmaps, - minFilter: t.minFilter, - magFilter: t.magFilter, - aniso: t.anisotropy, - wrap: [t.wrapS, t.wrapT], - flipY: t.flipY, - version: t.version, - uploaded: !!gpu, - live: gpu ? gl.isTexture(gpu) : false, - } - } const probe = this.probeFrame() return { when: new Date().toISOString(), @@ -938,7 +936,7 @@ export class InteriorView { observer: { theta: this.at.theta, z: this.at.z, eyeM: this.eyeM, yaw: this.yaw, pitch: this.pitch, fovDeg: this.fovDeg, - groundKm: this.groundKm(this.at), heights: !!this.heights, + groundKm: this.groundKm(this.at), lighting: this.lighting, dayPhase: this.dayPhase, clockDriven: this.clockDriven, }, uniforms: { @@ -951,7 +949,14 @@ export class InteriorView { uSpin: this.groundMat.uniforms['uSpin']!.value, uStarGain: this.groundMat.uniforms['uStarGain']!.value, }, - textures: { uColor: tex('uColor'), uHeight: tex('uHeight') }, + terrain: { + generation: terrain().generation, + compiled: !!terrain().compiled, + progress: terrain().progress, + tilesDrawn: this.model.lod.drawn, + settled: this.model.lod.settled, + workersBusy: terrain().inFlight, + }, programs: this.renderer.info.programs?.map((p) => ({ name: p.name, usedTimes: p.usedTimes, })), @@ -994,6 +999,10 @@ export class InteriorView { } this.spin = (this.spin + OMEGA_RAD_S * dt) % (2 * Math.PI) for (const m of this.shared) m.uniforms['uSpin']!.value = this.spin + this.fly(dt) + // Also what refines the ground around the eye, so it runs whether or not the + // observer moved: tiles keep arriving while they stand still. + this.model.setCamera(this.camera.position) this.renderer.render(this.scene, this.camera) this.record(now) this.updateLabels() @@ -1026,7 +1035,7 @@ export class InteriorView { this.trace.push({ t: Math.round(now), yaw: this.yaw, pitch: this.pitch, fov: this.fovDeg, eyeM: this.eyeM, z: this.at.z, theta: this.at.theta, - calls: r.calls, tris: r.triangles, probe, + calls: r.calls, tris: r.triangles, tiles: this.model.lod.drawn, probe, }) if (this.trace.length > TRACE_MAX) this.trace.splice(0, this.trace.length - TRACE_MAX) } diff --git a/src/views/map.ts b/src/views/map.ts index 9215500..af9feab 100644 --- a/src/views/map.ts +++ b/src/views/map.ts @@ -15,11 +15,12 @@ import { landStripCount, slatCount, slatWidth } from '../canon/slats.js' import { type SurfacePoint, fromMap, landStripCentre, slatCentre, toMap, } from '../canon/geometry.js' -import { type Place, isAreaKind } from '../world/schema.js' +import { type Feature, isAreaKind } from '../world/schema.js' +import { terrain } from '../terrain/service.js' import { KIND_COLOR } from './palette.js' export interface MapCallbacks { - onPick: (p: SurfacePoint, hit: Place | null, shiftKey: boolean) => void + onPick: (p: SurfacePoint, hit: Feature | null, shiftKey: boolean) => void onDragPlace: (id: string, p: SurfacePoint) => void onHover: (p: SurfacePoint | null) => void } @@ -33,7 +34,7 @@ export class TowerMap { private offsetX = 0 private offsetY = 0 - places: readonly Place[] = [] + places: readonly Feature[] = [] selectedId: string | null = null probe: { from: SurfacePoint; to: SurfacePoint | null } | null = null draftPolygon: [number, number][] = [] @@ -44,8 +45,8 @@ export class TowerMap { */ dragEnabled = true - /** The rendered terrain, drawn under everything. Null until it loads, or if absent. */ - private terrain: HTMLImageElement | null = null + /** The generated terrain, drawn under everything. Null until the first world is drawn. */ + private terrain: CanvasImageSource | null = null showTerrain = true private dragging: { id: string } | null = null @@ -62,17 +63,18 @@ export class TowerMap { } /** - * Load the terrain raster produced by `npm run terrain`. It is drawn against - * `mapThetaOrigin()`, which content/geography.json must match — see the note on - * MAP_CUT_WITHOUT_SLATS. Absence is fine; the map falls back to flat ground. + * Draw the terrain the service renders from the world (world-generation.md §9). It is + * rendered against `mapThetaOrigin()`, the same cut this map uses, and replaced + * whenever the world's terrain changes. Until the first one arrives the map shows + * flat ground. */ private loadTerrain(): void { - const img = new Image() - img.onload = () => { - this.terrain = img + const adopt = (): void => { + this.terrain = terrain().mapCanvas this.draw() } - img.src = `${import.meta.env.BASE_URL}terrain-color.png` + terrain().onMap(adopt) + adopt() } // ------------------------------------------------------------------- transform @@ -197,7 +199,7 @@ export class TowerMap { } /** Topmost place under the cursor, markers taking precedence over areas. */ - hitTest(px: number, py: number): Place | null { + hitTest(px: number, py: number): Feature | null { for (let i = this.places.length - 1; i >= 0; i--) { const p = this.places[i]! if (p.geometry.type !== 'point') continue @@ -400,8 +402,8 @@ export class TowerMap { /** Rank for label priority: settlements and landmarks beat broad regions. */ private static readonly LABEL_RANK: Record<string, number> = { - city: 0, tower: 1, town: 2, embassy: 3, landmark: 4, mountain: 5, - canyon: 6, river: 7, lake: 8, note: 9, forest: 10, desert: 11, region: 12, + settlement: 0, tower: 1, embassy: 3, landmark: 4, mountains: 5, + river: 7, lake: 8, sea: 8, note: 9, road: 10, path: 10, forest: 10, desert: 11, region: 12, } private drawPlaces(): void { @@ -459,10 +461,20 @@ export class TowerMap { ctx.setLineDash(p.public ? [] : [5, 4]) ctx.stroke() ctx.setLineDash([]) - const c = centroid(p.geometry.points) - const m = toMap({ theta: c[0], z: c[1] }) - const [sx, sy] = this.worldToScreen(m.x, m.y) - this.label(p.name, sx, sy, color, true) + if (p.kind === 'mountains' && p.peaks.length) { + // A range is read by its summits, so they carry the labels. + for (const pk of p.peaks) { + const m = toMap({ theta: pk.theta, z: pk.z }) + const [sx, sy] = this.worldToScreen(m.x, m.y) + this.peak(sx, sy, color, selected) + this.label(pk.name || p.name, sx + 8, sy + 4, color, false) + } + } else { + const c = centroid(p.geometry.points) + const m = toMap({ theta: c[0], z: c[1] }) + const [sx, sy] = this.worldToScreen(m.x, m.y) + this.label(p.name, sx, sy, color, true) + } } else { const m = toMap({ theta: p.geometry.theta, z: p.geometry.z }) const [sx, sy] = this.worldToScreen(m.x, m.y) @@ -481,6 +493,21 @@ export class TowerMap { private labelBoxes: { x: number; y: number; w: number; h: number }[] = [] + /** A summit: a small triangle, pointing up the way contour maps draw one. */ + private peak(x: number, y: number, color: string, selected: boolean): void { + const ctx = this.ctx + ctx.beginPath() + ctx.moveTo(x, y - 6) + ctx.lineTo(x + 5, y + 3) + ctx.lineTo(x - 5, y + 3) + ctx.closePath() + ctx.fillStyle = color + ctx.fill() + ctx.strokeStyle = selected ? '#fff' : 'rgba(0,0,0,0.6)' + ctx.lineWidth = selected ? 2 : 1.2 + ctx.stroke() + } + private label(text: string, x: number, y: number, color: string, centred: boolean): void { const ctx = this.ctx const w = ctx.measureText(text).width diff --git a/src/views/palette.ts b/src/views/palette.ts index 84e5f4e..14f0df2 100644 --- a/src/views/palette.ts +++ b/src/views/palette.ts @@ -1,17 +1,18 @@ -/** One colour per place kind, shared by the map, the list and the editor. */ -import type { PlaceKind } from '../world/schema.js' +/** One colour per feature kind, shared by the map, the list and the editor. */ +import type { FeatureKind } from '../world/schema.js' -export const KIND_COLOR: Record<PlaceKind, string> = { - city: '#e8c46a', - town: '#d8b25e', +export const KIND_COLOR: Record<FeatureKind, string> = { + settlement: '#e8c46a', + road: '#d8b25e', + path: '#b89a6a', embassy: '#d98cc8', tower: '#f0a0a0', landmark: '#e8815a', note: '#8a93a8', - mountain: '#cfd6e0', + mountains: '#cfd6e0', lake: '#4a9fd4', + sea: '#3f86c0', river: '#5cc0e8', - canyon: '#c58a5a', forest: '#6fbf8a', desert: '#d9c084', region: '#9aa8bd', diff --git a/src/views/raster.ts b/src/views/raster.ts deleted file mode 100644 index 609396c..0000000 --- a/src/views/raster.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Metadata for the rasters `npm run terrain` writes into static/. - * - * These must match the `render` block of content/geography.json — the generator - * reads that file, the browser only ever sees the PNGs, and nothing in between - * carries the numbers across. test/raster.test.ts asserts the two agree, because - * a silent disagreement here would displace the terrain rather than break it, and - * that failure mode has already cost this project once (D-024). - */ -export const RASTER_WIDTH = 2048 -export const RASTER_HEIGHT = 1192 -/** - * The working range terrain-height.png is stretched across: black is the floor of - * it, white the ceiling. It is deliberately not a round number and not anchored at - * zero — it only has to cover the terrain that exists, and the tighter it is the - * finer every step in it. Below zero is real: lake and sea floors are cut beneath - * the datum, and the old fixed 0..5,000 m encoding clipped them flat. - */ -export const RASTER_MIN_HEIGHT_M = -560 -export const RASTER_MAX_HEIGHT_M = 4400 -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' diff --git a/src/views/terrain-lod.ts b/src/views/terrain-lod.ts new file mode 100644 index 0000000..e9f3a57 --- /dev/null +++ b/src/views/terrain-lod.ts @@ -0,0 +1,292 @@ +/** + * The ground, as a quadtree of generated tiles. See world-generation.md §6.1. + * + * Replaces the single displaced cylinder of D-034. Every frame the view hands in + * where the eye is; this decides which tiles should be drawn at which level of + * detail, asks the terrain service for any it does not have, and shows the best + * ones it does. Detail arrives in the background and pops *in*: a tile is split only + * once all four of its children are ready, so the ground is never missing, only + * coarser than it is about to be. + * + * Both 3D views use it. From outside, looking in through a window 100 km away, the + * split rule keeps it to the coarsest tiles by itself. + */ +import { + BufferAttribute, BufferGeometry, Group, InterleavedBuffer, InterleavedBufferAttribute, Mesh, + type ShaderMaterial, Vector3, +} from 'three' +import type { TerrainService } from '../terrain/service.js' +import { + MATERIALS, MAX_LEVEL, ROOTS_ALONG, ROOTS_AROUND, type TileData, type TileGrid, type TileKey, + tileCentreKm, tileIndices, tileKey, tileSize, +} from '../terrain/tile.js' + +interface Node { + t: TileKey + key: string + mesh: Mesh | null + requested: boolean + /** Frame this node was last drawn or wanted, for eviction. */ + used: number + /** Where the tile's mesh is placed: its centre on the datum cylinder, km. */ + centre: Vector3 + /** + * A sphere around the tile's actual surface, for deciding how near it is. Until the + * tile is built its relief is unknown, and the datum centre and flat extent stand in. + */ + boundCentre: Vector3 + radius: number + /** The tile's larger side, km. */ + size: number +} + +export interface TerrainLODOptions { + grid: TileGrid + maxLevel: number + /** Split a tile once the eye is closer than this many tile-widths. */ + split: number + /** Most tiles kept in memory at once. */ + budget: number +} + +/** + * Measured, not guessed. At a split of 1.8 a level holds about 64 tiles around the eye, + * and a 65-vertex tile at that distance puts a vertex every half degree or so — about + * a dozen pixels on a laptop screen — which the smooth normals hide. At 2.4 it was + * 96 a level and three million triangles for no visible gain. + */ +export const DESKTOP_LOD: TerrainLODOptions = { grid: 65, maxLevel: MAX_LEVEL, split: 1.8, budget: 1400 } +export const PHONE_LOD: TerrainLODOptions = { grid: 33, maxLevel: MAX_LEVEL, split: 1.6, budget: 900 } + +/** Most tile requests outstanding at once, per worker. More only queues. */ +const REQUESTS_PER_WORKER = 2 + +export class TerrainLOD { + readonly group = new Group() + private nodes = new Map<string, Node>() + /** The previous generation's tiles, drawn until the new one has its roots. */ + private old: Map<string, Node> | null = null + private generation = -1 + private frame = 0 + private outstanding = 0 + private eye = new Vector3() + + constructor( + private service: TerrainService, + private material: ShaderMaterial, + private opts: TerrainLODOptions, + ) { + this.group.name = 'ground' + } + + /** True when every tile the current view wants is on the ground. */ + get settled(): boolean { + return this.generation >= 0 && this.generation === this.service.generation && !this.old + && this.outstanding === 0 && this.wantedMissing === 0 + } + private wantedMissing = 0 + + /** Choose and request tiles for an eye at `eye` (km, tower frame). Call every frame. */ + update(eye: Vector3): void { + this.frame++ + this.eye.copy(eye) + if (this.service.generation !== this.generation && this.service.compiled) { + this.startGeneration() + } + for (const n of this.nodes.values()) if (n.mesh) n.mesh.visible = false + if (this.old) for (const n of this.old.values()) if (n.mesh) n.mesh.visible = false + if (this.generation < 0) return + + const wanted: Node[] = [] + const roots: Node[] = [] + for (let i = 0; i < ROOTS_ALONG; i++) { + for (let j = 0; j < ROOTS_AROUND; j++) roots.push(this.node(this.nodes, { level: 0, i, j })) + } + + if (this.old && roots.every((r) => r.mesh)) { + for (const n of this.old.values()) this.dispose(n) + this.old = null + } + if (this.old) { + // Keep showing the last world while the new one's roots arrive. + for (const r of roots) if (!r.mesh) wanted.push(r) + for (let i = 0; i < ROOTS_ALONG; i++) { + for (let j = 0; j < ROOTS_AROUND; j++) { + this.select(this.old, this.node(this.old, { level: 0, i, j }), null) + } + } + } else { + for (const r of roots) this.select(this.nodes, r, wanted) + } + + this.wantedMissing = wanted.length + this.request(wanted) + this.evict() + } + + private startGeneration(): void { + if (this.old) for (const n of this.old.values()) this.dispose(n) + this.old = this.nodes.size ? this.nodes : null + this.nodes = new Map() + this.generation = this.service.generation + } + + private node(map: Map<string, Node>, t: TileKey): Node { + const key = tileKey(t) + let n = map.get(key) + if (!n) { + const size = tileSize(t.level) + const along = size.along / 1000, around = size.around / 1000 + n = { + t, key, mesh: null, requested: false, used: 0, + centre: new Vector3(...tileCentreKm(t)), + boundCentre: new Vector3(...tileCentreKm(t)), + radius: Math.hypot(along, around) / 2, + size: Math.max(along, around), + } + map.set(key, n) + } + return n + } + + private distance(n: Node): number { + return Math.max(0, this.eye.distanceTo(n.boundCentre) - n.radius) + } + + /** + * Draw `n`, or its children if they are wanted and all ready. `wanted` collects the + * tiles that should exist and do not; null while drawing an old generation, which + * never asks for anything. + */ + private select(map: Map<string, Node>, n: Node, wanted: Node[] | null): void { + n.used = this.frame + if (!n.mesh) { + if (wanted && !n.requested) wanted.push(n) + return + } + const split = n.t.level < this.opts.maxLevel && this.distance(n) < this.opts.split * n.size + if (split) { + const { level, i, j } = n.t + const around = ROOTS_AROUND * 2 ** (level + 1) + const kids = [ + this.child(map, level + 1, 2 * i, 2 * j, around, wanted !== null), + this.child(map, level + 1, 2 * i + 1, 2 * j, around, wanted !== null), + this.child(map, level + 1, 2 * i, 2 * j + 1, around, wanted !== null), + this.child(map, level + 1, 2 * i + 1, 2 * j + 1, around, wanted !== null), + ].filter((k): k is Node => k !== null) + if (kids.length === 4 && kids.every((k) => k.mesh)) { + for (const k of kids) this.select(map, k, wanted) + return + } + if (wanted) for (const k of kids) if (!k.mesh && !k.requested) wanted.push(k) + } + n.mesh.visible = true + } + + private child(map: Map<string, Node>, level: number, i: number, j: number, around: number, create: boolean): Node | null { + const t = { level, i, j: ((j % around) + around) % around } + if (create) { + const n = this.node(map, t) + n.used = this.frame + return n + } + return map.get(tileKey(t)) ?? null + } + + /** Ask for the wanted tiles, coarsest and nearest first, as the pool has room. */ + private request(wanted: Node[]): void { + const room = this.service.workers * REQUESTS_PER_WORKER - this.outstanding + if (room <= 0 || !wanted.length) return + wanted.sort((a, b) => a.t.level - b.t.level || this.distance(a) / a.size - this.distance(b) / b.size) + const gen = this.generation + for (const n of wanted.slice(0, room)) { + n.requested = true + this.outstanding++ + void this.service.tile(n.t, this.opts.grid).then((data) => { + this.outstanding-- + if (gen !== this.generation || this.nodes.get(n.key) !== n) return + if (!data) { + n.requested = false + return + } + /** + * Measure the tile now that its relief is known. A fixed allowance for the + * tallest mountain made every small tile within 5 km of the eye look near + * enough to split, all the way to the finest level, which is tens of + * thousands of tiles; the real extent of a 33 m tile is a few metres. + */ + const midKm = (data.minM + data.maxM) / 2000 + const r = Math.hypot(n.centre.x, n.centre.y) + n.boundCentre.set(n.centre.x * (1 - midKm / r), n.centre.y * (1 - midKm / r), n.centre.z) + n.radius = Math.hypot(n.radius, (data.maxM - data.minM) / 2000) + n.mesh = this.build(n, data) + this.group.add(n.mesh) + }, () => { + this.outstanding-- + n.requested = false + }) + } + } + + private build(n: Node, data: TileData): Mesh { + const g = new BufferGeometry() + g.setAttribute('position', new BufferAttribute(data.positions, 3)) + g.setAttribute('normal', new BufferAttribute(data.normals, 3, true)) + const mats = new InterleavedBuffer(data.materials, MATERIALS.length) + g.setAttribute('aMatA', new InterleavedBufferAttribute(mats, 4, 0, true)) + g.setAttribute('aMatB', new InterleavedBufferAttribute(mats, 4, 4, true)) + /** + * The index array is shared, but each tile gets its own attribute around it. + * Sharing the attribute too would share one GL buffer, and disposing any tile + * deletes that buffer out from under every other tile still drawing with it. + */ + g.setIndex(new BufferAttribute(tileIndices(this.opts.grid), 1)) + g.computeBoundingSphere() + /** + * Once on the GPU the CPU copies are dead weight — a thousand tiles of them is a + * couple of hundred megabytes — and nothing reads them again: the bounding sphere + * is already computed, and standing on the ground asks the elevation function, + * not the mesh. So they are dropped on upload. + */ + const release = function (this: { array: unknown }): void { this.array = null } + for (const a of [g.attributes['position'], g.attributes['normal'], g.index]) { + (a as BufferAttribute).onUpload(release) + } + mats.onUpload(release) + const mesh = new Mesh(g, this.material) + mesh.name = `tile ${n.key}` + mesh.position.copy(n.centre) + mesh.matrixAutoUpdate = false + mesh.updateMatrix() + mesh.visible = false + return mesh + } + + /** Drop the least recently used tiles past the budget. Roots stay: they are the floor. */ + private evict(): void { + let loaded = 0 + for (const n of this.nodes.values()) if (n.mesh) loaded++ + if (loaded <= this.opts.budget) return + const victims = [...this.nodes.values()] + .filter((n) => n.mesh && n.t.level > 0 && n.used < this.frame) + .sort((a, b) => a.used - b.used) + for (const n of victims.slice(0, loaded - this.opts.budget)) { + this.dispose(n) + this.nodes.delete(n.key) + } + } + + private dispose(n: Node): void { + if (!n.mesh) return + this.group.remove(n.mesh) + n.mesh.geometry.dispose() + n.mesh = null + } + + /** Tiles currently drawn, for the flight recorder. */ + get drawn(): number { + let k = 0 + for (const n of this.nodes.values()) if (n.mesh?.visible) k++ + return k + } +} diff --git a/src/world/schema.ts b/src/world/schema.ts index 0dc57d3..21ca3e4 100644 --- a/src/world/schema.ts +++ b/src/world/schema.ts @@ -1,5 +1,9 @@ /** - * The world file. See design.md §3.2. + * The world file, v2. See world-generation.md §3 and design.md §3.2. + * + * One file holds every feature of the world — landforms, water, forest, settlements, + * routes and plain markers — and the terrain is generated from it live, in the + * browser (D-062). There is no separate geography file any more. * * Coordinates are canon (theta radians, z metres), never map pixels, so the world * survives any change to how the map is drawn (decisions.md D-007, D-011). @@ -8,25 +12,37 @@ * blurb — public description, shipped to readers * notes — the author's working notes, NEVER shipped * plus `public: false` for a whole entry that is not ready to exist publicly. + * + * Every kind has the same `geometry` field, so the map, the list and the editor + * handle all of them one way. What differs by kind is the parameters beside it, and + * each phase of world-generation.md §14 adds the parameters its generators use — + * nothing is declared here before something reads it. */ -export const WORLD_VERSION = 1 +export const WORLD_VERSION = 2 + +// ------------------------------------------------------------------------ kinds -export const PLACE_KINDS = [ - 'city', 'town', 'embassy', 'tower', 'landmark', 'note', - 'mountain', 'lake', 'river', 'canyon', 'forest', 'desert', 'region', -] as const -export type PlaceKind = (typeof PLACE_KINDS)[number] +/** Features that shape the ground. */ +export const TERRAIN_KINDS = ['mountains', 'lake', 'sea', 'river', 'desert', 'forest'] as const +/** Built things. Generated from in later phases (world-generation.md §4.7, §4.8). */ +export const BUILT_KINDS = ['settlement', 'road', 'path'] as const +/** Labels on the world that generate nothing. */ +export const MARKER_KINDS = ['embassy', 'tower', 'landmark', 'note', 'region'] as const -/** Kinds drawn as a filled area. */ -export const AREA_KINDS: readonly PlaceKind[] = ['lake', 'forest', 'desert', 'region'] -/** Kinds drawn as an open line — rivers and canyons have length but no interior. */ -export const LINE_KINDS: readonly PlaceKind[] = ['river', 'canyon'] +export const FEATURE_KINDS = [...TERRAIN_KINDS, ...BUILT_KINDS, ...MARKER_KINDS] as const +export type FeatureKind = (typeof FEATURE_KINDS)[number] -export const isAreaKind = (k: PlaceKind): boolean => AREA_KINDS.includes(k) -export const isLineKind = (k: PlaceKind): boolean => LINE_KINDS.includes(k) +/** Kinds drawn as a filled area: a polygon, or a band that rings the world. */ +export const AREA_KINDS: readonly FeatureKind[] = + ['mountains', 'lake', 'sea', 'desert', 'forest', 'region'] +/** Kinds drawn as an open line. */ +export const LINE_KINDS: readonly FeatureKind[] = ['river', 'road', 'path'] + +export const isAreaKind = (k: FeatureKind): boolean => AREA_KINDS.includes(k) +export const isLineKind = (k: FeatureKind): boolean => LINE_KINDS.includes(k) /** Kinds drawn by tracing a path rather than by a single click. */ -export const isTracedKind = (k: PlaceKind): boolean => isAreaKind(k) || isLineKind(k) +export const isTracedKind = (k: FeatureKind): boolean => isAreaKind(k) || isLineKind(k) export type Geometry = | { type: 'point'; theta: number; z: number } @@ -35,36 +51,185 @@ export type Geometry = /** * A slice of the whole circumference between two z. Not expressible as a * polygon: theta 0 and theta 2pi normalise to the same map row, so a band - * written as four corners collapses into a hairline. Tower's desert and forest - * both ring the world, so this is a first-class shape here, not an edge case. + * written as four corners collapses into a hairline. Tower's seas, desert, + * forest and the Girdle all ring the world, so this is a first-class shape (D-027). */ | { type: 'band'; zFrom: number; zTo: number } -export interface Place { +export type GeometryType = Geometry['type'] + +// --------------------------------------------------------------------- features + +interface Base<K extends FeatureKind> { id: string name: string - kind: PlaceKind + kind: K geometry: Geometry blurb: string notes: string public: boolean + /** + * Written once, when the feature is created, and mixed into the world seed. + * Never derived from the id: ids follow names until pinned (D-012), and a seed + * that followed the id would reshuffle a mountain every time it was renamed. + * world-generation.md §5.5. + */ + seed: number +} + +export interface Peak { + name: string + theta: number + z: number + elevationM: number +} + +export interface Mountains extends Base<'mountains'> { + /** + * Summits, each held at its elevation. A range with none is a ridge: a polygon + * rises to `heightM` at its middle, and a band to `heightM` along its centre line. + */ + peaks: Peak[] + heightM: number + /** 0..1, how far ridged noise breaks the form into crests. */ + ruggedness: number + snowlineM: number +} + +export interface Lake extends Base<'lake'> { + /** 'auto' fills the basin from its floor, the way a tarn fills (D-020). */ + levelM: number | 'auto' + depthM: number + /** How the floor meets the shore: a steep bowl with a moraine, or a long soft shelf. */ + basin: 'bowl' | 'flooded' + /** Width of the shelving shore, for a flooded basin. */ + shoreM: number + /** Height of the moraine around a bowl. */ + rimM: number +} + +export interface Sea extends Base<'sea'> { + levelM: number + depthM: number + shoreM: number +} + +export interface River extends Base<'river'> { + /** Mean flow of this river's own catchment, before any tributary joins it. */ + flowM3S: number + /** Water surface at the source, or 'terrain' to take it from the ground there. */ + sourceM: number | 'terrain' + /** The lake, sea or river it ends in, or 'auto' to find it from where it ends. */ + outlet: string | 'auto' +} + +export interface Desert extends Base<'desert'> { + /** How far the desert's ground stands above the land around it. */ + upliftM: number + /** Width of the slope up onto it. */ + edgeM: number +} + +export interface Forest extends Base<'forest'> { + upliftM: number + edgeM: number +} + +export interface Settlement extends Base<'settlement'> { + size: 'hamlet' | 'village' | 'town' | 'station' | 'city' +} + +export interface Route extends Base<'road' | 'path'> { + widthM: number +} + +export interface Marker extends Base<'embassy' | 'tower' | 'landmark' | 'note' | 'region'> { + /** Height of a tower, for when towers are drawn. */ + heightM?: number +} + +export type Feature = + | Mountains | Lake | Sea | River | Desert | Forest | Settlement | Route | Marker + +/** The gentle relief everywhere nothing else is (today's "open ground"). */ +export interface Ground { + baseM: number + /** Amplitude of the broadest swell. The finer layers scale with it. */ + reliefM: number + notes: string } export interface World { version: number - places: Place[] + /** Changes every feature's randomness at once. Rarely touched. */ + seed: number + ground: Ground + features: Feature[] } -/** A place as it reaches readers: no working notes, no unpublished entries. */ -export interface PublicPlace { - id: string - name: string - kind: PlaceKind - geometry: Geometry - blurb: string +/** A feature as it reaches readers: no working notes. Unpublished ones never get here. */ +export type PublicFeature = Omit<Feature, 'notes'> + +// --------------------------------------------------------------------- defaults + +export const defaultGround = (): Ground => ({ baseM: 60, reliefM: 260, notes: '' }) + +export const emptyWorld = (): World => ({ + version: WORLD_VERSION, seed: 1, ground: defaultGround(), features: [], +}) + +/** A fresh seed for a new feature — the one place generation is allowed a random number. */ +export const newSeed = (): number => Math.floor(Math.random() * 0x7fffffff) + +/** Which shapes a kind may take. The first is what its tool draws. */ +export function geometryTypesFor(kind: FeatureKind): readonly GeometryType[] { + if (kind === 'river' || kind === 'road' || kind === 'path') return ['polyline'] + if (kind === 'lake') return ['polygon'] + if (kind === 'mountains' || kind === 'sea' || kind === 'desert' || kind === 'forest') { + return ['polygon', 'band'] + } + if (kind === 'settlement') return ['point'] + if (kind === 'region') return ['polygon', 'band'] + // Markers can be any shape: The Narrows is a landmark with length. + return ['point', 'polyline', 'polygon', 'band'] } -export const emptyWorld = (): World => ({ version: WORLD_VERSION, places: [] }) +type Common = Pick<Feature, 'id' | 'name' | 'geometry' | 'blurb' | 'notes' | 'public' | 'seed'> + +/** + * A feature of `kind` with every parameter at its default, keeping the common + * fields. Edges default narrow: a region drawn by hand is meant to be about as big as + * it was drawn, and its soft edge reaches outward (field.ts). The world's own ringing + * belts carry the wide edges v1 gave them. + * + * Used when creating a feature and when changing an existing one's kind. + */ +export function withKind(common: Common, kind: FeatureKind): Feature { + const c = { ...common } + switch (kind) { + case 'mountains': + return { ...c, kind, peaks: [], heightM: 2400, ruggedness: 0.55, snowlineM: 3000 } + case 'lake': + return { ...c, kind, levelM: 'auto', depthM: 60, basin: 'bowl', shoreM: 2000, rimM: 20 } + case 'sea': + return { ...c, kind, levelM: 90, depthM: 420, shoreM: 4000 } + case 'river': + return { ...c, kind, flowM3S: 40, sourceM: 'terrain', outlet: 'auto' } + case 'desert': + return { ...c, kind, upliftM: 300, edgeM: 4000 } + case 'forest': + return { ...c, kind, upliftM: 0, edgeM: 3000 } + case 'settlement': + return { ...c, kind, size: 'village' } + case 'road': + case 'path': + return { ...c, kind, widthM: kind === 'road' ? 8 : 2 } + default: + return { ...c, kind } + } +} + +// -------------------------------------------------------------------------- ids export function makeId(name: string, taken: ReadonlySet<string>): string { const base = @@ -83,36 +248,116 @@ export function isAutoId(id: string, name: string): boolean { return id === slug || new RegExp(`^${slug}-\\d+$`).test(id) } +/** A single representative point for a feature, whatever shape it is. */ +export function anchorOf(f: Feature): { theta: number; z: number } { + if (f.kind === 'mountains' && f.peaks[0]) return { theta: f.peaks[0].theta, z: f.peaks[0].z } + const g = f.geometry + if (g.type === 'point') return { theta: g.theta, z: g.z } + if (g.type === 'band') return { theta: 0, z: (g.zFrom + g.zTo) / 2 } + return { theta: g.points[0]![0], z: g.points[0]![1] } +} + +// ---------------------------------------------------------------------- parsing + +const num = (v: unknown, fallback: number): number => + typeof v === 'number' && Number.isFinite(v) ? v : fallback +const unit = (v: unknown, fallback: number): number => Math.min(1, Math.max(0, num(v, fallback))) +const str = (v: unknown, fallback = ''): string => (typeof v === 'string' ? v : fallback) + /** * Accept anything shaped like a world, discarding entries that are not. Import - * handles files written by older versions and by hand, so it must not throw. + * handles files written by hand, so it must not throw. A v1 file has no + * `features` and comes back empty: there is no backwards compatibility, and the + * one v1 world that mattered was ported by tools/port-world.mjs. */ export function parseWorld(raw: unknown): World { const src = raw as Partial<World> | null - const places: Place[] = [] - if (src && Array.isArray(src.places)) { - const seen = new Set<string>() - for (const p of src.places as Partial<Place>[]) { - const geometry = parseGeometry(p?.geometry) - if (!geometry) continue - const kind: PlaceKind = PLACE_KINDS.includes(p?.kind as PlaceKind) - ? (p!.kind as PlaceKind) - : 'landmark' - const name = typeof p?.name === 'string' && p.name.trim() ? p.name : 'Unnamed' - const id = typeof p?.id === 'string' && p.id && !seen.has(p.id) ? p.id : makeId(name, seen) - seen.add(id) - places.push({ - id, - name, - kind, - geometry, - blurb: typeof p?.blurb === 'string' ? p.blurb : '', - notes: typeof p?.notes === 'string' ? p.notes : '', - public: p?.public !== false, - }) + const world = emptyWorld() + if (!src || typeof src !== 'object') return world + world.seed = Math.floor(num(src.seed, 1)) + const g = src.ground as Partial<Ground> | undefined + if (g && typeof g === 'object') { + world.ground = { + baseM: num(g.baseM, 60), reliefM: Math.max(0, num(g.reliefM, 260)), notes: str(g.notes), } } - return { version: WORLD_VERSION, places } + if (!Array.isArray(src.features)) return world + const seen = new Set<string>() + for (const f of src.features as unknown as Record<string, unknown>[]) { + const parsed = parseFeature(f, seen) + if (!parsed) continue + seen.add(parsed.id) + world.features.push(parsed) + } + return world +} + +function parseFeature(f: Record<string, unknown> | null, seen: Set<string>): Feature | null { + if (!f || typeof f !== 'object') return null + const geometry = parseGeometry(f['geometry']) + if (!geometry) return null + const kind: FeatureKind = FEATURE_KINDS.includes(f['kind'] as FeatureKind) + ? (f['kind'] as FeatureKind) + : 'landmark' + // A shape the kind cannot take would give the generators nothing to work with. + if (!geometryTypesFor(kind).includes(geometry.type)) return null + const name = str(f['name']).trim() ? str(f['name']) : 'Unnamed' + const rawId = f['id'] + const id = typeof rawId === 'string' && rawId && !seen.has(rawId) ? rawId : makeId(name, seen) + const base = withKind({ + id, name, geometry, + blurb: str(f['blurb']), + notes: str(f['notes']), + public: f['public'] !== false, + seed: Math.floor(num(f['seed'], hashString(id))), + }, kind) + return fillParams(base, f) +} + +/** Overlay whatever valid parameters the source carries onto a kind's defaults. */ +function fillParams(d: Feature, f: Record<string, unknown>): Feature { + switch (d.kind) { + case 'mountains': { + const peaks = Array.isArray(f['peaks']) + ? (f['peaks'] as Record<string, unknown>[]).flatMap((p): Peak[] => + p && Number.isFinite(p['theta']) && Number.isFinite(p['z']) && Number.isFinite(p['elevationM']) + ? [{ name: str(p['name']), theta: p['theta'] as number, z: p['z'] as number, + elevationM: p['elevationM'] as number }] + : []) + : [] + return { ...d, peaks, heightM: num(f['heightM'], d.heightM), + ruggedness: unit(f['ruggedness'], d.ruggedness), + snowlineM: num(f['snowlineM'], d.snowlineM) } + } + case 'lake': + return { ...d, + levelM: Number.isFinite(f['levelM']) ? (f['levelM'] as number) : 'auto', + depthM: Math.max(1, num(f['depthM'], d.depthM)), + basin: f['basin'] === 'flooded' ? 'flooded' : 'bowl', + shoreM: Math.max(1, num(f['shoreM'], d.shoreM)), + rimM: Math.max(0, num(f['rimM'], d.rimM)) } + case 'sea': + return { ...d, levelM: num(f['levelM'], d.levelM), + depthM: Math.max(1, num(f['depthM'], d.depthM)), + shoreM: Math.max(1, num(f['shoreM'], d.shoreM)) } + case 'river': + return { ...d, flowM3S: Math.max(0.1, num(f['flowM3S'], d.flowM3S)), + sourceM: Number.isFinite(f['sourceM']) ? (f['sourceM'] as number) : 'terrain', + outlet: typeof f['outlet'] === 'string' && f['outlet'] ? f['outlet'] : 'auto' } + case 'desert': + case 'forest': + return { ...d, upliftM: num(f['upliftM'], d.upliftM), edgeM: Math.max(1, num(f['edgeM'], d.edgeM)) } + case 'settlement': { + const sizes = ['hamlet', 'village', 'town', 'station', 'city'] as const + const size = sizes.includes(f['size'] as never) ? (f['size'] as Settlement['size']) : d.size + return { ...d, size } + } + case 'road': + case 'path': + return { ...d, widthM: Math.max(0.5, num(f['widthM'], d.widthM)) } + default: + return Number.isFinite(f['heightM']) ? { ...d, heightM: f['heightM'] as number } : d + } } function parseGeometry(g: unknown): Geometry | null { @@ -125,7 +370,7 @@ function parseGeometry(g: unknown): Geometry | null { return { type: 'point', theta: src.theta!, z: src.z! } } if (src.type === 'band' && Number.isFinite(src.zFrom) && Number.isFinite(src.zTo)) { - return { type: 'band', zFrom: src.zFrom!, zTo: src.zTo! } + return { type: 'band', zFrom: Math.min(src.zFrom!, src.zTo!), zTo: Math.max(src.zFrom!, src.zTo!) } } if ((src.type === 'polygon' || src.type === 'polyline') && Array.isArray(src.points)) { const points = src.points @@ -139,16 +384,44 @@ function parseGeometry(g: unknown): Geometry | null { return null } +/** A stable seed for a file that did not carry one, so it at least stays put. */ +export function hashString(s: string): number { + let h = 2166136261 + for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619) + return (h >>> 0) & 0x7fffffff +} + /** - * Strip a world down to what readers get. Drops non-public entries entirely and - * removes `notes` from the rest — see decisions.md D-011 on why this is not, by - * itself, sufficient privacy. + * Strip a world down to what readers get. Drops non-public features entirely and + * removes every `notes` field, the ground's included — see decisions.md D-011 on + * why this is not, by itself, sufficient privacy. + * + * A dropped feature takes its terrain with it: readers get no basin for an + * unpublished lake. That is the point — a hole in the ground shaped like a lake + * would publish the lake (world-generation.md §3.3). */ -export function toPublicWorld(world: World): { version: number; places: PublicPlace[] } { +export function toPublicWorld(world: World): World { return { version: WORLD_VERSION, - places: world.places - .filter((p) => p.public) - .map(({ id, name, kind, geometry, blurb }) => ({ id, name, kind, geometry, blurb })), + seed: world.seed, + ground: { ...world.ground, notes: '' }, + features: world.features + .filter((f) => f.public) + .map((f) => ({ ...f, notes: '' })), } } + +/** + * Everything about the world that changes its terrain, and nothing that does not. + * Renaming a mountain or writing a blurb must not regenerate the world. + */ +export function terrainSignature(world: World): string { + return JSON.stringify({ + seed: world.seed, + ground: { baseM: world.ground.baseM, reliefM: world.ground.reliefM }, + // The id stays out too: a rename re-ids a feature, and references to it (a + // river's outlet) are rewritten with it, which is what shows up here instead. + features: world.features.map( + ({ id: _i, name: _n, blurb: _b, notes: _no, public: _p, ...rest }) => rest), + }) +} diff --git a/src/world/store.ts b/src/world/store.ts index 3a5d6cd..c7a81c3 100644 --- a/src/world/store.ts +++ b/src/world/store.ts @@ -6,10 +6,14 @@ * content once to seed the repo. There is no backend — GitLab Pages is static. */ import { - type Place, type World, emptyWorld, parseWorld, toPublicWorld, WORLD_VERSION, + type Feature, type World, emptyWorld, parseWorld, toPublicWorld, } from './schema.js' -const STORAGE_KEY = 'tower.world.v1' +/** + * v2, since the world file changed shape (D-062). A v1 draft under the old key is left + * where it is rather than read: it holds places, not features, and would load empty. + */ +const STORAGE_KEY = 'tower.world.v2' const SAVE_DEBOUNCE_MS = 400 export type SaveState = 'saved' | 'saving' | 'error' @@ -39,7 +43,7 @@ export class WorldStore { */ async load(seedUrl: string): Promise<void> { const stored = this.readStorage() - if (stored && stored.places.length > 0) { + if (stored && stored.features.length > 0) { this.world = stored this.emit() return @@ -56,7 +60,7 @@ export class WorldStore { // No seed file yet is entirely normal — start empty. } this.emit() - return this.world.places.length + return this.world.features.length } private readStorage(): World | null { @@ -107,28 +111,41 @@ export class WorldStore { // ------------------------------------------------------------------------ CRUD - get places(): readonly Place[] { - return this.world.places + /** The whole world, for the terrain service. Read-only by convention. */ + get current(): World { + return this.world + } + + get features(): readonly Feature[] { + return this.world.features } - get(id: string): Place | undefined { - return this.world.places.find((p) => p.id === id) + get(id: string): Feature | undefined { + return this.world.features.find((p) => p.id === id) } get takenIds(): ReadonlySet<string> { - return new Set(this.world.places.map((p) => p.id)) + return new Set(this.world.features.map((p) => p.id)) } - add(place: Place): Place { - this.world.places.push(place) + add(feature: Feature): Feature { + this.world.features.push(feature) this.touch() - return place + return feature } - update(id: string, patch: Partial<Omit<Place, 'id'>>): void { - const place = this.get(id) - if (!place) return - Object.assign(place, patch) + update(id: string, patch: Partial<Omit<Feature, 'id'>>): void { + const feature = this.get(id) + if (!feature) return + Object.assign(feature, patch) + this.touch() + } + + /** Swap a feature for another at the same position — a change of kind. */ + replace(id: string, next: Feature): void { + const i = this.world.features.findIndex((p) => p.id === id) + if (i < 0) return + this.world.features[i] = next this.touch() } @@ -138,17 +155,21 @@ export class WorldStore { */ setId(oldId: string, newId: string): boolean { if (oldId === newId || this.get(newId)) return false - const place = this.get(oldId) - if (!place) return false - place.id = newId + const feature = this.get(oldId) + if (!feature) return false + feature.id = newId + // A river names what it flows into by id; renaming The Deep must not strand it. + for (const f of this.world.features) { + if (f.kind === 'river' && f.outlet === oldId) f.outlet = newId + } this.touch() return true } remove(id: string): void { - const i = this.world.places.findIndex((p) => p.id === id) + const i = this.world.features.findIndex((p) => p.id === id) if (i >= 0) { - this.world.places.splice(i, 1) + this.world.features.splice(i, 1) this.touch() } } @@ -162,7 +183,7 @@ export class WorldStore { /** Everything, including private notes and unpublished places. The backup. */ fullJSON(): string { - return JSON.stringify({ version: WORLD_VERSION, places: this.world.places }, null, 2) + return JSON.stringify(this.world, null, 2) } /** Public places only, notes stripped. The file that ships in the repo. */ @@ -171,7 +192,7 @@ export class WorldStore { } publicCount(): number { - return this.world.places.filter((p) => p.public).length + return this.world.features.filter((p) => p.public).length } } diff --git a/static/terrain-color.png b/static/terrain-color.png deleted file mode 100644 index b4d0e67..0000000 Binary files a/static/terrain-color.png and /dev/null differ diff --git a/static/terrain-corridor.png b/static/terrain-corridor.png deleted file mode 100644 index 394bdbd..0000000 Binary files a/static/terrain-corridor.png and /dev/null differ diff --git a/static/terrain-height.png b/static/terrain-height.png deleted file mode 100644 index c3ea60e..0000000 Binary files a/static/terrain-height.png and /dev/null differ diff --git a/static/world.json b/static/world.json index f5e1438..964a4bb 100644 --- a/static/world.json +++ b/static/world.json @@ -1,1005 +1,890 @@ { - "version": 1, - "places": [ + "version": 2, + "seed": 1729, + "ground": { + "baseM": 60, + "reliefM": 260, + "notes": "" + }, + "features": [ { "id": "sunward-sea", "name": "The Sunward Sea", - "kind": "lake", + "blurb": "", + "notes": "", + "public": true, + "seed": 1437950893, + "kind": "sea", "geometry": { "type": "band", "zFrom": 0, "zTo": 42000 }, - "blurb": "" + "levelM": 90, + "depthM": 420, + "shoreM": 16000 }, { "id": "deepward-sea", "name": "The Deepward Sea", - "kind": "lake", + "blurb": "", + "notes": "", + "public": true, + "seed": 1346489887, + "kind": "sea", "geometry": { "type": "band", "zFrom": 496000, "zTo": 540000 }, - "blurb": "" + "levelM": 90, + "depthM": 420, + "shoreM": 16000 }, { "id": "sun-cap", "name": "The Sun Cap", + "blurb": "", + "notes": "", + "public": true, + "seed": 722399060, "kind": "landmark", "geometry": { "type": "point", "theta": 0, "z": 0 - }, - "blurb": "" + } }, { "id": "far-cap", "name": "The Far Cap", + "blurb": "", + "notes": "", + "public": true, + "seed": 1439513367, "kind": "landmark", "geometry": { "type": "point", "theta": 0, "z": 540000 - }, - "blurb": "" + } }, { "id": "snowcrown", "name": "Snowcrown", - "kind": "mountain", + "blurb": "", + "notes": "", + "public": true, + "seed": 1497766597, + "kind": "mountains", "geometry": { - "type": "point", - "theta": 0.3, - "z": 88000 + "type": "polygon", + "points": [ + [0.9, 88000], + [0.894867, 92307], + [0.879555, 96541], + [0.854328, 100629], + [0.819615, 104500], + [0.776012, 108089], + [0.724264, 111335], + [0.665257, 114181], + [0.6, 116579], + [0.52961, 118488], + [0.455291, 119876], + [0.378316, 120718], + [0.3, 121000], + [0.221684, 120718], + [0.144709, 119876], + [0.07039, 118488], + [0, 116579], + [-0.065257, 114181], + [-0.124264, 111335], + [-0.176012, 108089], + [-0.219615, 104500], + [-0.254328, 100629], + [-0.279555, 96541], + [-0.294867, 92307], + [-0.3, 88000], + [-0.294867, 83693], + [-0.279555, 79459], + [-0.254328, 75371], + [-0.219615, 71500], + [-0.176012, 67911], + [-0.124264, 64665], + [-0.065257, 61819], + [0, 59421], + [0.07039, 57512], + [0.144709, 56124], + [0.221684, 55282], + [0.3, 55000], + [0.378316, 55282], + [0.455291, 56124], + [0.52961, 57512], + [0.6, 59421], + [0.665257, 61819], + [0.724264, 64665], + [0.776012, 67911], + [0.819615, 71500], + [0.854328, 75371], + [0.879555, 79459], + [0.894867, 83693] + ] }, - "blurb": "" + "peaks": [ + { + "name": "Snowcrown", + "theta": 0.3, + "z": 88000, + "elevationM": 4300 + } + ], + "heightM": 4300, + "ruggedness": 0.55, + "snowlineM": 3000 }, { "id": "the-sisters", "name": "The Sisters", - "kind": "mountain", + "blurb": "", + "notes": "", + "public": true, + "seed": 1441553728, + "kind": "mountains", "geometry": { - "type": "point", - "theta": -0.52, - "z": 94000 + "type": "polygon", + "points": [ + [0, 94000], + [-0.004449, 97655], + [-0.017719, 101247], + [-0.039583, 104715], + [-0.069667, 108000], + [-0.107456, 111045], + [-0.152304, 113799], + [-0.203444, 116214], + [-0.26, 118249], + [-0.321005, 119869], + [-0.385414, 121046], + [-0.452126, 121760], + [-0.52, 122000], + [-0.587874, 121760], + [-0.654586, 121046], + [-0.718995, 119869], + [-0.78, 118249], + [-0.836556, 116214], + [-0.887696, 113799], + [-0.932544, 111045], + [-0.970333, 108000], + [-1.000417, 104715], + [-1.022281, 101247], + [-1.035551, 97655], + [-1.04, 94000], + [-1.035551, 90345], + [-1.022281, 86753], + [-1.000417, 83285], + [-0.970333, 80000], + [-0.932544, 76955], + [-0.887696, 74201], + [-0.836556, 71786], + [-0.78, 69751], + [-0.718995, 68131], + [-0.654586, 66954], + [-0.587874, 66240], + [-0.52, 66000], + [-0.452126, 66240], + [-0.385414, 66954], + [-0.321005, 68131], + [-0.26, 69751], + [-0.203444, 71786], + [-0.152304, 74201], + [-0.107456, 76955], + [-0.069667, 80000], + [-0.039583, 83285], + [-0.017719, 86753], + [-0.004449, 90345] + ] }, - "blurb": "" + "peaks": [ + { + "name": "The Sisters", + "theta": -0.52, + "z": 94000, + "elevationM": 3650 + } + ], + "heightM": 3650, + "ruggedness": 0.55, + "snowlineM": 3000 }, { "id": "westwall", "name": "Westwall", - "kind": "mountain", + "blurb": "", + "notes": "", + "public": true, + "seed": 946001310, + "kind": "mountains", "geometry": { - "type": "point", - "theta": -1.1, - "z": 86000 + "type": "polygon", + "points": [ + [-0.62, 86000], + [-0.624106, 89394], + [-0.636356, 92729], + [-0.656538, 95950], + [-0.684308, 99000], + [-0.71919, 101828], + [-0.760589, 104385], + [-0.807795, 106627], + [-0.86, 108517], + [-0.916312, 110021], + [-0.975767, 111114], + [-1.037347, 111778], + [-1.1, 112000], + [-1.162653, 111778], + [-1.224233, 111114], + [-1.283688, 110021], + [-1.34, 108517], + [-1.392205, 106627], + [-1.439411, 104385], + [-1.48081, 101828], + [-1.515692, 99000], + [-1.543462, 95950], + [-1.563644, 92729], + [-1.575894, 89394], + [-1.58, 86000], + [-1.575894, 82606], + [-1.563644, 79271], + [-1.543462, 76050], + [-1.515692, 73000], + [-1.48081, 70172], + [-1.439411, 67615], + [-1.392205, 65373], + [-1.34, 63483], + [-1.283688, 61979], + [-1.224233, 60886], + [-1.162653, 60222], + [-1.1, 60000], + [-1.037347, 60222], + [-0.975767, 60886], + [-0.916312, 61979], + [-0.86, 63483], + [-0.807795, 65373], + [-0.760589, 67615], + [-0.71919, 70172], + [-0.684308, 73000], + [-0.656538, 76050], + [-0.636356, 79271], + [-0.624106, 82606] + ] }, - "blurb": "" + "peaks": [ + { + "name": "Westwall", + "theta": -1.1, + "z": 86000, + "elevationM": 3400 + } + ], + "heightM": 3400, + "ruggedness": 0.55, + "snowlineM": 3000 }, { "id": "eastwall", "name": "Eastwall", - "kind": "mountain", + "blurb": "", + "notes": "", + "public": true, + "seed": 653882312, + "kind": "mountains", "geometry": { - "type": "point", - "theta": 0.98, - "z": 92000 + "type": "polygon", + "points": [ + [1.48, 92000], + [1.475722, 95524], + [1.462963, 98988], + [1.44194, 102332], + [1.413013, 105500], + [1.376677, 108437], + [1.333553, 111092], + [1.284381, 113421], + [1.23, 115383], + [1.171342, 116945], + [1.10941, 118080], + [1.045263, 118769], + [0.98, 119000], + [0.914737, 118769], + [0.85059, 118080], + [0.788658, 116945], + [0.73, 115383], + [0.675619, 113421], + [0.626447, 111092], + [0.583323, 108437], + [0.546987, 105500], + [0.51806, 102332], + [0.497037, 98988], + [0.484278, 95524], + [0.48, 92000], + [0.484278, 88476], + [0.497037, 85012], + [0.51806, 81668], + [0.546987, 78500], + [0.583323, 75563], + [0.626447, 72908], + [0.675619, 70579], + [0.73, 68617], + [0.788658, 67055], + [0.85059, 65920], + [0.914737, 65231], + [0.98, 65000], + [1.045263, 65231], + [1.10941, 65920], + [1.171342, 67055], + [1.23, 68617], + [1.284381, 70579], + [1.333553, 72908], + [1.376677, 75563], + [1.413013, 78500], + [1.44194, 81668], + [1.462963, 85012], + [1.475722, 88476] + ] }, - "blurb": "" + "peaks": [ + { + "name": "Eastwall", + "theta": 0.98, + "z": 92000, + "elevationM": 3550 + } + ], + "heightM": 3550, + "ruggedness": 0.55, + "snowlineM": 3000 + }, + { + "id": "the-girdle", + "name": "The Girdle", + "blurb": "", + "notes": "", + "public": true, + "seed": 1143607758, + "kind": "mountains", + "geometry": { + "type": "band", + "zFrom": 56000, + "zTo": 124000 + }, + "peaks": [], + "heightM": 2900, + "ruggedness": 0.6, + "snowlineM": 3000 }, { "id": "hollow-tarn", "name": "Hollow Tarn", + "blurb": "", + "notes": "", + "public": true, + "seed": 226829642, "kind": "lake", "geometry": { "type": "polygon", "points": [ - [ - 0.26, - 100000 - ], - [ - 0.25923141121612925, - 100468.21677283871 - ], - [ - 0.2569551813004515, - 100918.44023767622 - ], - [ - 0.2532587844921018, - 101333.36855924704 - ], - [ - 0.2482842712474619, - 101697.05627484771 - ], - [ - 0.2422228093207841, - 101995.52706952611 - ], - [ - 0.2353073372946036, - 102217.31087802708 - ], - [ - 0.22780361288064513, - 102353.88467296775 - ], - [ - 0.22, - 102400 - ], - [ - 0.21219638711935487, - 102353.88467296775 - ], - [ - 0.2046926627053964, - 102217.31087802708 - ], - [ - 0.19777719067921593, - 101995.52706952611 - ], - [ - 0.19171572875253812, - 101697.05627484771 - ], - [ - 0.18674121550789818, - 101333.36855924704 - ], - [ - 0.18304481869954853, - 100918.44023767622 - ], - [ - 0.18076858878387078, - 100468.21677283871 - ], - [ - 0.18, - 100000 - ], - [ - 0.18076858878387078, - 99531.78322716129 - ], - [ - 0.18304481869954853, - 99081.55976232378 - ], - [ - 0.18674121550789818, - 98666.63144075296 - ], - [ - 0.1917157287525381, - 98302.94372515229 - ], - [ - 0.19777719067921593, - 98004.47293047389 - ], - [ - 0.20469266270539638, - 97782.68912197292 - ], - [ - 0.21219638711935485, - 97646.11532703225 - ], - [ - 0.22, - 97600 - ], - [ - 0.22780361288064513, - 97646.11532703225 - ], - [ - 0.2353073372946036, - 97782.68912197292 - ], - [ - 0.24222280932078408, - 98004.47293047389 - ], - [ - 0.24828427124746189, - 98302.94372515229 - ], - [ - 0.2532587844921018, - 98666.63144075296 - ], - [ - 0.2569551813004515, - 99081.55976232378 - ], - [ - 0.2592314112161292, - 99531.78322716129 - ] + [0.26, 100000], + [0.259231, 100468], + [0.256955, 100918], + [0.253259, 101333], + [0.248284, 101697], + [0.242223, 101996], + [0.235307, 102217], + [0.227804, 102354], + [0.22, 102400], + [0.212196, 102354], + [0.204693, 102217], + [0.197777, 101996], + [0.191716, 101697], + [0.186741, 101333], + [0.183045, 100918], + [0.180769, 100468], + [0.18, 100000], + [0.180769, 99532], + [0.183045, 99082], + [0.186741, 98667], + [0.191716, 98303], + [0.197777, 98004], + [0.204693, 97783], + [0.212196, 97646], + [0.22, 97600], + [0.227804, 97646], + [0.235307, 97783], + [0.242223, 98004], + [0.248284, 98303], + [0.253259, 98667], + [0.256955, 99082], + [0.259231, 99532] ] }, - "blurb": "" + "levelM": "auto", + "depthM": 190, + "basin": "bowl", + "shoreM": 2000, + "rimM": 120 }, { "id": "sisters-tarn", "name": "Sisters Tarn", + "blurb": "", + "notes": "", + "public": true, + "seed": 1798023826, "kind": "lake", "geometry": { "type": "polygon", "points": [ - [ - -0.43, - 104000 - ], - [ - -0.43076858878387075, - 104468.21677283871 - ], - [ - -0.4330448186995485, - 104918.44023767622 - ], - [ - -0.4367412155078982, - 105333.36855924704 - ], - [ - -0.44171572875253806, - 105697.05627484771 - ], - [ - -0.4477771906792159, - 105995.52706952611 - ], - [ - -0.4546926627053964, - 106217.31087802708 - ], - [ - -0.46219638711935485, - 106353.88467296775 - ], - [ - -0.47, - 106400 - ], - [ - -0.4778036128806451, - 106353.88467296775 - ], - [ - -0.48530733729460357, - 106217.31087802708 - ], - [ - -0.49222280932078405, - 105995.52706952611 - ], - [ - -0.4982842712474619, - 105697.05627484771 - ], - [ - -0.5032587844921018, - 105333.36855924704 - ], - [ - -0.5069551813004515, - 104918.44023767622 - ], - [ - -0.5092314112161291, - 104468.21677283871 - ], - [ - -0.51, - 104000 - ], - [ - -0.5092314112161291, - 103531.78322716129 - ], - [ - -0.5069551813004515, - 103081.55976232378 - ], - [ - -0.5032587844921018, - 102666.63144075296 - ], - [ - -0.4982842712474619, - 102302.94372515229 - ], - [ - -0.49222280932078405, - 102004.47293047389 - ], - [ - -0.48530733729460357, - 101782.68912197292 - ], - [ - -0.4778036128806451, - 101646.11532703225 - ], - [ - -0.47, - 101600 - ], - [ - -0.46219638711935485, - 101646.11532703225 - ], - [ - -0.4546926627053964, - 101782.68912197292 - ], - [ - -0.4477771906792159, - 102004.47293047389 - ], - [ - -0.44171572875253806, - 102302.94372515229 - ], - [ - -0.4367412155078982, - 102666.63144075296 - ], - [ - -0.43304481869954853, - 103081.55976232378 - ], - [ - -0.43076858878387075, - 103531.78322716129 - ] + [-0.43, 104000], + [-0.430769, 104468], + [-0.433045, 104918], + [-0.436741, 105333], + [-0.441716, 105697], + [-0.447777, 105996], + [-0.454693, 106217], + [-0.462196, 106354], + [-0.47, 106400], + [-0.477804, 106354], + [-0.485307, 106217], + [-0.492223, 105996], + [-0.498284, 105697], + [-0.503259, 105333], + [-0.506955, 104918], + [-0.509231, 104468], + [-0.51, 104000], + [-0.509231, 103532], + [-0.506955, 103082], + [-0.503259, 102667], + [-0.498284, 102303], + [-0.492223, 102004], + [-0.485307, 101783], + [-0.477804, 101646], + [-0.47, 101600], + [-0.462196, 101646], + [-0.454693, 101783], + [-0.447777, 102004], + [-0.441716, 102303], + [-0.436741, 102667], + [-0.433045, 103082], + [-0.430769, 103532] ] }, - "blurb": "" + "levelM": "auto", + "depthM": 190, + "basin": "bowl", + "shoreM": 2000, + "rimM": 120 }, { "id": "west-tarn", "name": "Cold Tarn", + "blurb": "", + "notes": "", + "public": true, + "seed": 1277495802, "kind": "lake", "geometry": { "type": "polygon", "points": [ - [ - -1.01, - 98000 - ], - [ - -1.0107685887838709, - 98468.21677283871 - ], - [ - -1.0130448186995487, - 98918.44023767622 - ], - [ - -1.0167412155078983, - 99333.36855924704 - ], - [ - -1.0217157287525382, - 99697.05627484771 - ], - [ - -1.027777190679216, - 99995.52706952611 - ], - [ - -1.0346926627053965, - 100217.31087802708 - ], - [ - -1.042196387119355, - 100353.88467296775 - ], - [ - -1.05, - 100400 - ], - [ - -1.0578036128806452, - 100353.88467296775 - ], - [ - -1.0653073372946036, - 100217.31087802708 - ], - [ - -1.0722228093207842, - 99995.52706952611 - ], - [ - -1.0782842712474618, - 99697.05627484771 - ], - [ - -1.0832587844921018, - 99333.36855924704 - ], - [ - -1.0869551813004514, - 98918.44023767622 - ], - [ - -1.0892314112161292, - 98468.21677283871 - ], - [ - -1.09, - 98000 - ], - [ - -1.0892314112161292, - 97531.78322716129 - ], - [ - -1.0869551813004514, - 97081.55976232378 - ], - [ - -1.0832587844921018, - 96666.63144075296 - ], - [ - -1.0782842712474618, - 96302.94372515229 - ], - [ - -1.0722228093207842, - 96004.47293047389 - ], - [ - -1.0653073372946036, - 95782.68912197292 - ], - [ - -1.0578036128806452, - 95646.11532703225 - ], - [ - -1.05, - 95600 - ], - [ - -1.042196387119355, - 95646.11532703225 - ], - [ - -1.0346926627053965, - 95782.68912197292 - ], - [ - -1.027777190679216, - 96004.47293047389 - ], - [ - -1.0217157287525382, - 96302.94372515229 - ], - [ - -1.0167412155078983, - 96666.63144075296 - ], - [ - -1.0130448186995487, - 97081.55976232378 - ], - [ - -1.0107685887838709, - 97531.78322716129 - ] + [-1.01, 98000], + [-1.010769, 98468], + [-1.013045, 98918], + [-1.016741, 99333], + [-1.021716, 99697], + [-1.027777, 99996], + [-1.034693, 100217], + [-1.042196, 100354], + [-1.05, 100400], + [-1.057804, 100354], + [-1.065307, 100217], + [-1.072223, 99996], + [-1.078284, 99697], + [-1.083259, 99333], + [-1.086955, 98918], + [-1.089231, 98468], + [-1.09, 98000], + [-1.089231, 97532], + [-1.086955, 97082], + [-1.083259, 96667], + [-1.078284, 96303], + [-1.072223, 96004], + [-1.065307, 95783], + [-1.057804, 95646], + [-1.05, 95600], + [-1.042196, 95646], + [-1.034693, 95783], + [-1.027777, 96004], + [-1.021716, 96303], + [-1.016741, 96667], + [-1.013045, 97082], + [-1.010769, 97532] ] }, - "blurb": "" + "levelM": "auto", + "depthM": 190, + "basin": "bowl", + "shoreM": 2000, + "rimM": 120 }, { "id": "east-tarn", "name": "Long Tarn", + "blurb": "", + "notes": "", + "public": true, + "seed": 1335790732, "kind": "lake", "geometry": { "type": "polygon", "points": [ - [ - 0.98, - 103000 - ], - [ - 0.9792314112161291, - 103468.21677283871 - ], - [ - 0.9769551813004514, - 103918.44023767622 - ], - [ - 0.9732587844921018, - 104333.36855924704 - ], - [ - 0.9682842712474619, - 104697.05627484771 - ], - [ - 0.9622228093207841, - 104995.52706952611 - ], - [ - 0.9553073372946035, - 105217.31087802708 - ], - [ - 0.9478036128806451, - 105353.88467296775 - ], - [ - 0.94, - 105400 - ], - [ - 0.9321963871193548, - 105353.88467296775 - ], - [ - 0.9246926627053964, - 105217.31087802708 - ], - [ - 0.9177771906792158, - 104995.52706952611 - ], - [ - 0.911715728752538, - 104697.05627484771 - ], - [ - 0.9067412155078981, - 104333.36855924704 - ], - [ - 0.9030448186995484, - 103918.44023767622 - ], - [ - 0.9007685887838708, - 103468.21677283871 - ], - [ - 0.8999999999999999, - 103000 - ], - [ - 0.9007685887838708, - 102531.78322716129 - ], - [ - 0.9030448186995484, - 102081.55976232378 - ], - [ - 0.9067412155078981, - 101666.63144075296 - ], - [ - 0.911715728752538, - 101302.94372515229 - ], - [ - 0.9177771906792158, - 101004.47293047389 - ], - [ - 0.9246926627053963, - 100782.68912197292 - ], - [ - 0.9321963871193548, - 100646.11532703225 - ], - [ - 0.94, - 100600 - ], - [ - 0.9478036128806451, - 100646.11532703225 - ], - [ - 0.9553073372946036, - 100782.68912197292 - ], - [ - 0.962222809320784, - 101004.47293047389 - ], - [ - 0.9682842712474619, - 101302.94372515229 - ], - [ - 0.9732587844921018, - 101666.63144075296 - ], - [ - 0.9769551813004514, - 102081.55976232378 - ], - [ - 0.9792314112161291, - 102531.78322716129 - ] + [0.98, 103000], + [0.979231, 103468], + [0.976955, 103918], + [0.973259, 104333], + [0.968284, 104697], + [0.962223, 104996], + [0.955307, 105217], + [0.947804, 105354], + [0.94, 105400], + [0.932196, 105354], + [0.924693, 105217], + [0.917777, 104996], + [0.911716, 104697], + [0.906741, 104333], + [0.903045, 103918], + [0.900769, 103468], + [0.9, 103000], + [0.900769, 102532], + [0.903045, 102082], + [0.906741, 101667], + [0.911716, 101303], + [0.917777, 101004], + [0.924693, 100783], + [0.932196, 100646], + [0.94, 100600], + [0.947804, 100646], + [0.955307, 100783], + [0.962223, 101004], + [0.968284, 101303], + [0.973259, 101667], + [0.976955, 102082], + [0.979231, 102532] ] }, - "blurb": "" + "levelM": "auto", + "depthM": 190, + "basin": "bowl", + "shoreM": 2000, + "rimM": 120 }, { "id": "the-dry-reach", "name": "The Dry Reach", + "blurb": "", + "notes": "", + "public": true, + "seed": 227210026, "kind": "desert", "geometry": { "type": "band", "zFrom": 148000, "zTo": 218000 }, - "blurb": "" + "upliftM": 780, + "edgeM": 20000 + }, + { + "id": "first-ridge", + "name": "First Ridge", + "blurb": "", + "notes": "", + "public": true, + "seed": 1871446183, + "kind": "mountains", + "geometry": { + "type": "band", + "zFrom": 162000, + "zTo": 190000 + }, + "peaks": [], + "heightM": 260, + "ruggedness": 0, + "snowlineM": 3000 + }, + { + "id": "second-ridge", + "name": "Second Ridge", + "blurb": "", + "notes": "", + "public": true, + "seed": 1668141571, + "kind": "mountains", + "geometry": { + "type": "band", + "zFrom": 177000, + "zTo": 203000 + }, + "peaks": [], + "heightM": 300, + "ruggedness": 0, + "snowlineM": 3000 + }, + { + "id": "third-ridge", + "name": "Third Ridge", + "blurb": "", + "notes": "", + "public": true, + "seed": 540879940, + "kind": "mountains", + "geometry": { + "type": "band", + "zFrom": 188000, + "zTo": 218000 + }, + "peaks": [], + "heightM": 240, + "ruggedness": 0, + "snowlineM": 3000 }, { "id": "westwall-water", "name": "Westwall Water", + "blurb": "", + "notes": "", + "public": true, + "seed": 1255511300, "kind": "river", "geometry": { "type": "polyline", "points": [ - [ - -1.06, - 104000 - ], - [ - -1.02, - 118000 - ], - [ - -0.92, - 132000 - ], - [ - -0.8, - 144000 - ], - [ - -0.66, - 152000 - ], - [ - -0.52, - 155500 - ], - [ - -0.4, - 158500 - ], - [ - -0.28, - 157500 - ], - [ - -0.16, - 160000 - ], - [ - -0.06, - 161000 - ], - [ - 0.062, - 162000 - ] + [-1.06, 104000], + [-1.02, 118000], + [-0.92, 132000], + [-0.8, 144000], + [-0.66, 152000], + [-0.52, 155500], + [-0.4, 158500], + [-0.28, 157500], + [-0.16, 160000], + [-0.06, 161000], + [0.062, 162000] ] }, - "blurb": "" + "flowM3S": 98, + "sourceM": 1980, + "outlet": "auto" }, { "id": "sisters-water", "name": "Sisters Water", + "blurb": "", + "notes": "", + "public": true, + "seed": 654450092, "kind": "river", "geometry": { "type": "polyline", "points": [ - [ - -0.47, - 110000 - ], - [ - -0.45, - 124000 - ], - [ - -0.4, - 138000 - ], - [ - -0.33, - 148000 - ], - [ - -0.24, - 154000 - ], - [ - -0.14, - 157500 - ], - [ - -0.04, - 160000 - ], - [ - 0.062, - 162000 - ] + [-0.47, 110000], + [-0.45, 124000], + [-0.4, 138000], + [-0.33, 148000], + [-0.24, 154000], + [-0.14, 157500], + [-0.04, 160000], + [0.062, 162000] ] }, - "blurb": "" + "flowM3S": 98, + "sourceM": 1900, + "outlet": "auto" }, { "id": "eastwall-water", "name": "Eastwall Water", + "blurb": "", + "notes": "", + "public": true, + "seed": 856398702, "kind": "river", "geometry": { "type": "polyline", "points": [ - [ - 0.95, - 108000 - ], - [ - 0.92, - 122000 - ], - [ - 0.86, - 134000 - ], - [ - 0.78, - 145000 - ], - [ - 0.66, - 152000 - ], - [ - 0.54, - 155000 - ], - [ - 0.42, - 158000 - ], - [ - 0.3, - 157000 - ], - [ - 0.2, - 159500 - ], - [ - 0.12, - 161000 - ], - [ - 0.062, - 162000 - ] + [0.95, 108000], + [0.92, 122000], + [0.86, 134000], + [0.78, 145000], + [0.66, 152000], + [0.54, 155000], + [0.42, 158000], + [0.3, 157000], + [0.2, 159500], + [0.12, 161000], + [0.062, 162000] ] }, - "blurb": "" + "flowM3S": 98, + "sourceM": 1960, + "outlet": "auto" }, { "id": "the-long-water", "name": "The Long Water", + "blurb": "", + "notes": "", + "public": true, + "seed": 2105334655, "kind": "river", "geometry": { "type": "polyline", "points": [ - [ - 0.22, - 100000 - ], - [ - 0.195, - 112000 - ], - [ - 0.17, - 126000 - ], - [ - 0.14, - 140000 - ], - [ - 0.1, - 152000 - ], - [ - 0.062, - 162000 - ], - [ - 0.03, - 170000 - ], - [ - -0.01, - 178000 - ], - [ - 0.015, - 186000 - ], - [ - -0.02, - 194000 - ], - [ - 0.005, - 201000 - ], - [ - -0.015, - 208000 - ], - [ - 0.01, - 216000 - ], - [ - 0.048, - 226000 - ], - [ - 0.086, - 236000 - ], - [ - 0.12, - 246000 - ] + [0.22, 100000], + [0.195, 112000], + [0.17, 126000], + [0.14, 140000], + [0.1, 152000], + [0.062, 162000], + [0.03, 170000], + [-0.01, 178000], + [0.015, 186000], + [-0.02, 194000], + [0.005, 201000], + [-0.015, 208000], + [0.01, 216000], + [0.048, 226000], + [0.086, 236000], + [0.12, 246000] ] }, - "blurb": "" + "flowM3S": 98, + "sourceM": 1900, + "outlet": "auto" }, { "id": "the-narrows", "name": "The Narrows", - "kind": "canyon", + "blurb": "", + "notes": "", + "public": true, + "seed": 1630660453, + "kind": "landmark", "geometry": { "type": "polyline", "points": [ - [ - 0.03, - 170000 - ], - [ - -0.01, - 178000 - ], - [ - 0.015, - 186000 - ], - [ - -0.02, - 194000 - ], - [ - 0.005,
Diff truncated. Clone the repository to see all of it.