tower / commit
docs: the interior view, and four findings from building it
README.md | 56 +++++++++++++------ decisions.md | 180 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ design.md | 110 ++++++++++++++++++++++++------------ 3 files changed, 293 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 8443f0b..ffe4a01 100644 --- a/README.md +++ b/README.md @@ -5,24 +5,25 @@ Companion piece to a novel set inside **Tower** — a fictional smart cylinder orbiting Earth, spun for 1 g, with an inner surface the size of Wisconsin, lit by 54 TW of sunlight piped down its axis and scattered by mirrors that make its days and nights. The hull -is sealed: no windows, no stars, no view of the Earth it orbits — only a pinwheel of -stars through the windows at the far cap. +is sealed: no windows, no stars, no view of the Earth it orbits. The end caps are +the only places in the world with a horizon that is not Tower. -Explore Tower from outside, pick a spot on the inner surface, and stand there while -the light shifts and the stars wheel past every seven and a half minutes. Turn its -sun up to Earth clear-sky noon, or down to permanent dusk. +Explore Tower from outside, pick a spot on the inner surface, and stand there — with +the ground curving up on both sides and the far side of the world hanging 100 km +overhead, clearer than the ground at your feet. Turn its sun up to Earth clear-sky +noon, or down to permanent dusk. Tower flies all 78 billion mirrors of its own sunlight, individually, because nothing passive would stay in the beam past mid-afternoon. It is a person. ## Status -**Phase 6 — writer mode.** The map and the world file work end to end; the 3D -exterior and interior views are still ahead. +**All three views are built** — map, exterior and interior — over an authored +geography and a working writer mode. -Built: unrolled surface map with slats and land strips, place editing for six kinds -(points and polygons), a runtime slat-count control (0–16), localStorage autosave, -backup and static exports, import, and the sightline probe. +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. - [design.md](design.md) — canon, physics and application architecture - [decisions.md](decisions.md) — choices made and why @@ -35,13 +36,14 @@ downstream of them. ``` npm install npm run dev # http://localhost:5173 -npm test # 63 tests; canon figures are pinned to design.md +npm test # 94 tests; canon figures are pinned to design.md npm run build npm run terrain # regenerate the maps from content/geography.json ``` Vite + TypeScript, deployed to GitLab Pages by `.gitlab-ci.yml` on every push to the -default branch. Three.js arrives with the 3D views. +default branch. Three.js is loaded only when a 3D view is opened, so a reader who +stays on the map never downloads it. 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 @@ -51,12 +53,14 @@ needed. ## Geography [`content/geography.json`](content/geography.json) is the authored source of truth -for terrain — coordinates in canon (θ, z), elevations in metres above the base -shell. `npm run terrain` renders it, with no dependencies beyond Node: +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: | | | |---|---| -| `static/terrain-height.png` | 16-bit greyscale, 0–5,000 m | +| `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 | @@ -89,13 +93,31 @@ Any selection updates the URL, so a link is always shareable: ## Views **Map** is the unrolled inner surface. **Exterior** is Tower from outside — hull, -end caps, the 244 km collector flying ahead and the beam converging into the sun -cap, with the terrain wrapped onto the hull as an x-ray of what is inside. Each +checkered end caps, the 244 km collector flying ahead and the beam converging into +the sun cap, with the terrain wrapped onto the hull as an x-ray of what is inside. Each piece is captioned. The active view is remembered and travels in the permalink, so `#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 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 +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. + +Drag to look, scroll to zoom in like a pair of binoculars, and select a place — in +the list or by its caption — to **walk there**. Most of the world is beyond the haze +horizon from any given spot, so travelling is more use than turning. + +The end caps are checkered — the only structure in the world, and the only thing in +it with a known cell size, so they double as a rule for judging distance. The eye-height slider runs from 1.7 m to 25 km with the local gravity +beside it, the light has the three canon settings, and **Rain** is the only moving +thing in the sky — it leans 1.5° antispinward, which is all the evidence there is +that Tower turns. Nothing in the view rotates, because you, the ground and the air +all turn together. + ## Working in the map Pick a tool, click to place; **Lake**, **Forest**, **Desert** and **Region** trace a diff --git a/decisions.md b/decisions.md index 901cbba..f337990 100644 --- a/decisions.md +++ b/decisions.md @@ -1019,3 +1019,183 @@ raster from the same count the app uses, which means the count becomes an input `npm run terrain` rather than a runtime toggle — and the geography would need re-authoring around wherever the bands fall. That is a content decision as much as a code one, which is the real reason it is parked. + +--- + +## D-034 — The interior view: real geometry, and haze doing the work + +Phase 5, and the piece design.md §2.4 said would carry the illusion. The scope held: +**real geometry, simple ground.** The shell is the actual cylinder, displaced by the +height raster and painted with the colour raster; there is no scenery, no buildings +and no vegetation. + +**Nothing in the scene rotates, and that is the point.** The observer, the ground and +the air all co-rotate, so from inside a sealed hull the spin is genuinely invisible. +No stars wheel overhead because there are no stars (§1.4). What is left is +`src/canon/motion.ts`, and it says something the design did not anticipate: + +| | lean, antispinward | +|---|---| +| rain at terminal velocity | **1.5°** — fixed, whatever the cloud height | +| a 1 km waterfall, at the bottom | **11.3°**, and 133 m of drift | + +The waterfall figure is the *velocity* at the bottom, which is the angle a streak of +falling water is drawn at. §1.9.2 tabulates 7.6° for the same drop, which is the +tilt of the *path* — where it lands against how far it fell. Both are right and they +differ by exactly half again in tangent (`freeFallPathLeanRad` alongside +`freeFallLeanRad`, pinned in test/motion.test.ts), and noticing they were not the +same number is the only reason this is written down rather than silently +contradicting the canon. + +Rain barely leans, and it leans by a *constant* angle because drag holds the fall +speed constant. Only free fall keeps accelerating, so only free fall keeps leaning. +Design.md §2.4 offered "slanted rain and a leaning waterfall" as a pair; they are not +a pair. **The waterfall is the compass.** The rain is drawn, and the readout prints +1.5° beside it so the smallness reads as the finding it is rather than as a bug. + +**Haze is integrated per fragment**, 24 midpoint samples through the rotational +density profile, and it reproduces the §1.8 table from the other direction: the +readout prints 78.1% straight up against 31.3% for the same 100 km along the ground. +The extinction is carried per channel (Rayleigh goes as λ⁻⁴, so red survives twice +as far as green and blue half as far), which is what makes distance blue rather than +merely grey. + +**The airlight is neutral, deliberately.** Tinting it sky blue is the obvious move +and it is wrong: blue already scatters 2.4× harder in the per-channel extinction, so +a blue source colours the haze twice and every distant thing turns mauve regardless +of what it is made of — desert included, which is how the bug was spotted. With a +neutral source the blue falls out of the physics and the far side stays recognisably +forest and desert at the 78% canon promises. + +## D-035 — Standing on the ground requires the ground on the CPU + +The shell is displaced in the vertex shader, which is fine until somebody has to +stand on it. The first build put the camera 1.7 m above the *undisplaced* cylinder — +underground everywhere the land is higher than 1.7 m, 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. The view showed sky below the horizon and nothing else, and +at 366 m of eye height it looked perfect, which is what made it puzzling. + +There is no way to ask the GPU where the ground is, so `terrain-height.png` is now +read back through a canvas and sampled here. Two mismatches had to be handled: + +- **The mesh is coarser than the raster.** Cells are 0.7 km, texels are 0.26 km, so + the surface actually drawn is the raster sampled at the cell corners and stretched + flat between them. The lookup interpolates the same four corners rather than + sampling the raster directly, which would put the observer's feet tens of metres + off in rough country. +- **Underground is much worse than hovering.** Half the spread across the cell is + added as clearance: nil on the plains, and on a ridge it lifts you clear of the + facet instead of dropping you inside it. + +The readback is 8-bit — browsers decode 16-bit PNGs down to 8 — so elevations +quantise to 19.5 m steps. Both the shader and this lookup read the same quantised +data, so they agree with each other, which is what matters for not being buried. + +## D-036 — A custom ShaderMaterial writes linear colour into an sRGB canvas + +Three inserts its colour-space handling into its own materials and never into a +custom one, and the first build silently got both halves wrong: the colour raster +was sampled as if it were linear, and linear output was written straight to the +canvas. Everything read dark and oversaturated, and the far side of the world came +out navy where it should have been hazy green. + +The fix is both halves explicitly — decode the raster with a `pow(c, 2.2)` and end +each fragment shader with `#include <colorspace_fragment>`. The trap on the way: +adding `#include <colorspace_pars_fragment>` too fails to link, because Three +already puts that chunk in the prefix it builds for *every* material in order to +define `linearToOutputTexel`. The duplicate definitions produced no console error at +all — only `INVALID_OPERATION: no valid shader program in use` once per frame, and a +window filled with clear colour. + +## D-037 — A shared `#view=` link was erased before it was read + +Found while opening the interior view by URL. `App`'s constructor rendered before it +decided which view to open, and `render()` rewrites the hash from current state — so +`#view=interior` was replaced by an empty hash a few lines before `initialView()` +went looking for it. Every shared link that named a view had been landing on the map +since D-029, and it went unnoticed because the last-used view is *also* persisted in +`localStorage`, which restored the right view for whoever had visited before. The +view is now decided before the first render. + +## D-038 — Selecting a place walks you there + +Turning to face a place was the wrong default, and standing in the view for a minute +makes it obvious: at ground level the haze horizon closes at 100–150 km (§1.8), so +"face the Deepward Sea" from the capital points at 270 km of nothing. Selecting a +place now *moves* the observer to it, which is what "focus" already meant in the +other two views — the map centres, the exterior swings round, the interior walks. + +Places with extent also get a heading: stand at the head of a river and look down +it, taking the centroid of the traced points as the aim. A point has no direction of +its own, so arriving is the whole answer and the heading is left alone. The separate +"Stand at the selected place" button is gone, since selection now does exactly that. + +## D-039 — The height raster gets a declared working range + +`terrain-height.png` was normalised 0..5,000 m against a fixed `maxElevationM`, and +both halves of that were wrong. + +**The floor mattered more than the ceiling.** Clamping at zero flattened everything +below the datum, so every sea and lake floor was a plane — the Deepward Sea read as +0 m where it is actually −320 m. `content/geography.json` now declares +`minHeightM` / `maxHeightM` and the raster is stretched across that range. + +**The range is declared, not fitted.** Fitting it to the terrain would silently +change what every grey level means whenever the geography is edited, which is the +kind of drift D-024 came from. Instead the generator prints the range it found +against the range declared, warns and clamps if the terrain has outgrown it, and +test/raster.test.ts asserts the declared range still contains the terrain. + +**The precision win is smaller than it looks**, and worth stating plainly: the +terrain spans −508..4,392 m, so tightening the range to −520..4,400 m moves the step +from 19.6 m to 19.3 m. The quantisation is not set by the range at all — it is set +by browsers decoding a 16-bit PNG down to 8 bits, which leaves 256 levels however +wide the span. The fix for *that* is a second byte in a second channel, which is a +change to the encoder and the shader, and is not made here. + +## D-040 — The axial sightline names the distance it measured + +The readout compared "100 km up" against "100 km on" — except the along-axis probe +was clamped to the far cap, so standing 22 km from the end it measured 22 km and +still called it 100. It reported 77.5% where the honest figure for 100 km is 31%, +which is exactly backwards from the point the line exists to make. It now measures +whichever axial direction has the most room and prints the distance it used. + +## D-041 — The caps get a pattern, and it is polar + +The end caps were flat tinted discs. They are the only surfaces in Tower that are +not landscape — 100 km of structure closing each end — and blank they read as fog +with an edge. A checker fixes that, and does something more useful besides: it is +the one thing in the world with a **known cell size**, so a glance at the far cap +tells you roughly how far away it is. That is worth having in a world where §1.8 +says distance is otherwise unreadable. + +**Polar, not square.** A cap is a disc on a spinning cylinder, so its natural +coordinates are radius and angle; a square grid meets the rim at every possible +angle and reads as something laid over the geometry rather than part of it. Cells +stay roughly square by doubling the sector count each time the radius doubles, which +leaves a seam at each doubling — which is what a segmented diaphragm would have +anyway. The alternative is slivers at the hub or slabs at the rim. + +**One texture, both views.** Drawn once into a canvas and shared, so the exterior +and the interior cannot drift apart. Three maps UVs across the bounding box for both +`CircleGeometry` and `RingGeometry`, so the same image lands correctly on the far +cap's full disc and on the sun cap's annulus. + +Two things had to be walked back after looking at it: + +- **It must be mipmapped.** A high-contrast geometric pattern is the worst case for + point sampling. My first pass disabled mipmaps on the theory that resolving the + checker at distance was the whole point; minified, the far cap moiréd into a dark + cross-hatch that read as damage. Averaging to grey once the cells fall below a + pixel is both correct and honest — you cannot resolve them from 540 km either. +- **Six rings, not ten.** 5 km cells looked better face-on from inside and collapsed + from outside, where D-030 deliberately frames the caps near edge-on and + foreshortens them ten to one. A pattern has to survive its worst viewing angle. + +The cap framing also moved to ~40° off the axis rather than ~17°, since there is now +something on the face worth turning to see, and the caps carry an emissive copy of +the pattern for the same reason the hull carries one of the terrain: sunlight runs +along the axis, so a cap is edge-on to every real light and renders as a dark disc +exactly when it is being looked at. diff --git a/design.md b/design.md index ad07aee..eb1601a 100644 --- a/design.md +++ b/design.md @@ -300,16 +300,25 @@ absent, and a meander in Tower is pure flow dynamics with no thumb on the scale. The force goes into the floor rather than across it, so it appears whenever water moves up or down. Deflection of a free fall goes as h^1.5: -| Fall | Lands (retrograde) | Lean | -|---|---|---| -| 100 m | 4.2 m | 2.4° | -| 500 m | 47 m | 5.4° | -| **1,000 m** | **133 m** | **7.6°** | -| 2,000 m | 377 m | 10.7° | +| Fall | Lands (retrograde) | Lean of the path | Lean at impact | +|---|---|---|---| +| 100 m | 4.2 m | 2.4° | 3.6° | +| 500 m | 47 m | 5.4° | 8.1° | +| **1,000 m** | **133 m** | **7.6°** | **11.3°** | +| 2,000 m | 377 m | 10.7° | 15.8° | + +Two different angles, and the interior view needs both. The **path** leans by where +the water lands against how far it fell — the tilt of a waterfall's plume seen +whole. The **impact** lean is the direction the water is actually moving when it +arrives, which is what a streak of it is drawn at, and it is half again the other in +tangent. `src/canon/motion.ts` computes both. **Rain falls permanently slanted** — a raindrop at terminal velocity sits at a fixed **1.47° against the spin**, ~51 m of drift over a 2 km fall, never varying, never -reversing. Rising things lean the opposite way: smoke drifts spinward, rain drifts +reversing. It is fixed because drag holds the fall speed constant, so unlike free +fall the angle does not grow with the drop: rain leans the same 1.5° whatever the +cloud. Set against a waterfall's 11°, **the waterfall is the compass and the rain is +not**, which is the opposite of what §2.4 originally assumed (D-034). Rising things lean the opposite way: smoke drifts spinward, rain drifts anti-spinward. Every plume and every shower in Tower tilts, and they tilt against each other. @@ -497,10 +506,12 @@ src/ canon/ constants.ts single source of truth for every number in §1 geometry.ts (θ,z) <-> 3D, slat layout, map <-> surface - lighting.ts swarm aiming solution -> sun direction + intensity at - (θ,z,t), scaled by the global brightness setting (§1.3) + 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 + visible evidence of the spin, now that there are no stars 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 world/ schema.ts world.json types + validation store.ts places CRUD, localStorage drafts, JSON import/export @@ -510,12 +521,11 @@ src/ views/ exterior.ts Three.js: hull, caps, collector, beam, spin, point picker map.ts Canvas 2D: unrolled surface, slats, terminator, point picker - interior.ts Three.js: shell geometry, beam, diffusers, ground - sky.ts the atmospheric shader of §1.5 + interior.ts Three.js: shell geometry, the haze shader of §1.5, beam, rain ui/ - controls.ts time-of-day slider, brightness slider, spin toggle, view switcher - editor.ts writer-mode place editing panel - readout.ts live figures for the picked point + app.ts the whole panel: tools, editor, readouts, view switching + scales.ts slider mappings — kept out of views/ so the panel can label + the eye-height slider without pulling in Three.js main.ts ``` @@ -587,25 +597,52 @@ Markers are scaled per frame to hold a roughly constant size on screen. A 4 km d is legible from 200 km and sub-pixel from the 1,200 km of the default view, which is to say the places would simply not be there. -### 2.4 The interior view - -Per the chosen scope: **real geometry, simple ground.** - -Real: the shell curving up on both sides, the far land visible overhead through -haze, the axis beam and its diffusers, correct gravity-gradient and atmospheric -cues. **No stars** — the hull is sealed (§1.4). The sky is land and light. - -Simple: the ground is procedural terrain and texture that varies plausibly with -(θ, z), not modelled scenery. No buildings, no vegetation meshes. Detailed -procedural scenery is a later phase, deliberately deferred. - -The sky is a shader over an inward-facing shell, fed by: view direction, the -atmospheric column integral along that ray, and the diffuser lighting solution. It -is the piece that carries the whole illusion and deserves the most care. - -With the stars gone, the interior view has to sell the spin some other way, and -§1.2 says how: **the sky is still and the water is not.** Slanted rain and a leaning -waterfall now do the work the wheeling stars used to. +### 2.4 The interior view — built + +Scope held: **real geometry, simple ground.** + +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`. +**No stars** — the hull is sealed (§1.4). The sky is land and light. + +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. + +**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 +extinguishes per channel — red survives twice as far as green, blue half as far, +which is what makes distance blue rather than grey. From the floor it reproduces +§1.8 from the inside: 78% straight up against 31% for the same 100 km along the +ground, printed in the readout so the claim can be checked rather than believed. +The airlight the haze adds is *neutral*; tinting it blue double-counts the +wavelength dependence and turns even the desert mauve (D-034). + +**Nothing in the scene rotates**, because the observer, the ground and the air all +co-rotate. Inside a sealed cylinder the spin is genuinely invisible, which leaves +what falls to carry it — and §1.9.2 turns out to be lopsided about how. Rain leans a +fixed 1.5° whatever the cloud, because drag pins its speed; only free fall keeps +accelerating and so keeps leaning. **The waterfall is the compass; the rain is +barely a hint.** Rain is drawn, and the readout prints its 1.5° next to it. + +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). + +Instruments in the view: eye height from 1.7 m to 25 km with the local gravity +beside it, the three 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 +100–150 km, so turning to face something usually just points at haze. + +The caps are checkered (D-041) — polar, six rings, cells about 8 km. They are the +only structure in the world, and the only object in it with a known size, so they +double as a scale for judging distance. The same pattern is on them in the exterior +view. + +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. --- @@ -706,7 +743,8 @@ a moment. Reading the world, not editing it. 2. **Scaffold** — Vite/TS/Three, CI to Pages, `canon/` implemented and unit-tested. 3. **Exterior** — ✅ hull, caps, collector, beam, spin, and hull point picking. 4. **Map** — unrolled surface, slat rendering, picking, sync with exterior. -5. **Interior** — shell geometry, sky shader, beam, stars, time-of-day. +5. **Interior** — ✅ shell geometry, the haze shader, the beam, rain, eye height, + and the three illumination settings. No stars: there are none to draw. 6. **Writer mode** — ✅ *built ahead of 3–5, at the author's request, because the world needs developing before there is anything to render.* Map view, place editing for all thirteen kinds, points/polylines/polygons, localStorage @@ -716,8 +754,8 @@ a moment. Reading the world, not editing it. writer modes (D-011); permalinks (D-010). 8. **Polish** — transitions, mobile, performance. -Phase 5 (interior) is the last major piece. Phase 2's `canon/` landed with phase 6, -since the map could not be drawn without it. +All five view phases are built. Phase 2's `canon/` landed with phase 6, since the +map could not be drawn without it. **Shareable permalinks are implemented** (phase 7) — the app has readers, and "stand here and look at this" is the whole reason it exists as a