tower / cascadia / files / README.md
Branch cascadia · default branch · all branches
README.md
cf4f8772 2026-09-07 Pavel Safronov | 1 | # Tower |
| 2 | ||
| 3 | **Live map — <https://pavelsafronov.gitlab.io/tower/>** · [Source](https://gitlab.com/PavelSafronov/tower) | |
| 4 | ||
| 5 | Companion piece to a novel set inside **Tower** — a fictional smart cylinder orbiting Earth, | |
| 6 | spun for 1 g, with an inner surface the size of Wisconsin, lit by 54 TW of sunlight | |
769726ea 2026-09-08 Pavel Safronov | 7 | piped down its axis and scattered by mirrors that make its days and nights. Its wall |
| 8 | is a kilometre of sealed hull: no windows along all 540 km, and no view of the Earth | |
9c891d2e 2026-09-08 Pavel Safronov | 9 | it orbits. Both end caps are windows, though — 100 km of glass each, and the only |
| 10 | places in the world with a horizon that is not Tower. Nobody aboard can see the sun | |
| 11 | either: the 244 km collector sits between Tower and it, 79 times its diameter, so | |
49b0d64d 2026-09-08 Pavel Safronov | 12 | the sun cap looks out at a black disc and the stars around it — black except at its |
| 13 | edge, where the structure holding it flat catches the sun side-on and traces the | |
| 14 | whole 244 km in a thin sun-coloured ring. | |
cf4f8772 2026-09-07 Pavel Safronov | 15 | |
28f9a6bc 2026-09-07 Pavel Safronov | 16 | Explore Tower from outside, pick a spot on the inner surface, and stand there — with |
| 17 | the ground curving up on both sides and the far side of the world hanging 100 km | |
| 18 | overhead, clearer than the ground at your feet. Turn its sun up to Earth clear-sky | |
769726ea 2026-09-08 Pavel Safronov | 19 | noon, down to permanent dusk, or shut the beam off entirely and watch the sky come |
| 20 | out: through the far cap the stars wheel once every 7 min 29 s about a single | |
| 21 | motionless star on the axis. Nothing inside Tower rotates, because you and the | |
| 22 | ground and the air all turn together — so that window is the only place the spin is | |
| 23 | visible at all. | |
cf4f8772 2026-09-07 Pavel Safronov | 24 | |
| 25 | Tower flies all 78 billion mirrors of its own sunlight, individually, because | |
| 26 | nothing passive would stay in the beam past mid-afternoon. It is a person. | |
| 27 | ||
| 28 | ## Status | |
| 29 | ||
28f9a6bc 2026-09-07 Pavel Safronov | 30 | **All three views are built** — map, exterior and interior — over an authored |
| 31 | geography and a working writer mode. | |
cf4f8772 2026-09-07 Pavel Safronov | 32 | |
28f9a6bc 2026-09-07 Pavel Safronov | 33 | Built: the unrolled surface map, Tower from outside, standing inside it, place |
| 34 | editing for thirteen kinds (points, lines, polygons and bands), localStorage | |
| 35 | autosave, backup and static exports, import, permalinks, and the sightline probe. | |
cf4f8772 2026-09-07 Pavel Safronov | 36 | |
| 37 | - [design.md](design.md) — canon, physics and application architecture | |
| 38 | - [decisions.md](decisions.md) — choices made and why | |
| 39 | ||
| 40 | Both are series-bible material: coherence outranks spectacle, and the prose is | |
| 41 | downstream of them. | |
| 42 | ||
fc11b064 2026-09-08 Pavel Safronov | 43 | On a phone the sidebar is a **drawer** rather than a column: below 720 px the world |
| 44 | gets the whole screen and the panel slides in over it, one tap away and one tap | |
| 45 | gone. Picking a place closes it, because in every view selecting a place is how you | |
| 46 | travel there — see [decisions.md](decisions.md) D-043. | |
| 47 | ||
6364748a 2026-09-08 Pavel Safronov | 48 | ## Screenshots |
| 49 | ||
| 50 | [](docs/screenshots/overview.png) | |
| 51 | ||
| 52 | `npm run screenshots` drives the real application in a headless browser — clicking | |
| 53 | the view buttons, picking places out of the list, dragging to orbit and to look up — | |
| 54 | and writes [`docs/screenshots/`](docs/screenshots/). Taking them *through the UI* is | |
| 55 | the point: a shot taken this way fails when the UI fails, so a black canvas ends up | |
| 56 | in the picture instead of hiding behind a passing unit test. | |
| 57 | ||
18958d3d 2026-09-08 Pavel Safronov | 58 | The whole tour takes about **35 seconds**. It used to take six and a half minutes, |
| 59 | nearly all of it spent rendering Tower's haze integral on the CPU — the default | |
| 60 | headless browser has no GPU. It now asks for the full Chrome build, which reaches | |
| 61 | the real one, and photographs four shots at a time, since a shot is mostly spent | |
| 62 | waiting. The cost is that the committed images are tied to the GPU that drew them: | |
| 63 | another machine will show a large one-off divergence rather than a regression. | |
| 64 | ||
6364748a 2026-09-08 Pavel Safronov | 65 | | Desktop — 1440×900 | | |
| 66 | |---|---| | |
| 67 | | [map](docs/screenshots/desktop-map.png) | the unrolled inner surface, with a place selected | | |
| 68 | | [map-sightline](docs/screenshots/desktop-map-sightline.png) | shift-click two points to measure a sightline through the air | | |
4699e6cb 2026-09-08 Pavel Safronov | 69 | | [exterior](docs/screenshots/desktop-exterior.png) | x-ray hull — the inner surface projected out, with the caps and the collector | |
| 70 | | [exterior-true](docs/screenshots/desktop-exterior-true.png) | true hull — bare skin, and the land visible through the far cap window | | |
6364748a 2026-09-08 Pavel Safronov | 71 | | [interior](docs/screenshots/desktop-interior.png) | standing at The Narrows, the ground curving up on both sides | |
| 72 | | [interior-overhead](docs/screenshots/desktop-interior-overhead.png) | looking up: the far side 100 km overhead is clearer than ground 100 km away | | |
2bb3e0de 2026-09-08 Pavel Safronov | 73 | | [interior-dawn](docs/screenshots/desktop-interior-dawn.png) | dawn — a lit sky over dark ground, the light still 27 km away | |
6364748a 2026-09-08 Pavel Safronov | 74 | | [reader](docs/screenshots/desktop-reader.png) | `?reader=1` — terrain, places, a detail card and the probe, no editing | |
| 75 | ||
| 76 | | Mobile — 390×844 | | | |
| 77 | |---|---| | |
| 78 | | [map](docs/screenshots/mobile-map.png) | the world with the whole screen, the panel one tap away | | |
| 79 | | [exterior](docs/screenshots/mobile-exterior.png) | the spin readout dropped clear of the panel button | | |
| 80 | | [interior](docs/screenshots/mobile-interior.png) | the standing readout at a size that fits 390 px | | |
| 81 | | [panel](docs/screenshots/mobile-panel.png) | the sidebar as a drawer, opened over the world | | |
| 82 | ||
| 83 | ### Looking before committing | |
| 84 | ||
| 85 | [`overview.png`](docs/screenshots/overview.png) is a contact sheet of all ten. It | |
| 86 | exists to be **looked at, once, before committing** — one image that answers "is the | |
| 87 | application still there?". Regenerate, glance at it, then commit. | |
| 88 | ||
| 89 | `--check` puts a number on the same question. Every shot is compared against the | |
| 90 | committed one and the run fails if any of them moved too far: | |
| 91 | ||
| 92 | ``` | |
| 93 | npm run screenshots -- --check # fail past 3% divergence on any shot | |
| 94 | npm run screenshots -- --threshold 8 # report only, looser bar | |
| 95 | npm run screenshots -- --url http://localhost:5173 # use a dev server already up | |
| 96 | ``` | |
| 97 | ||
18958d3d 2026-09-08 Pavel Safronov | 98 | The threshold is measured at both ends, not guessed. The 2D shots come out |
| 99 | **byte-identical** between runs — nothing in Tower is stochastic, the exterior spin | |
| 100 | is switched off, and every shot gets a fresh browser context so no camera leaks into | |
| 101 | the next. **The WebGL shots do not**, because the tour draws on the real GPU and | |
| 102 | Metal does not schedule floating-point work identically twice; they drift by 0.1% to | |
| 103 | **0.9%**. Against that, hiding both 3D canvases scored **7% to 31%**, and a view | |
| 104 | going entirely black scores 12% to 39%. | |
6364748a 2026-09-08 Pavel Safronov | 105 | |
| 106 | **3% is three times the worst noise and less than half the mildest real failure.** | |
| 107 | It is a smoke alarm, not a verdict: when it fires, go and look at the sheet. Note | |
| 108 | that a view which never worked scores 0% when it breaks further — this catches | |
| 109 | regressions, not things that were already broken. | |
| 110 | ||
cf4f8772 2026-09-07 Pavel Safronov | 111 | ## Development |
| 112 | ||
| 113 | ``` | |
| 114 | npm install | |
| 115 | npm run dev # http://localhost:5173 | |
1affced2 2026-09-08 Pavel Safronov | 116 | npm test # 104 tests; canon figures are pinned to design.md |
cf4f8772 2026-09-07 Pavel Safronov | 117 | npm run build |
| 118 | npm run terrain # regenerate the maps from content/geography.json | |
1affced2 2026-09-08 Pavel Safronov | 119 | npm run cascadia # regenerate the 1:1 Cascadia surface (see below) |
6364748a 2026-09-08 Pavel Safronov | 120 | npm run screenshots # drive the app and rephotograph docs/screenshots/ |
cf4f8772 2026-09-07 Pavel Safronov | 121 | ``` |
| 122 | ||
| 123 | Vite + TypeScript, deployed to GitLab Pages by `.gitlab-ci.yml` on every push to the | |
28f9a6bc 2026-09-07 Pavel Safronov | 124 | default branch. Three.js is loaded only when a 3D view is opened, so a reader who |
| 125 | stays on the map never downloads it. | |
cf4f8772 2026-09-07 Pavel Safronov | 126 | |
4a8aed84 2026-09-07 Pavel Safronov | 127 | Assets are referenced **relatively**, so the same build works whether Pages serves |
| 128 | it from `https://pavelsafronov.gitlab.io/tower/` or from a generated unique domain | |
| 129 | at a root. Set a `BASE` CI variable to force an absolute base if that is ever | |
| 130 | needed. | |
cf4f8772 2026-09-07 Pavel Safronov | 131 | |
| 132 | ## Geography | |
| 133 | ||
| 134 | [`content/geography.json`](content/geography.json) is the authored source of truth | |
28f9a6bc 2026-09-07 Pavel Safronov | 135 | for terrain — coordinates in canon (θ, z), elevations in metres relative to the base |
| 136 | shell, negative for anything cut below it. `render.minHeightM` / `render.maxHeightM` | |
| 137 | declare the range the height raster is stretched across; the generator prints the | |
| 138 | range the terrain actually occupies and warns if it has outgrown the declaration. `npm run terrain` renders it, with no dependencies beyond Node: | |
cf4f8772 2026-09-07 Pavel Safronov | 139 | |
| 140 | | | | | |
| 141 | |---|---| | |
28f9a6bc 2026-09-07 Pavel Safronov | 142 | | `static/terrain-height.png` | 16-bit greyscale, across the declared `minHeightM`–`maxHeightM` | |
62458672 2026-09-07 Pavel Safronov | 143 | | `static/terrain-color.png` | the whole world, 264 m/px | |
| 144 | | `static/terrain-corridor.png` | the developed corridor, 122 m/px | | |
| 145 | | `static/world.json` | seed places for the app | | |
cf4f8772 2026-09-07 Pavel Safronov | 146 | |
| 147 | It reports each stage as it goes — every relief op, colourisation, and PNG encode — | |
| 148 | and takes about 6 seconds. Flags: | |
| 149 | ||
| 150 | | | | | |
| 151 | |---|---| | |
| 152 | | `--probe <theta>` | print elevation jumps along a row, for checking river gradients and finding discontinuities | | |
| 153 | | `--quiet` | one line per stage instead of one per feature | | |
| 154 | | `--force` | let the seed overwrite places added in the app | | |
| 155 | ||
1affced2 2026-09-08 Pavel Safronov | 156 | ### The Cascadia surface — a ruler |
| 157 | ||
| 158 | The panel's **Surface** control can swap the authored ground for **the Pacific | |
| 159 | Northwest at 1:1**, to answer the question a number cannot: *how big is this | |
| 160 | actually?* | |
| 161 | ||
| 162 | It fits because Tower is a corridor and so is Cascadia. Measured in UTM 10N, the | |
| 163 | ground from the Pacific surf at Kalaloch east to the Columbia at Entiat is | |
| 164 | **312.8 km against Tower's 314.2 km circumference** — so the real coast closes the | |
| 165 | ring on real terrain, with 1.4 km to spare. Walk east from the beach, over the | |
| 166 | Olympics, across Puget Sound, over the Cascades and down the east flank to the | |
| 167 | Columbia, and you are back at the beach. Seattle sits 150 km deepward of the sun | |
| 168 | cap, and the point 100 km straight overhead is *both* Kalaloch and Entiat — one | |
| 169 | place, reached by walking either way round. Nothing is rescaled and nothing is | |
| 170 | invented. | |
| 171 | ||
| 172 | **It is not canon**, contains no Tower geography, defaults off, and draws no | |
| 173 | places. The one artefact is a seam where θ wraps and geography does not — a scarp | |
| 174 | of 369–844 m through the settled half, kept as a landform rather than repaired. | |
| 175 | See D-055 for the alternatives that were measured and rejected. | |
| 176 | ||
| 177 | ``` | |
| 178 | npm run cascadia # ~2 s from cache, ~5 s cold | |
| 179 | ``` | |
| 180 | ||
| 181 | | | | | |
| 182 | |---|---| | |
| 183 | | `static/cascadia-height.png` | 16-bit greyscale, sharing canon's `-560`–`4400` m window | | |
| 184 | | `static/cascadia-color.png` | the same 2048 x 1192 grid, 264 m/px | | |
| 185 | ||
| 186 | Elevation comes from the AWS open terrain tiles — SRTM/3DEP over land, GEBCO below | |
| 187 | water, Canadian ground north of 49°N in the same grid — cached under `.cache/`, | |
| 188 | re-fetchable, and not committed. There are no dependencies beyond Node; the UTM | |
| 189 | projection and the PNG decoder are in the repo. | |
| 190 | ||
| 191 | Every surface **must** share the raster grid and height window: the interior view | |
| 192 | bakes that window into its GLSL as literals, so a surface declaring its own would | |
| 193 | silently displace its ground. `test/raster.test.ts` reads the constants back out of | |
| 194 | `tools/cascadia.mjs` and holds it to them. | |
| 195 | ||
cf4f8772 2026-09-07 Pavel Safronov | 196 | ## Reading and writing |
| 197 | ||
| 198 | The map opens in **writer mode**: tools, editor, canon controls and import/export. | |
| 199 | Add **`?reader=1`** to preview what a reader gets — the terrain, the places, a | |
| 200 | detail card and the sightline probe, with no editing. Writer is the default while | |
| 201 | the world is still being built; that is a one-line change when it is ready to | |
| 202 | publish. Either way it is a UI mode, not a security boundary — with no backend an | |
| 203 | editor can only change their own copy (decisions.md D-011). | |
| 204 | ||
| 205 | Any selection updates the URL, so a link is always shareable: | |
| 206 | ||
| 207 | | | | | |
| 208 | |---|---| | |
| 209 | | `#place=the-narrows` | select and centre a place | | |
| 210 | | `#at=0.03,170000` | drop a sightline endpoint at (θ, z) | | |
| 211 | ||
5f38efd0 2026-09-07 Pavel Safronov | 212 | ## Views |
| 213 | ||
a3f01bd7 2026-09-08 Pavel Safronov | 214 | **Map** is the unrolled inner surface. **Exterior** is Tower from outside — a |
| 215 | kilometre of hull, the checkered sun cap, the glazed far cap, the 244 km collector | |
| 216 | flying ahead and the beam converging into the sun cap. Each piece is captioned. | |
| 217 | ||
| 218 | The hull reads two ways and the **Hull** switch picks one. **True** is the outer | |
| 219 | skin: bare structure, and the far cap a real window — look in through it and the | |
| 220 | land is actually there, lit and receding 540 km down the tube. It is the only line | |
| 221 | of sight into Tower that is not a diagram. **X-ray** wraps the terrain onto the | |
| 222 | outside instead, which is a diagram of what is inside rather than a view of it, and | |
| 223 | useful precisely because the wall is opaque. The active view is remembered and travels in the permalink, so | |
8b6287ba 2026-09-07 Pavel Safronov | 224 | `#view=exterior&place=the-narrows` opens where you left off. Drag to orbit, scroll to zoom, click the hull to drop a point; the spin runs at |
5f38efd0 2026-09-07 Pavel Safronov | 225 | 1x to 1200x real time, and a point picked off the turning hull still comes back in |
| 226 | canon coordinates. | |
| 227 | ||
28f9a6bc 2026-09-07 Pavel Safronov | 228 | **Interior** is standing inside. The shell is the real cylinder, displaced by the |
| 229 | height raster and painted with the colour one, so the ground curves up on both sides | |
| 230 | and closes overhead. Haze is integrated through Tower's rotational air column for | |
| 231 | every pixel, which reproduces the strangest fact about the place: the land 100 km | |
| 232 | straight up is **78%** clear while the ground 100 km away is **31%** — distant | |
| 233 | geography here is best seen by looking up. | |
| 234 | ||
| 235 | Drag to look, scroll to zoom in like a pair of binoculars, and select a place — in | |
| 236 | the list or by its caption — to **walk there**. Most of the world is beyond the haze | |
| 237 | horizon from any given spot, so travelling is more use than turning. | |
| 238 | ||
769726ea 2026-09-08 Pavel Safronov | 239 | **The far cap is a window** — 100 km of it, looking into deep anti-sun space. By day |
| 240 | it is simply a disc of blue, because 540 km of air in front of it scatters more | |
| 241 | light than anything behind it sends; the stars are there at noon and unseeable, | |
| 242 | which is a fact about eyes rather than about glass. Turn the light down and they | |
| 243 | come out. The sun cap stays a checkered plate: it faces the sun, and needs glare | |
| 244 | management rather than a view. | |
| 245 | ||
da6bcb0e 2026-09-08 Pavel Safronov | 246 | The one part of the far cap that is not glass is a **3.2 km mirror** on the axis. |
| 247 | A quarter of the beam survives all 540 km and arrives collimated, so it lands as | |
| 248 | 1.7 MW/m² — 1,200 suns — on a spot a thousandth of the cap's area; the mirror sends | |
| 249 | it back up the column for a second pass, which lifts the beam's utilisation from 76% | |
| 250 | to 94%. You can see it: the rod of light visibly terminates on a bright disc, and | |
| 251 | the disc banks and goes out with the beam. | |
| 252 | ||
769726ea 2026-09-08 Pavel Safronov | 253 | The checker survives on the far cap as the window's **mullions** — the same polar |
| 254 | tessellation at the same pitch, so it is still the only object in the world with a | |
| 255 | known cell size and still the only rule anyone has for judging distance. It is just | |
| 256 | read against sky now instead of against a plate. | |
| 257 | ||
2bb3e0de 2026-09-08 Pavel Safronov | 258 | **Dawn is a line, not a fade.** The beam glows by itself, so when its power comes up |
| 259 | the sky brightens everywhere at once — but the ground is lit by the diffusers, and | |
| 260 | they hand the light down the axis. So a Tower morning is a **fully lit sky over dark | |
| 261 | ground**, with daylight arriving as a front running deepward from the sun cap at | |
| 262 | 225 m/s, crossing all 540 km in 40 minutes. Tower has no latitude; the cycle gives it | |
| 263 | longitude. Scrub the **Time** slider to 06:17 and stand at The Capital to watch it | |
| 264 | come, or tick **Run the clock** and let it. | |
| 265 | ||
| 266 | The light has five settings when the clock is not driving it. **Dark** shuts the 54 TW beam down entirely and is the | |
769726ea 2026-09-08 Pavel Safronov | 267 | only one that is genuinely black — there is no airlight, no lit ground and no rod |
| 268 | overhead, and the only thing left in the world is the sky. **Night** banks the beam | |
| 269 | to a thousandth instead of killing it, which is what night here has to be: the beam | |
| 270 | glows by itself, so a lit beam lights the world whatever the mirrors are doing, and | |
| 271 | aiming them away would leave the floor at about 100 W/m² — half of noon. | |
| 272 | ||
567fe8ad 2026-09-08 Pavel Safronov | 273 | The eye-height slider runs from 1.7 m to 25 km with the local gravity beside it, and |
| 274 | **Rain** leans 1.5° antispinward. Nothing *inside* rotates, because you, the ground | |
| 275 | and the air all turn together — so for 539 of the 540 km that 1.5° is the only | |
| 276 | evidence Tower turns at all. The exception is the window: the sky out there does not | |
| 277 | co-rotate, and wheels once every 7 min 29 s about the one motionless star on the | |
| 278 | axis. It is by a wide margin the plainest sign of the spin anywhere in the view, and | |
769726ea 2026-09-08 Pavel Safronov | 279 | it is only available at night, at the deepward end, looking the one direction that |
| 280 | has ever had anything in it but Tower. | |
28f9a6bc 2026-09-07 Pavel Safronov | 281 | |
cf4f8772 2026-09-07 Pavel Safronov | 282 | ## Working in the map |
| 283 | ||
| 284 | Pick a tool, click to place; **Lake**, **Forest**, **Desert** and **Region** trace a | |
| 285 | polygon and **River** and **Canyon** trace an open line (Enter finishes, Esc | |
| 286 | cancels). Drag markers to move them, **shift-click two points** to | |
| 287 | measure a sightline, **f** to fit the view. | |
| 288 | ||
cf37225c 2026-09-07 Pavel Safronov | 289 | Canon is **0 star slats** — the hull is sealed, the inner surface is one unbroken |
| 290 | 169,646 km², and the only views out are at the end caps. The runtime control for | |
| 291 | this is disabled and hidden (decisions.md D-033): the terrain raster is baked at 0 | |
| 292 | slats, so any other count draws bands over land that has none. | |
cf4f8772 2026-09-07 Pavel Safronov | 293 | |
| 294 | Everything autosaves to this browser's localStorage. Two exports: | |
| 295 | ||
| 296 | | | | | |
| 297 | |---|---| | |
| 298 | | **Export backup** | everything, private notes included. Keep out of the public repo — `.gitignore` already excludes `tower-backup-*.json`. | | |
62458672 2026-09-07 Pavel Safronov | 299 | | **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. | |