tower / commit
feat(photos): save a view as numbers, go back to it, render it again at 4K (D-099)
.gitignore | 3 + README.md | 63 ++++++++++++++++++++- content/photos.json | 53 +++++++++++++++++ decisions.md | 66 ++++++++++++++++++++++ design.md | 12 +++- index.html | 7 +++ package.json | 3 +- src/ui/app.ts | 154 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/exterior.ts | 37 +++++++++++- src/views/interior.ts | 41 +++++++++++++- src/world/photos.ts | 124 ++++++++++++++++++++++++++++++++++++++++ test/photos.test.ts | 44 +++++++++++++++ tools/browser.mjs | 100 ++++++++++++++++++++++++++++++++ tools/photos.mjs | 119 ++++++++++++++++++++++++++++++++++++++ tools/screenshots.mjs | 90 ++--------------------------- vite.config.ts | 55 +++++++++++++++++- 16 files changed, 876 insertions(+), 95 deletions(-) diff --git a/.gitignore b/.gitignore index 3facc40..6f80ec9 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ dist/ # Left over from a rejected Python attempt; kept out of the repo. .venv/ .cache + +# Renders of content/photos.json (npm run photos): made from the list, so not kept. +photos/ diff --git a/README.md b/README.md index 10ccaaa..2670268 100644 --- a/README.md +++ b/README.md @@ -158,15 +158,76 @@ It is a smoke alarm, not a verdict: when it fires, go and look at the sheet. Not that a view which never worked scores 0% when it breaks further — this catches regressions, not things that were already broken. +## Photographs + +A view worth keeping — things lined up, at a particular time — is saved as numbers and +rendered again whenever it is wanted, so an illustration can always be remade from the +latest world and the latest renderer (D-099). + +1. `npm run dev`, open the **exterior** or the **interior**, and frame the shot: the camera, + the day and time, the Light, the hull. +2. Press **P**, or **Save this shot** under View, and give it a name. The shot goes into + [`content/photos.json`](content/photos.json). Saving under a name already in the list + re-frames that shot. The button and the key exist only on a dev server, and it takes a + shot only from the machine it runs on; the published site has neither. +3. `npm run photos` renders every shot to `photos/<name>.png`. + +To go back to a shot, pick it from **Saved shots**, under the button: the app opens its +view, puts the camera where the shot has it, and stops the clock at its moment (and, +outside, the spin), and carries on from there. Adjust it and save under the same name — +the name is filled in — to re-frame it. The list is read from the file each time it is +opened, so a shot typed by hand is in it too. + +``` +npm run photos # every shot in content/photos.json +npm run photos -- --only far-cap # just the shots whose names start so +npm run photos -- --long-edge 5760 # bigger; the default is 3840 (4K) +npm run photos -- --url http://localhost:5173 # use a dev server already up +``` + +A render is **the view alone** — no panel, no readouts, no gauge, no labels — at the shape +of the window the shot was framed in, 3840 px along its long edge. The file name never +changes, so rendering again replaces the picture with the newest version of itself. +`photos/` is not committed: the list is what is kept, and the pictures are made from it. + +An entry is plain numbers, and is meant to be edited by hand as well: + +| | | +|---|---| +| `id` | the file's name: lower-case letters, digits, hyphens | +| `note` | anything worth remembering; kept when the shot is re-framed | +| `view` | `exterior` or `interior` | +| `hours` | the world clock, hours since day 0 began (day 2, 06:30 is `54.5`) | +| `dial` | the Light: `dark`, `twilight`, `temperate`, `full` | +| `aspect` | width over height of the frame | +| interior: `theta`, `z`, `eyeM` | where the observer stands: radians round, metres from the sun cap, eye height in metres | +| interior: `yaw`, `pitch`, `fovDeg` | heading in radians from deepward (`0` faces the far cap, `3.14159` the sun cap), elevation in radians, field of view in degrees | +| interior: `spin` | how far the sky through the far cap has wheeled, radians | +| exterior: `position`, `target` | the camera and the point it looks at, in kilometres | +| exterior: `phase`, `hull` | how far the hull has turned, radians; and the hull reading | +| exterior: `fovDeg` | field of view in degrees. The view's own is `42` and it has no zoom, so a saved shot says `42`; type a smaller number to frame it tighter | + +A render holds still: the hull does not turn, the sky through the far cap does not wheel, +and the water is held at one moment, so the same entry gives the same file every time. A +shot cannot be saved while a scenic tour is running — its flyers belong to the tour — so +stop the tour first. + +The list holds the two caps as seen from 300 m over the Capital at 08:45, and +`tower-from-the-far-end`, the blog illustration of 2026-08-19 worked back from its JPEG: +the camera and the field of view fitted to where the far cap, the collector and the sun +fall in it, the day from the stars and the hour from the lit rod. How far the hull had +turned could only be told to a sixth of a turn, so the rock is not lump for lump the same. + ## Development ``` npm install npm run dev # http://localhost:5173, plus a LAN URL and a QR code for it -npm test # 175 tests; canon figures are pinned to design.md +npm test # 279 tests; canon figures are pinned to design.md npm run build npm run screenshots # drive the app and rephotograph docs/screenshots/ npm run screenshots -- --only tour-grand # just those shots, for working on one view +npm run photos # render the saved shots in content/photos.json to photos/ ``` `npm run dev` binds to every interface (`vite --host`) and prints a scannable QR diff --git a/content/photos.json b/content/photos.json new file mode 100644 index 0000000..f1c4e36 --- /dev/null +++ b/content/photos.json @@ -0,0 +1,53 @@ +[ + { + "id": "far-cap-from-the-capital", + "note": "From 300 m over the Capital, at 08:45 on day 0.", + "view": "interior", + "hours": 8.75, + "dial": "temperate", + "aspect": 1.5, + "theta": 0, + "z": 270000, + "eyeM": 300, + "yaw": 0, + "pitch": 0.2, + "fovDeg": 60, + "spin": 0 + }, + { + "id": "sun-cap-from-the-capital", + "note": "From 300 m over the Capital, at 08:45 on day 0.", + "view": "interior", + "hours": 8.75, + "dial": "temperate", + "aspect": 1.5, + "theta": 0, + "z": 270000, + "eyeM": 300, + "yaw": 3.14159, + "pitch": 0.2, + "fovDeg": 60, + "spin": 0 + }, + { + "id": "tower-from-the-far-end", + "note": "The 2026-08-19 blog illustration, worked back from the JPEG: camera and field of view fitted to the far cap, the collector and the sun (about 3 px), the day from the stars, the hour from the lit rod. The hull's turn is only known to a sixth of a turn.", + "view": "exterior", + "hours": 138.78, + "dial": "temperate", + "aspect": 1.4728, + "hull": "rock", + "position": [ + 137.2, + 30.4, + 861.1 + ], + "target": [ + 7.5, + 0.4, + 400 + ], + "phase": 0, + "fovDeg": 33.31 + } +] diff --git a/decisions.md b/decisions.md index 0c26a2c..c93bddb 100644 --- a/decisions.md +++ b/decisions.md @@ -3350,3 +3350,69 @@ more than half a percent (flying, a tour, the gauge) or the gauge is touched; it gauge is held. The view re-telling the same height every frame does not wake it. The thumb and the ticks stay, so the height is still read off the scale. Wider screens are unchanged: there the box is not in the way. Screen readers are unaffected — the gauge's value text is always current. + +## D-099 — Photographs: a view saved as numbers, rendered again on demand + +**Asked for** (author, 2026-10-03): "i've been finding interesting photo opportunities, like +lining things up at a particular time, etc. and i've started to use these photos as +illustrations, after messing with them in lightroom. let's come up with a way for me to choose +the parameters, then render the latest version of that image." And, of the choices then put to +the author: save from the app with a key and a button that show up only locally; 4K; no labels at +all; the renders in a `photos/` folder that git ignores; the exterior first, then the two caps +from the Capital. + +**The list** (`content/photos.json`, `src/world/photos.ts`). A photograph is a view, a moment +and a frame: the world clock in hours, the Light, the frame's aspect, and then for the +interior where the observer stands and looks (`theta`, `z`, `eyeM`, `yaw`, `pitch`, `fovDeg`) +and how far the far cap's sky has wheeled (`spin`); for the exterior the camera and its target +in kilometres, the hull's turn (`phase`), the field of view and the hull reading. The exterior +has no zoom, so a saved shot's field is always its own 42°; the number is there to be typed, +to frame a shot tighter than the view can. Its `id` is its file name. +`parsePhoto` checks every field and says which is wrong, because the list is typed by hand as +often as it is saved, and the id is held to letters, digits and hyphens because it becomes a +path. + +**Saving** (`ui/app.ts savePhoto`, `vite.config.ts`). **P**, or **Save this shot** under View, +takes the open 3D view, asks for a name, and posts it to the dev server, which writes the +list; a name already there is re-framed after a confirm, keeping its place and its note. This +is the one place the browser causes a file to be written, and D-072 stands: it is not the +world, it is not in the build (the row is hidden and the key does nothing unless +`import.meta.env.DEV`), and the endpoint answers only the machine the server runs on, since +`npm run dev` is open to the network. A shot is refused while a scenic tour runs: the tour's +flyers and camera are its own and would not come back. + +**Going back to one** (author, the same day: "it would be helpful to be able to navigate back +to existing points, like replaying. show a list of current ones and allow loading one?"). +**Saved shots**, under the button, lists the file as the dev server has it now, read again +each time the list is opened. Choosing one opens its view and poses it (`poseShot`, not held), +with the clock stopped at its hour and, outside, the spin switched off, since otherwise the +framing is gone before it is seen; the view carries on from there, and the shot's name is +offered when the next one is saved, so adjusting and re-framing is two keys. + +**Rendering** (`tools/photos.mjs`, `npm run photos`). Each shot is opened with its clock in +the address, posed through `window.towerPhotos.show` — which sets the Light and the hull and +calls the view's `holdShot` — and photographed with everything but the canvas taken away: +panel, readouts, gauge, view buttons, labels. The window is the shot's aspect, 1920 CSS px on +its long edge at two device pixels, so the picture is 3840 px and the stars and lines keep the +weight they had on the author's screen. `--long-edge` changes the size, `--only` picks shots. +A held view stands still — the hull does not turn, the far cap's sky does not wheel, the water +is at the held scenes' moment, the plant does not vent — and an interior entry rendered twice +gave the same bytes. Posed directly, not through the UI: the screenshot tour (D-042) is a test +of the application and stays as it is; this is a camera. The two share `tools/browser.mjs`, +and the tour hides the save button, since it runs on a dev server and photographs what +readers get. + +**Not done.** A shot taken during a tour. + +**The list as it starts.** The far cap and the sun cap from 300 m over the Capital, at 08:45 +(author: "i like the cap photos, but please change them to be at around 8:30-9am"). And +`tower-from-the-far-end`: the author's blog illustration, whose settings were never saved, +worked back from the JPEG. Its stars matched a render's at 1.28 times the size, so it is a +crop — hence the 33.31° field; the camera was fitted by least squares to the far cap's hub +and outline, the collector's rim and the sun, to about 3 px; the stars put it on day 5, and +the lit rod seen through the far cap puts the sun 525 km along, 18:47. The cap's frame +repeats every sixth of a turn and the rock could not be matched through the processing, so +`phase` is 0 by default rather than by measurement. + +**Tests** (`test/photos.test.ts`): both kinds of shot read back as written; a bad field named; +ids that could leave the folder refused; every entry in the list valid and uniquely named. diff --git a/design.md b/design.md index 8bc5398..9a0f353 100644 --- a/design.md +++ b/design.md @@ -979,6 +979,7 @@ src/ schema.ts world.json v2 types + validation: features, not places instruments.ts sightline, viewshed, lighting probe, travel (§3.3) weather.ts Tower's weather plan read at an hour: what it does, what it told people + photos.ts a saved shot of a 3D view: its numbers, checked (D-099) state/ store.ts selected point, time-of-day, spin phase, view mode views/ @@ -992,13 +993,17 @@ src/ scales.ts slider mappings — kept out of views/ so the panel can label the eye-height gauge without pulling in Three.js main.ts loads world.json and starts the app -vite.config.ts also makes world.json from content/world.json (§3.1) +vite.config.ts also makes world.json from content/world.json (§3.1), and on a dev + server writes the author's saved shots to content/photos.json (D-099) tools/ node-only, never bundled; plain .mjs with no build step port-world.mjs the one-off port of v1's geography.json into world.json v2 build-pages.sh the Pages site: every branch built into one, with branches.json (D-073) screenshots.mjs drives the running application in a headless browser and photographs it into docs/screenshots/ (D-042) + photos.mjs renders the shots in content/photos.json to photos/, the view alone, + at 4K (D-099) + browser.mjs what those two share: the browser, the warm world, waiting for terrain ``` `canon/constants.ts` is the spine. Every derived figure — period, rim speed, land @@ -1281,6 +1286,11 @@ push, and the published site serves it. Everyone sees the same app. Until D-072 there was a writer mode (the default) and a reader mode (`?reader=1`); the query parameter now does nothing. +**One exception, and it is not the world** (D-099): on a dev server the author has a +button and a key that save the open view as a photograph, into `content/photos.json`, +written by the dev server. It is not in the build, so readers still see one app, and it +changes nothing about the world: a photograph is a camera position and a moment. + ### 3.2 The world file ```jsonc diff --git a/index.html b/index.html index 7ffeec0..1e9f308 100644 --- a/index.html +++ b/index.html @@ -47,6 +47,13 @@ <summary>View</summary> <label class="row" id="terrain-row"><input id="f-terrain" type="checkbox" checked /> Show terrain</label> <label class="row"><input id="f-labels" type="checkbox" checked /> Show labels <span class="dim">— L</span></label> + <div id="photo-row" hidden> + <button id="f-photo" type="button">Save this shot — P</button> + <label>Saved shots + <select id="f-photos"><option value="">Go to a saved shot…</option></select></label> + <p class="hint" id="photo-status">Saves the framing, the time and the Light to + <code>content/photos.json</code>; <code>npm run photos</code> renders them.</p> + </div> <div id="map-options"> <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 diff --git a/package.json b/package.json index 66240c6..07fd66d 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "preview": "vite preview", "test": "vitest run", "test:watch": "vitest", - "screenshots": "node tools/screenshots.mjs" + "screenshots": "node tools/screenshots.mjs", + "photos": "node tools/photos.mjs" }, "devDependencies": { "@types/node": "^26.4.1", diff --git a/src/ui/app.ts b/src/ui/app.ts index 1421c27..b0675f6 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -17,6 +17,7 @@ import { TowerMap } from '../views/map.js' import type { ExteriorView, HullMode } from '../views/exterior.js' import type { InteriorView } from '../views/interior.js' import { clock } from '../world/clock.js' +import { type Framing, type Photo, parsePhoto, photoId } from '../world/photos.js' import { type Sky, clockText, dateLabel, hoursAt, isDial, skyAt } from '../world/sky.js' import { CIRCUMFERENCE_M, G0, LENGTH_M, RADIUS_M, SPIN_PERIOD_S, gravityAtHeight } from '../canon/constants.js' import { EyeGauge } from './eye-gauge.js' @@ -153,6 +154,7 @@ export class App { this.buildViews() this.wireView() this.wirePanel() + this.wirePhotos() void this.wireBranches() window.addEventListener('hashchange', () => this.readHash()) window.addEventListener('resize', () => { @@ -584,6 +586,155 @@ export class App { $('panel').inert = this.narrow.matches && !open } + // ------------------------------------------------------------------- photos + + /** The shot last gone back to, offered as the name when the next one is saved. */ + private photoName = '' + + /** + * Photographs (world/photos.ts, D-099). Saving one, and going back to one, are for the + * author at a dev server, which is what keeps the list (vite.config.ts): the button, the + * key and the list are not there on the published site. Holding one is the photo tool's + * way in, and is always there, as the views' own handles are — it does nothing until it + * is called. + */ + private wirePhotos(): void { + ;(window as unknown as Record<string, unknown>)['towerPhotos'] = { + show: (raw: unknown) => this.showPhoto(parsePhoto(raw), true), + } + if (!import.meta.env.DEV) return + $('photo-row').hidden = false + $('f-photo').onclick = () => { void this.savePhoto() } + const list = $<HTMLSelectElement>('f-photos') + // Read again whenever the list is reached for: the file is edited by hand as well. + list.onpointerdown = list.onfocus = () => { void this.listPhotos() } + list.onchange = () => { + const id = list.value + list.value = '' + if (id) void this.gotoPhoto(id) + } + void this.listPhotos() + } + + /** The saved shots, as the dev server has them now, into the list. */ + private async listPhotos(): Promise<unknown[]> { + let shots: unknown[] = [] + try { + const res = await fetch('/__photos', { cache: 'no-store' }) + const raw: unknown = res.ok ? await res.json() : [] + if (Array.isArray(raw)) shots = raw + } catch { /* no dev server answering just means an empty list */ } + const list = $<HTMLSelectElement>('f-photos') + const ids = shots.map((s) => String((s as { id?: unknown } | null)?.id ?? '')) + const had = [...list.options].slice(1).map((o) => o.value) + // Left alone when nothing changed: rebuilding an open menu closes it. + if (ids.join('\n') !== had.join('\n')) { + list.replaceChildren(list.options[0]!, ...shots.map((s, i) => { + const view = String((s as { view?: unknown } | null)?.view ?? '') + return Object.assign(document.createElement('option'), { value: ids[i]!, textContent: `${ids[i]} — ${view}` }) + })) + } + return shots + } + + /** Go back to a saved shot: its view, its camera, its moment — and carry on from there. */ + private async gotoPhoto(id: string): Promise<void> { + const say = (text: string): void => { $('photo-status').textContent = text } + const raw = (await this.listPhotos()).find((s) => (s as { id?: unknown } | null)?.id === id) + try { + await this.showPhoto(parsePhoto(raw), false) + this.photoName = id + say(`At ${id}, with the clock${this.view === 'exterior' ? ' and the spin' : ''} stopped. ` + + 'Save under the same name to re-frame it.') + } catch (e) { + say(`Cannot go to ${id}: ${e instanceof Error ? e.message : String(e)}`) + } + } + + /** The open view as a photo, less its name: where the camera is, the moment, the Light. */ + private takePhoto(): Framing | string { + const v = this.view === 'exterior' ? this.exterior : this.view === 'interior' ? this.interior : null + if (!v) return 'Open the exterior or the interior first: a shot is of one of those.' + // A tour's flyers and its camera are the tour's; a shot saved from one would come back without them. + if (v.touring) return 'Stop the tour first (Esc): a shot does not keep a tour\'s flyers.' + const box = $(this.view).getBoundingClientRect() + const to = (x: number, places: number): number => Number(x.toFixed(places)) + const common = { hours: to(clock.hours, 4), dial: clock.dial, aspect: to(box.width / box.height, 4) } + if (this.view === 'exterior') { + const s = this.exterior!.shot() + const km = (p: [number, number, number]): [number, number, number] => [to(p[0], 3), to(p[1], 3), to(p[2], 3)] + return { + view: 'exterior', ...common, hull: $<HTMLSelectElement>('f-ext-view').value as HullMode, + position: km(s.position), target: km(s.target), phase: to(s.phase, 5), fovDeg: to(s.fovDeg, 2), + } + } + const s = this.interior!.shot() + return { + view: 'interior', ...common, + theta: to(s.theta, 6), z: to(s.z, 1), eyeM: to(s.eyeM, 2), + yaw: to(s.yaw, 5), pitch: to(s.pitch, 5), fovDeg: s.fovDeg, spin: to(s.spin, 5), + } + } + + /** Name the shot on screen and hand it to the dev server, which keeps the list. */ + private async savePhoto(): Promise<void> { + const say = (text: string): void => { $('photo-status').textContent = text } + // Taken before the name is asked for, so it is the frame that was on screen at the key. + const shot = this.takePhoto() + if (typeof shot === 'string') return say(shot) + const name = prompt('Name this shot. It is rendered to photos/<name>.png; an existing name re-frames that shot.', this.photoName) + if (name === null) return + const id = photoId(name) + if (!id) return say('A shot needs a name with a letter or a digit in it.') + const post = (replace: boolean): Promise<Response> => fetch('/__photos', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ photo: { id, ...shot }, replace }), + }) + try { + let res = await post(false) + if (res.status === 409) { + if (!confirm(`There is already a shot called ${id}. Replace it with this framing?`)) return + res = await post(true) + } + say(res.ok + ? `Saved ${id}. Render it with: npm run photos -- --only ${id}` + : `Not saved: ${await res.text()}`) + if (res.ok) { + this.photoName = id + void this.listPhotos() + } + } catch { + say('Not saved: the dev server did not answer.') + } + } + + /** + * Open a photo's view at its moment: held there for the photo tool to take, or not, for + * the author to carry on from. Either way the clock is stopped at the shot's hour, and + * outside the spin is switched off, or the framing would be gone before it was seen. + */ + private async showPhoto(p: Photo, hold: boolean): Promise<void> { + clock.setRunning(false) + clock.set(p.hours) + clock.setDial(p.dial) + $<HTMLSelectElement>('f-light').value = p.dial + if (p.view === 'exterior') { + const hull = $<HTMLSelectElement>('f-ext-view') + hull.value = p.hull + hull.dispatchEvent(new Event('change')) + const spin = $<HTMLInputElement>('f-spin') + spin.checked = false + spin.dispatchEvent(new Event('change')) + } + await this.setView(p.view) + if (p.view === 'exterior') this.exterior?.poseShot(p, hold) + else { + this.interior?.poseShot(p, hold) + if (this.interior) this.eye.set(this.interior.eyeM) + } + this.render() + } + // --------------------------------------------------------------- permalinks /** @@ -705,6 +856,9 @@ export class App { } // 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() + if (import.meta.env.DEV && (e.key === 'p' || e.key === 'P') && !e.metaKey && !e.ctrlKey && !e.altKey) { + void this.savePhoto() + } if (e.key === 'l' || e.key === 'L') { const box = $<HTMLInputElement>('f-labels') box.checked = !box.checked diff --git a/src/views/exterior.ts b/src/views/exterior.ts index 26840cc..465f486 100644 --- a/src/views/exterior.ts +++ b/src/views/exterior.ts @@ -26,6 +26,7 @@ import { InteriorModel, STAR_GLSL, WINDOW_RENDER_ORDER } from './interior-model. import { defaultCalendar } from '../world/schema.js' import { clock } from '../world/clock.js' import { skyAt } from '../world/sky.js' +import type { ExteriorShot } from '../world/photos.js' import { skyMatrix, starTexture } from './stars.js' import { ROCK_COVER, buildRockHull, rockCover } from './rock-hull.js' import { type PipeHull, buildPipeHull } from './pipe-hull.js' @@ -167,6 +168,8 @@ export class ExteriorView { /** Multiple of real time. Tower turns once per 7.5 min, which is slow to watch. */ timeScale = 60 spinning = true + /** Held at a saved shot (`poseShot`): the hull does not turn and the plant does not vent. */ + private still = false /** Screen-space captions, so nothing in the scene is left unexplained. */ private labels: { at: Vector3; el: HTMLSpanElement }[] = [] @@ -696,6 +699,33 @@ export class ExteriorView { } } + // -------------------------------------------------------------------- photos + + /** The numbers a photograph of this view is made of (world/photos.ts, D-099). */ + shot(): ExteriorShot { + return { + position: this.camera.position.toArray(), target: this.controls.target.toArray(), + phase: this.phase, fovDeg: this.camera.fov, + } + } + + /** + * Put the camera at a saved shot, the hull turned as the shot has it. Held, which is the + * photo tool's way in, the view stays still there, so the same entry is the same picture + * on every run. Not held, it is the author going back to a shot: the hull turns on from + * there if the spin is on. + */ + poseShot(s: ExteriorShot, hold: boolean): void { + if (this.tour) this.stopTour() + this.camera.position.fromArray(s.position) + this.controls.target.fromArray(s.target) + this.controls.update() + this.camera.fov = s.fovDeg + this.camera.updateProjectionMatrix() + this.phase = normalizeTheta(s.phase) + this.still = hold + } + // --------------------------------------------------------------- interaction private attach(): void { @@ -788,11 +818,12 @@ export class ExteriorView { const dt = clock.held ? 0 : Math.min(0.1, (now - this.lastFrame) / 1000) this.lastFrame = now // On the tour the hull turns at the leg's own rate — real time, unless it says otherwise. - const spin = this.tour ? (this.tour.frozen ? 0 : this.tour.current?.rate ?? 1) : this.spinning ? this.timeScale : 0 + const spin = this.still ? 0 : this.tour ? (this.tour.frozen ? 0 : this.tour.current?.rate ?? 1) : this.spinning ? this.timeScale : 0 this.phase = (this.phase + OMEGA_RAD_S * dt * spin) % (2 * Math.PI) this.tower.rotation.z = this.phase - if (this.pipes?.group.visible) this.pipes.update(now / 1000) - if (this.mixed?.pipes.group.visible) this.mixed.pipes.update(now / 1000) + const plantS = this.still ? 0 : now / 1000 + if (this.pipes?.group.visible) this.pipes.update(plantS) + if (this.mixed?.pipes.group.visible) this.mixed.pipes.update(plantS) if (this.tour) this.stepTour(dt) else this.controls.update() /** diff --git a/src/views/interior.ts b/src/views/interior.ts index 05753a2..7c8c66a 100644 --- a/src/views/interior.ts +++ b/src/views/interior.ts @@ -35,6 +35,7 @@ import { type RailLine, railLines } from '../terrain/rails.js' import { type Leg, type Pose, planInterior } from '../tour/interior-legs.js' import { legSeconds, tourRng } from '../tour/plan.js' import type { World } from '../world/schema.js' +import type { InteriorShot } from '../world/photos.js' import { CraftView } from './craft.js' import { TourPlayer } from './tour-player.js' @@ -223,6 +224,8 @@ export class InteriorView { private labels: { place: Feature; el: HTMLSpanElement }[] = [] /** The bank, about the view axis: only a flyer on the scenic tour tilts the horizon. */ roll = 0 + /** Held at a saved shot (`poseShot`): the sky does not wheel and the water does not move. */ + private still = false /** The scenic tour, while one is playing (tour/). */ private tour: TourPlayer<Leg> | null = null private player: TourPlayer<Leg> @@ -775,6 +778,37 @@ export class InteriorView { } } + // -------------------------------------------------------------------- photos + + /** The numbers a photograph of this view is made of (world/photos.ts, D-099). */ + shot(): InteriorShot { + return { + theta: this.at.theta, z: this.at.z, eyeM: this.eyeM, + yaw: this.yaw, pitch: this.pitch, fovDeg: this.fovDeg, spin: this.spin, + } + } + + /** + * Stand at a saved shot. Held, which is the photo tool's way in, the view stays still + * there: the sky through the far cap stays where the shot has it and the water keeps the + * held scenes' moment, so the same entry is the same picture on every run. Not held, it + * is the author going back to a shot, and the view carries on from it. + */ + poseShot(s: InteriorShot, hold: boolean): void { + if (this.tour) this.stopTour() + this.at = { theta: normalizeTheta(s.theta), z: Math.min(LENGTH_M, Math.max(0, s.z)) } + this.eyeM = Math.max(EYE_MIN_M, Math.min(EYE_MAX_M, s.eyeM)) + this.yaw = normalizeTheta(s.yaw) + this.pitch = Math.max(-1.55, Math.min(1.55, s.pitch)) + this.fovDeg = s.fovDeg + this.roll = 0 + this.spin = normalizeTheta(s.spin) + this.still = hold + this.place() + this.cb.onLook() + if (!hold) this.queueSave() + } + // -------------------------------------------------------------------- readout /** What the instruments say from here. Rendered by the app into the overlay. */ @@ -963,18 +997,19 @@ export class InteriorView { */ // The clock runs by itself (clock.ts); the light follows it every frame. this.applyLighting() - this.spin = (this.spin + OMEGA_RAD_S * dt) % (2 * Math.PI) + if (!this.still) this.spin = (this.spin + OMEGA_RAD_S * dt) % (2 * Math.PI) this.time += dt + const waterS = this.still ? FROZEN_TIME_S : this.time // The sea state changes over hours; a few times a second is plenty. if (this.frames % 10 === 0) this.model.setWeather(this.worldHours) for (const m of this.shared) { m.uniforms['uSpin']!.value = this.spin - m.uniforms['uTime']!.value = this.tour?.frozen ? FROZEN_TIME_S : this.time + m.uniforms['uTime']!.value = this.tour?.frozen ? FROZEN_TIME_S : waterS } if (this.tour) this.stepTour(dt) else this.fly(dt) if (!this.tour) this.crafts.clear() - this.model.setShips(this.worldHours, this.time, this.camera.position) + this.model.setShips(this.worldHours, waterS, this.camera.position) // 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) diff --git a/src/world/photos.ts b/src/world/photos.ts new file mode 100644 index 0000000..c9b3129 --- /dev/null +++ b/src/world/photos.ts @@ -0,0 +1,124 @@ +/** + * Photographs (D-099): a view, posed and timed, kept as numbers so the same picture can be + * rendered again from whatever the world and the renderer have become since. + * + * `content/photos.json` is the list. A shot is saved into it from the running app — P, or + * the button, on a dev server only — and `npm run photos` renders every one to + * `photos/<id>.png` (tools/photos.mjs). The numbers are plain and meant to be edited by + * hand as well: nudge an hour, a pitch or a field of view and render again. + */ +import { LENGTH_M } from '../canon/constants.js' +import type { HullMode } from '../views/exterior.js' +import { type Dial, isDial } from './sky.js' + +const HULLS = ['true', 'rock', 'pipes', 'rockpipes', 'xray'] as const satisfies readonly HullMode[] + +type Vec3 = [number, number, number] + +/** Where the exterior's camera is: kilometres, in the frame the hull turns in. */ +export interface ExteriorShot { + position: Vec3 + /** The point the camera orbits and looks at. */ + target: Vec3 + /** How far round the hull has turned, radians. */ + phase: number + /** + * Vertical field of view, degrees. The view's own is 42 and it has no zoom, so a saved + * shot always says 42; a smaller number typed here frames the shot tighter. + */ + fovDeg: number +} + +/** Where the interior's observer stands and looks. */ +export interface InteriorShot { + theta: number + /** Metres from the sun cap. */ + z: number + /** Eye height above the ground, metres. */ + eyeM: number + /** Heading, radians clockwise from deepward: 0 faces the far cap, pi the sun cap. */ + yaw: number + /** Elevation, radians. */ + pitch: number + fovDeg: number + /** Phase of the sky through the far cap, radians: where the stars have wheeled to. */ + spin: number +} + +interface Common { + /** The file's name, less its extension: lower-case letters, digits and hyphens. */ + id: string + /** Anything worth remembering about the shot. Never read by the tools. */ + note?: string + /** The world clock, hours since day 0 began: the sun, the weather and the ships follow it. */ + hours: number + /** The Light. */ + dial: Dial + /** Width over height of the frame the shot was composed in. */ + aspect: number +} + +export type ExteriorPhoto = Common & ExteriorShot & { view: 'exterior'; hull: HullMode } +export type InteriorPhoto = Common & InteriorShot & { view: 'interior' } +export type Photo = ExteriorPhoto | InteriorPhoto +/** A shot as taken, before it has been given its name. */ +export type Framing = Omit<ExteriorPhoto, 'id'> | Omit<InteriorPhoto, 'id'> + +const ID = /^[a-z0-9]+(-[a-z0-9]+)*$/ + +/** A name as typed, made into an id: "Far cap, from the Capital" is far-cap-from-the-capital. */ +export const photoId = (name: string): string => + name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') + +/** + * One entry of the list, every field checked. Throws with what is wrong: an entry is typed + * by hand as often as it is saved, and the id becomes a file name. + */ +export function parsePhoto(raw: unknown): Photo { + if (typeof raw !== 'object' || raw === null) throw new Error('a photo is an object') + const o = raw as Record<string, unknown> + const id = o['id'] + if (typeof id !== 'string' || !ID.test(id)) { + throw new Error(`a photo's id is lower-case letters, digits and hyphens, not ${JSON.stringify(id)}`) + } + const num = (key: string, lo = -Infinity, hi = Infinity): number => { + const v = o[key] + if (typeof v !== 'number' || !Number.isFinite(v) || v < lo || v > hi) { + throw new Error(`${id}: ${key} is ${JSON.stringify(v)}`) + } + return v + } + const vec = (key: string): Vec3 => { + const v = o[key] + if (!Array.isArray(v) || v.length !== 3 || !v.every((n) => typeof n === 'number' && Number.isFinite(n))) { + throw new Error(`${id}: ${key} is three numbers`) + } + return [v[0], v[1], v[2]] + } + const dial = o['dial'] + if (!isDial(dial)) throw new Error(`${id}: dial is ${JSON.stringify(dial)}`) + const note = o['note'] + if (note !== undefined && typeof note !== 'string') throw new Error(`${id}: note is text`) + const common: Common = { + id, ...(note === undefined ? {} : { note }), + hours: num('hours', 0), dial, aspect: num('aspect', 0.2, 5), + } + if (o['view'] === 'exterior') { + const hull = o['hull'] + if (!HULLS.includes(hull as HullMode)) throw new Error(`${id}: hull is ${JSON.stringify(hull)}`) + return { + ...common, view: 'exterior', hull: hull as HullMode, + position: vec('position'), target: vec('target'), phase: num('phase'), + fovDeg: num('fovDeg', 1, 150), + } + } + if (o['view'] === 'interior') { + return { + ...common, view: 'interior', + theta: num('theta'), z: num('z', 0, LENGTH_M), eyeM: num('eyeM', 0), + yaw: num('yaw'), pitch: num('pitch', -1.55, 1.55), fovDeg: num('fovDeg', 1, 150), + spin: num('spin'), + } + } + throw new Error(`${id}: view is exterior or interior, not ${JSON.stringify(o['view'])}`) +} diff --git a/test/photos.test.ts b/test/photos.test.ts new file mode 100644 index 0000000..ddf96da --- /dev/null +++ b/test/photos.test.ts @@ -0,0 +1,44 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { parsePhoto, photoId } from '../src/world/photos.js' + +const interior = { + id: 'far-cap', view: 'interior', hours: 13, dial: 'temperate', aspect: 1.5, + theta: 0, z: 270_000, eyeM: 1.7, yaw: 0, pitch: 0.2, fovDeg: 60, spin: 0, +} +const exterior = { + id: 'outside', view: 'exterior', hours: 30.5, dial: 'full', aspect: 1.47, hull: 'rock', + position: [160, -360, 780], target: [0, 0, 510], phase: 1.2, fovDeg: 42, +} + +describe('photographs (D-099)', () => { + it('reads a shot of either view back as it was written', () => { + expect(parsePhoto(interior)).toEqual(interior) + expect(parsePhoto(exterior)).toEqual(exterior) + expect(parsePhoto({ ...interior, note: 'the sun just out' }).note).toBe('the sun just out') + }) + + it('says what is wrong with an entry typed by hand', () => { + expect(() => parsePhoto({ ...interior, pitch: 2 })).toThrow('far-cap: pitch') + expect(() => parsePhoto({ ...interior, z: -1 })).toThrow('far-cap: z') + expect(() => parsePhoto({ ...interior, dial: 'bright' })).toThrow('dial') + expect(() => parsePhoto({ ...exterior, hull: 'glass' })).toThrow('hull') + expect(() => parsePhoto({ ...exterior, target: [0, 0] })).toThrow('target') + expect(() => parsePhoto({ ...interior, view: 'map' })).toThrow('view') + expect(() => parsePhoto({ ...interior, aspect: 0 })).toThrow('aspect') + }) + + it('keeps an id fit to be a file name', () => { + expect(photoId(' Far cap, from the Capital! ')).toBe('far-cap-from-the-capital') + expect(photoId('***')).toBe('') + for (const id of ['../world', 'a/b', 'Far Cap', '', 'a--b']) { + expect(() => parsePhoto({ ...interior, id })).toThrow('id') + } + }) + + it('has a list every entry of which can be rendered, under names that do not collide', () => { + const list = JSON.parse(readFileSync('content/photos.json', 'utf8')) as unknown[] + const ids = list.map((p) => parsePhoto(p).id) + expect(new Set(ids).size).toBe(ids.length) + }) +}) diff --git a/tools/browser.mjs b/tools/browser.mjs new file mode 100644 index 0000000..9a7405b --- /dev/null +++ b/tools/browser.mjs @@ -0,0 +1,100 @@ +/** + * What the tools that drive the app in a headless browser share: the screenshot tour + * (screenshots.mjs) and the photographs (photos.mjs). + */ +import { chromium } from 'playwright' + +/** + * Launch the browser that will do the drawing. + * + * `channel: 'chromium'` is the full Chrome for Testing build, which reaches the + * real GPU; the default headless shell falls back to SwiftShader and renders the + * interior's haze integral on the CPU. That is not a small difference — one + * interior shot measured **87.3 s on SwiftShader against 6.4 s on the GPU**, and + * the 180° turn inside it went from 77.7 s to 2.6 s, because every mouse move + * during a drag waits on a frame. + * + * The cost is that the baseline is now tied to this machine's GPU rather than to a + * software renderer that would give the same pixels anywhere. Nothing runs this in + * CI, so that is a fair trade — but it is why `--check` compares against images a + * particular machine produced, and why a different one will show a large one-off + * divergence rather than a regression. + */ +export async function launch() { + try { + return await chromium.launch({ channel: 'chromium' }) + } catch { + console.log(' (no Chrome for Testing build; falling back to the software renderer)') + return await chromium.launch() + } +} + +/** + * Compile the world once, in a page of its own, and return the storage it leaves behind — + * the IndexedDB holding the compiled world — for every later context to start from, so + * none of them compiles at all. localStorage is emptied first: it holds the view and the + * camera, and a page inheriting those would stop being reproducible. + */ +export async function warmWorld(browser, base, params) { + const t0 = Date.now() + const context = await browser.newContext() + const page = await context.newPage() + await page.goto(`${base}/?${params}`, { waitUntil: 'networkidle' }) + await page.waitForFunction(() => window.towerTerrain && !window.towerTerrain.progress.busy + && window.towerTerrain.compiled, null, { timeout: 120_000, polling: 200 }) + // saveCompiled is not awaited by the app; give its transaction a moment to land. + await page.waitForTimeout(500) + await page.evaluate(() => localStorage.clear()) + const storage = await context.storageState({ indexedDB: true }) + await context.close() + console.log(` world compiled once ${((Date.now() - t0) / 1000).toFixed(1)}s`) + return storage +} + +/** + * Insurance rather than a live need: the rain that used to be the world's one + * randomised thing is gone (D-049), and the star field is hashed in GLSL rather + * than sampled. If anything stochastic comes back, the contact sheet stays + * comparable between runs without anyone having to remember why. + */ +export async function seedRandom(context) { + await context.addInitScript(() => { + let seed = 0x2f6e2b1 + Math.random = () => { + seed = (seed * 1664525 + 1013904223) >>> 0 + return seed / 0x100000000 + } + }) +} + +/** + * Wait until the generated world is all there: compiled, the map drawn, and every + * tile of ground the open view wants delivered (world-generation.md §5). Terrain is + * made in the browser now, so a fixed delay would photograph the coarse tiles on a + * fast machine and a half-built world on a slow one. + * + * Checked twice with frames between, because the pool can be idle for a moment + * between one frame asking for tiles and the next asking for their children. + */ +export async function terrainReady(page) { + for (let round = 0; round < 2; round++) { + await page.waitForFunction(() => { + const t = window.towerTerrain + if (!t || t.progress.busy || !t.mapCanvas || t.inFlight > 0) return false + const interiorOpen = !document.getElementById('interior').classList.contains('hidden') + return !interiorOpen || window.tower?.settled === true + }, null, { timeout: 120_000, polling: 100 }) + await page.evaluate(async () => { + for (let i = 0; i < 10; i++) await new Promise((r) => requestAnimationFrame(() => r(null))) + }) + } +} + +/** Flush a few animation frames, then hold still, so a settling view has settled. */ +export async function settle(page, ms = 900) { + await terrainReady(page) + await page.evaluate(async () => { + for (let i = 0; i < 20; i++) await new Promise((r) => requestAnimationFrame(() => r(null))) + }) + await page.waitForTimeout(ms) +} diff --git a/tools/photos.mjs b/tools/photos.mjs new file mode 100644 index 0000000..e38ba09 --- /dev/null +++ b/tools/photos.mjs @@ -0,0 +1,119 @@ +/** + * Photographs. `npm run photos` + * + * Renders every shot in content/photos.json to photos/<id>.png: the view alone — no panel, + * no readout, no labels — at the shape it was composed in, 3840 px along its long edge. + * + * The list is the thing kept; the renders are made from it and are not committed. A shot is + * saved from the running app (P, or the button, at `npm run dev`) or typed by hand, and its + * file name never changes, so rendering again replaces the picture with the latest version + * of the same picture: the world as it is now, drawn as the renderer draws it now. + * + * Unlike the screenshot tour (screenshots.mjs), a photo is posed directly rather than + * through the UI. That tour is a test of the application; this is a camera. + * + * Flags: + * --only <id,id> render just these shots (id prefixes) + * --long-edge <px> the long edge, in pixels (default 3840) + * --url <origin> photograph a server already running instead of starting one + */ +import { mkdir, readFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { createServer } from 'vite' +import { launch, seedRandom, settle, warmWorld } from './browser.mjs' + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const LIST = path.join(ROOT, 'content', 'photos.json') +const OUT = path.join(ROOT, 'photos') + +/** As the screenshot tour asks: two terrain workers, and the compiled world from the cache. */ +const PARAMS = 'workers=2&cache=1' + +/** + * The view and nothing else. The panel's column is given back to the world, and everything + * laid over the canvas — labels, readouts, the gauge, the view buttons — is taken away. + */ +const BARE = ` + #app { grid-template-columns: 1fr !important; } + aside, #panel-toggle, #scrim, main > :not(canvas) { display: none !important; } +` + +const argv = process.argv.slice(2) +const flag = (name) => { + const i = argv.indexOf(name) + return i >= 0 ? argv[i + 1] : undefined +} +const only = (flag('--only') ?? '').split(',').map((s) => s.trim()).filter(Boolean) +const longEdge = Number(flag('--long-edge') ?? 3840) +const givenUrl = flag('--url') + +const list = JSON.parse(await readFile(LIST, 'utf8').catch(() => '[]')) +const photos = list.filter((p) => only.length === 0 || only.some((o) => String(p.id).startsWith(o))) +if (!photos.length) { + console.log(only.length + ? `No shot in content/photos.json matches ${only.join(', ')}.` + : 'content/photos.json has no shots yet: run `npm run dev`, frame one, and press P.') + process.exit(only.length ? 1 : 0) +} + +async function take(browser, base, storage, photo) { + const t0 = Date.now() + /** + * Two device pixels to the CSS pixel, which is the most the views will draw at, and what + * the author's own screen does: the stars and the lines come out the weight they were + * when the shot was framed, rather than half of it across a 3840 px window. + */ + const long = Math.round(longEdge / 2) + const short = Math.round(long / Math.max(photo.aspect, 1 / photo.aspect)) + const viewport = photo.aspect >= 1 ? { width: long, height: short } : { width: short, height: long } + const context = await browser.newContext({ storageState: storage, viewport, deviceScaleFactor: 2 }) + await seedRandom(context) + const page = await context.newPage() + const problems = [] + page.on('pageerror', (e) => problems.push(String(e))) + page.on('console', (m) => { if (m.type() === 'error') problems.push(m.text()) }) + + // The clock is named in the address so the page never opens at the time now, running. + await page.goto(`${base}/?${PARAMS}&clock=${photo.hours}#view=${photo.view}`, { waitUntil: 'networkidle' }) + await page.addStyleTag({ content: BARE }) + await settle(page) + // The app checks every field, and says which is wrong: the list is edited by hand too. + await page.evaluate((p) => window.towerPhotos.show(p), photo) + await settle(page, 1500) + + const file = path.join(OUT, `${photo.id}.png`) + await page.screenshot({ path: file }) + await context.close() + const note = problems.length ? ` ⚠ ${problems.length} console error(s): ${problems[0]}` : '' + console.log(` ${path.relative(ROOT, file)} ${viewport.width * 2}×${viewport.height * 2} ${((Date.now() - t0) / 1000).toFixed(1)}s${note}`) +} + +await mkdir(OUT, { recursive: true }) +const server = givenUrl ? null : await createServer({ root: ROOT, server: { port: 0 } }) +if (server) await server.listen() +const base = givenUrl ?? server.resolvedUrls.local[0].replace(/\/$/, '') +console.log(`\nTower photographs — ${base}\n`) + +let failed = 0 +try { + const browser = await launch() + try { + const storage = await warmWorld(browser, base, `${PARAMS}&clock=12`) + // One at a time: each is a 4K frame on the GPU, and there are only ever a handful. + for (const photo of photos) { + try { + await take(browser, base, storage, photo) + } catch (e) { + failed++ + console.log(` ${photo.id} ✗ ${String(e).split('\n')[0]}`) + } + } + } finally { + await browser.close() + } +} finally { + await server?.close() +} +if (failed) process.exitCode = 1 +console.log(`\nDone: ${photos.length - failed} of ${photos.length} in photos/.\n`) diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs index 0529767..08eaae9 100644 --- a/tools/screenshots.mjs +++ b/tools/screenshots.mjs @@ -28,7 +28,7 @@ import { mkdir, readFile } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' import { createServer } from 'vite' -import { chromium } from 'playwright' +import { launch, seedRandom, settle, warmWorld } from './browser.mjs' const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const OUT = path.join(ROOT, 'docs', 'screenshots') @@ -71,37 +71,6 @@ const PROFILES = [ // --------------------------------------------------------------------- helpers -/** - * Wait until the generated world is all there: compiled, the map drawn, and every - * tile of ground the open view wants delivered (world-generation.md §5). Terrain is - * made in the browser now, so a fixed delay would photograph the coarse tiles on a - * fast machine and a half-built world on a slow one. - * - * Checked twice with frames between, because the pool can be idle for a moment - * between one frame asking for tiles and the next asking for their children. - */ -async function terrainReady(page) { - for (let round = 0; round < 2; round++) { - await page.waitForFunction(() => { - const t = window.towerTerrain - if (!t || t.progress.busy || !t.mapCanvas || t.inFlight > 0) return false - const interiorOpen = !document.getElementById('interior').classList.contains('hidden') - return !interiorOpen || window.tower?.settled === true - }, null, { timeout: 120_000, polling: 100 }) - await page.evaluate(async () => { - for (let i = 0; i < 10; i++) await new Promise((r) => requestAnimationFrame(() => r(null))) - }) - } -} - -/** Flush a few animation frames, then hold still, so a settling view has settled. */ -async function settle(page, ms = 900) { - await terrainReady(page) - await page.evaluate(async () => { - for (let i = 0; i < 20; i++) await new Promise((r) => requestAnimationFrame(() => r(null))) - }) - await page.waitForTimeout(ms) -} /** The canvas box, for aiming clicks and drags at the world rather than the panel. */ async function stage(page) { @@ -440,31 +409,6 @@ const fileFor = (profile, shot) => path.join(OUT, `${profile}-${shot}.png`) // ------------------------------------------------------------------- the tour -/** - * Launch the browser that will do the drawing. - * - * `channel: 'chromium'` is the full Chrome for Testing build, which reaches the - * real GPU; the default headless shell falls back to SwiftShader and renders the - * interior's haze integral on the CPU. That is not a small difference — one - * interior shot measured **87.3 s on SwiftShader against 6.4 s on the GPU**, and - * the 180° turn inside it went from 77.7 s to 2.6 s, because every mouse move - * during a drag waits on a frame. - * - * The cost is that the baseline is now tied to this machine's GPU rather than to a - * software renderer that would give the same pixels anywhere. Nothing runs this in - * CI, so that is a fair trade — but it is why `--check` compares against images a - * particular machine produced, and why a different one will show a large one-off - * divergence rather than a regression. - */ -async function launch() { - try { - return await chromium.launch({ channel: 'chromium' }) - } catch { - console.log(' (no Chrome for Testing build; falling back to the software renderer)') - return await chromium.launch() - } -} - async function capture(browser, base, profile, shot) { const t0 = Date.now() /** @@ -479,25 +423,16 @@ async function capture(browser, base, profile, shot) { isMobile: profile.mobile, hasTouch: profile.mobile, }) - /** - * Insurance rather than a live need: the rain that used to be the world's one - * randomised thing is gone (D-049), and the star field is hashed in GLSL rather - * than sampled. If anything stochastic comes back, the contact sheet stays - * comparable between runs without anyone having to remember why. - */ - await context.addInitScript(() => { - let seed = 0x2f6e2b1 - Math.random = () => { - seed = (seed * 1664525 + 1013904223) >>> 0 - return seed / 0x100000000 - } - }) + await seedRandom(context) const page = await context.newPage() const problems = [] page.on('pageerror', (e) => problems.push(String(e))) page.on('console', (m) => { if (m.type() === 'error') problems.push(m.text()) }) await page.goto(`${base}/${searchFor(shot)}`, { waitUntil: 'networkidle' }) + // The tour runs on a dev server, where the author has a button for saving a photograph + // (D-099). Readers do not, and these are pictures of what readers get. + await page.addStyleTag({ content: '#photo-row { display: none !important; }' }) await settle(page) await shot.run(page) @@ -682,20 +617,7 @@ try { * leaves behind. localStorage is emptied first: it holds the view and the camera, and * a shot inheriting those would stop being reproducible. */ - { - const t0 = Date.now() - const context = await tour.newContext() - const page = await context.newPage() - await page.goto(`${base}/?${TOUR_PARAMS}`, { waitUntil: 'networkidle' }) - await page.waitForFunction(() => window.towerTerrain && !window.towerTerrain.progress.busy - && window.towerTerrain.compiled, null, { timeout: 120_000, polling: 200 }) - // saveCompiled is not awaited by the app; give its transaction a moment to land. - await page.waitForTimeout(500) - await page.evaluate(() => localStorage.clear()) - warmStorage = await context.storageState({ indexedDB: true }) - await context.close() - console.log(` world compiled once for the tour ${((Date.now() - t0) / 1000).toFixed(1)}s`) - } + warmStorage = await warmWorld(tour, base, TOUR_PARAMS) const jobs = [] for (const profile of PROFILES) { for (const shot of SHOTS) { diff --git a/vite.config.ts b/vite.config.ts index 9cb4ae8..21bd64d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,8 @@ -import { readFileSync } from 'node:fs' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' import path from 'node:path' import { type Plugin, defineConfig } from 'vite' import { qrcode } from 'vite-plugin-qrcode' +import { parsePhoto } from './src/world/photos.js' import { parseWorld, toPublicWorld } from './src/world/schema.js' /** @@ -35,6 +36,56 @@ function world(): Plugin { } } +/** + * `content/photos.json` is the list of saved shots (src/world/photos.ts, D-099), and the dev + * server is what keeps it: the app posts a shot here when the author presses P, and reads + * the list back to go to one. Dev only — there is no such thing in the build — and only + * from this machine, because `npm run dev` is open to the whole network and this writes a + * file. Every field is checked first. + */ +const PHOTOS = path.resolve('content/photos.json') + +function photos(): Plugin { + return { + name: 'tower-photos', + configureServer(server) { + server.middlewares.use((req, res, next) => { + if (req.url?.split('?')[0] !== '/__photos') return next() + const say = (status: number, text: string): void => { + res.statusCode = status + res.end(text) + } + const from = req.socket.remoteAddress ?? '' + if (!['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(from)) return say(403, 'shots are saved from the machine the dev server runs on') + if (req.method === 'GET') { + res.setHeader('Content-Type', 'application/json') + res.setHeader('Cache-Control', 'no-store') + return say(200, existsSync(PHOTOS) ? readFileSync(PHOTOS, 'utf8') : '[]') + } + if (req.method !== 'POST') return say(405, 'GET the list, or POST a shot') + let body = '' + req.on('data', (chunk: Buffer) => { body += chunk.toString() }) + req.on('end', () => { + try { + const { photo, replace } = JSON.parse(body) as { photo: unknown; replace?: boolean } + const shot = parsePhoto(photo) + const list = existsSync(PHOTOS) ? (JSON.parse(readFileSync(PHOTOS, 'utf8')) as { id?: string; note?: string }[]) : [] + const at = list.findIndex((p) => p.id === shot.id) + if (at >= 0 && !replace) return say(409, `there is already a shot called ${shot.id}`) + // A re-framed shot keeps its place in the list, and whatever note was written on it. + if (at >= 0) list[at] = { ...shot, ...(list[at]!.note === undefined ? {} : { note: list[at]!.note }) } + else list.push(shot) + writeFileSync(PHOTOS, `${JSON.stringify(list, null, 2)}\n`) + say(200, shot.id) + } catch (e) { + say(400, e instanceof Error ? e.message : String(e)) + } + }) + }) + }, + } +} + /** * Relative asset paths by default, so the same build works wherever Pages puts it. * @@ -53,7 +104,7 @@ export default defineConfig({ base, // `npm run dev` binds to every interface (--host) so a phone on the same Wi-Fi // can open the app; this prints a scannable QR of that network URL alongside it. - plugins: [qrcode(), world()], + plugins: [qrcode(), world(), photos()], // Vite's static directory is `static/`, not the default `public/`, because // GitLab Pages requires its artifact to be a directory named `public` — and // `mv dist public` does not rename when `public/` already exists, it moves dist