fuzzyworld

tower / commit

feat(photos): save a view as numbers, go back to it, render it again at 4K (D-099)

3cc2b5bd1fc5efe88a7308a8f9f224023028d273 · Pavel Safronov · 2026-10-03 22:28:02 -0700 · parents 7a5edd5f1cb7

Claude Code usage: this commit ~221.4K out / ~27.8M cached (first commit of its session: everything since it began) · cost unknown (no per-model trailer) · session 6 tally 340 in / 221.4K out / 27.8M cached · usage by commit and session

feat(photos): save a view as numbers, go back to it, render it again at 4K (D-099)

A shot of the exterior or the interior is saved into content/photos.json
with P or "Save this shot" — on a dev server only, and only from the
machine it runs on — and `npm run photos` renders every one to
photos/<name>.png: the view alone, no panel, readouts or labels, 3840 px
on its long edge at the shape it was framed in. Same name, same file, so
a render is always the latest version of the picture. "Saved shots"
lists them and goes back to one, clock and spin stopped, to adjust and
re-frame. A held render stands still, so an entry renders the same twice.

The list starts with the two caps from over the Capital at 08:45, and
the blog illustration worked back from its JPEG (camera and field fitted
to the cap, collector and sun; day from the stars, hour from the rod).

The screenshot tour and the photo tool share tools/browser.mjs; the tour
hides the save button, since it photographs what readers get.

Usage: 340 in / 221.4K out / 27.8M cached (session tally)
rtk:   1.9M saved, 27.3% over 3203 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 .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