fuzzyworld

tower / commit

feat(interior): a flying camera, and fix the panel clock that was not told

1fcef8e63cab3af46a62dca808446e204662f19f · Pavel Safronov · 2026-09-08 11:04:46 -0700 · parents 17ccee99b35a

feat(interior): a flying camera, and fix the panel clock that was not told

The Time slider did not move while the cycle ran. The readout was reading
dayPhase off the view every frame while the slider sat wherever it had last
been dragged, so the panel had two clocks in it disagreeing. It follows now,
and is left alone while it has focus so it cannot fight a drag in progress.

Flying replaces the standing observer with a free point inside the shell.
`at` stops being the position and becomes a readout of it.

- Up is still toward the axis at every radius, because that is what up means
  in here: the direction gravity does not point. On the axis the radial
  direction is undefined and the fallback keeps the horizon from tumbling —
  you are weightless there anyway, so there is no physical up to get wrong.
- No throttle and no brake. The mode is flying; the only control is where
  you point, which the existing drag handlers already do.
- Held inside the shell: the caps stop you at both ends, the terrain stops
  you underneath. The floor test samples the height raster rather than the
  datum, so the mountains are solid instead of something you sink through.
- Selecting a place arrives at altitude rather than landing, since landing
  would silently leave the mode you are in.

The readout changes with the mode rather than lying. Eye height means
nothing off the ground, so it gives way to altitude over the terrain and
height above the datum, with gravity computed at the real radius — 0.91 g at
4.5 km up, because g falls linearly to zero at the axis. Flight gets easier
the higher you go, which is true here and nowhere on Earth. The beam's
apparent width measures to the axis as well, so it widens as you climb.

A camera that never stops cannot be photographed twice the same, which the
tour cares about. The shot fixes where the flight starts instead of trying
to stop it: take off from a known 4 km at the slowest 150 m/s with a short
shutter. Drift between runs measures 0.00%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
 README.md                                      |  18 +++--
 decisions.md                                   |  38 +++++++++
 docs/screenshots/desktop-exterior-true.png     | Bin 506644 -> 507719 bytes
 docs/screenshots/desktop-exterior.png          | Bin 332426 -> 332404 bytes
 docs/screenshots/desktop-interior-dawn.png     | Bin 489635 -> 491728 bytes
 docs/screenshots/desktop-interior-flying.png   | Bin 0 -> 553298 bytes
 docs/screenshots/desktop-interior-night.png    | Bin 277170 -> 279347 bytes
 docs/screenshots/desktop-interior-overhead.png | Bin 544181 -> 545827 bytes
 docs/screenshots/desktop-interior-suncap.png   | Bin 401306 -> 402798 bytes
 docs/screenshots/desktop-interior.png          | Bin 545753 -> 547467 bytes
 docs/screenshots/mobile-exterior.png           | Bin 81716 -> 81802 bytes
 docs/screenshots/mobile-interior.png           | Bin 172345 -> 172341 bytes
 docs/screenshots/mobile-panel.png              | Bin 76387 -> 77357 bytes
 docs/screenshots/overview.png                  | Bin 1154824 -> 1269165 bytes
 index.html                                     |   3 +
 src/ui/app.ts                                  |  42 +++++++++-
 src/views/interior.ts                          | 102 ++++++++++++++++++++++++-
 tools/screenshots.mjs                          |  27 +++++++
 18 files changed, 221 insertions(+), 9 deletions(-)

diff --git a/README.md b/README.md
index e3432ab..aeb019e 100644
--- a/README.md
+++ b/README.md
@@ -78,6 +78,7 @@ another machine will show a large one-off divergence rather than a regression.
 | [interior-overhead](docs/screenshots/desktop-interior-overhead.png) | looking up: the far side 100 km overhead is clearer than ground 100 km away |
 | [interior-night](docs/screenshots/desktop-interior-night.png) | the far cap window at night: stars, and the beam banked to a thousandth |
 | [interior-suncap](docs/screenshots/desktop-interior-suncap.png) | the sun cap window: the collector, its sunlit rim, and Tower's clock on its face |
+| [interior-flying](docs/screenshots/desktop-interior-flying.png) | flying — 4 km up, off the ground, with the world closing overhead |
 | [interior-dawn](docs/screenshots/desktop-interior-dawn.png) | dawn — a lit sky over dark ground, the light still 27 km away |
 | [reader](docs/screenshots/desktop-reader.png) | `?reader=1` — terrain, places, a detail card and the probe, no editing |
 
@@ -238,11 +239,18 @@ glows by itself, so a lit beam lights the world whatever the mirrors are doing,
 aiming them away would leave the floor at about 100 W/m² — half of noon.
 
 The eye-height slider runs from 1.7 m to 25 km with the local gravity beside it, and
-**Rain** leans 1.5° antispinward. Nothing *inside* rotates, because you, the ground
-and the air all turn together — so for 539 of the 540 km that 1.5° is the only
-evidence Tower turns at all. The exception is the window: the sky out there does not
-co-rotate, and wheels once every 7 min 29 s about the one motionless star on the
-axis. It is by a wide margin the plainest sign of the spin anywhere in the view, and
+**Fly** lets go of the ground altogether — a camera that is always moving, steered
+by the same drag that turns your head, held inside the shell by the caps at both
+ends and the terrain underneath. There is no brake. Gravity falls linearly to zero
+at the axis, so climbing makes flight *easier*: 0.91 g at 4.5 km up, and the beam
+widens overhead as you close on it.
+
+Nothing *inside* rotates, because you, the ground and the air all turn together — so
+for 539 of the 540 km there is no evidence Tower turns at all. The exception is the
+window: the sky out there does not co-rotate, and wheels once every 7 min 29 s about
+the one motionless star on the axis. Rain used to stand in for that, leaning a
+permanent 1.5°, and is no longer drawn — the lean is real and still computed, but
+1.5° was never a picture of anything. It is by a wide margin the plainest sign of the spin anywhere in the view, and
 it is only available at night, at the deepward end, looking the one direction that
 has ever had anything in it but Tower.
 
diff --git a/decisions.md b/decisions.md
index 98b1cfe..cb7a788 100644
--- a/decisions.md
+++ b/decisions.md
@@ -1682,3 +1682,41 @@ not a defect in the instrument.
 **Not yet in the exterior view**, which has no time of day at all — its collector is
 a flat disc with no clock on it. Giving it one means giving that view the day model
 first, so the two views disagree about the same object until then.
+
+## D-056 — Flying, and the panel clock that was not being told
+
+**Two fixes and a mode.**
+
+The panel's Time slider did not move while the cycle ran. The readout was reading
+`dayPhase` off the view every frame and the slider was sitting wherever it had last
+been dragged, so the panel had two clocks in it disagreeing. It now follows, and is
+left alone while it has focus so it cannot fight a drag in progress.
+
+**Flying** replaces the standing observer with a free point inside the shell. `at`
+stops being the position and starts being a readout of it.
+
+- **Up is still toward the axis**, at every radius, because that is what up *means*
+  in here — the direction gravity does not point. On the axis itself the radial
+  direction is undefined; the fallback keeps the horizon from tumbling, and you are
+  weightless there anyway (§1.2), so there is no physical up to get wrong.
+- **No throttle and no brake.** The mode is *flying*; the only control is where you
+  point, which the existing drag handlers already do. A speed slider sets the cruise
+  and that is all.
+- **Held inside the shell.** The caps stop you at both ends and the terrain stops
+  you underneath — sampled from the height raster rather than the datum, so the
+  mountains are solid rather than something you sink through.
+- **Selecting a place arrives at altitude** rather than landing you on it, since
+  landing would silently leave the mode you are in.
+
+The readout changes with the mode rather than lying: eye height means nothing once
+you are off the ground, so it gives way to altitude over the terrain and height
+above the datum — and the gravity beside it is computed at the real radius, which is
+the interesting part. **0.91 g at 4.5 km up**, because g falls linearly to zero at
+the axis (§1.2). Flight gets easier the higher you go, which is true here and true
+nowhere on Earth. The beam's apparent width now measures from the flier to the axis
+too, so it widens as you climb.
+
+**A camera that never stops cannot be photographed twice the same**, which the
+screenshot tour cares about. The shot fixes where the flight *starts* rather than
+trying to stop it: take off from a known 4 km, cruise at the slowest 150 m/s, and
+keep the shutter short. Drift between runs comes out below the measurement — 0.00%.
diff --git a/docs/screenshots/desktop-exterior-true.png b/docs/screenshots/desktop-exterior-true.png
index 219fcbe..888a6e4 100644
Binary files a/docs/screenshots/desktop-exterior-true.png and b/docs/screenshots/desktop-exterior-true.png differ
diff --git a/docs/screenshots/desktop-exterior.png b/docs/screenshots/desktop-exterior.png
index a4ffec4..c617fe4 100644
Binary files a/docs/screenshots/desktop-exterior.png and b/docs/screenshots/desktop-exterior.png differ
diff --git a/docs/screenshots/desktop-interior-dawn.png b/docs/screenshots/desktop-interior-dawn.png
index 781c0e4..c2ef621 100644
Binary files a/docs/screenshots/desktop-interior-dawn.png and b/docs/screenshots/desktop-interior-dawn.png differ
diff --git a/docs/screenshots/desktop-interior-flying.png b/docs/screenshots/desktop-interior-flying.png
new file mode 100644
index 0000000..597bf41
Binary files /dev/null and b/docs/screenshots/desktop-interior-flying.png differ
diff --git a/docs/screenshots/desktop-interior-night.png b/docs/screenshots/desktop-interior-night.png
index 9b69dbc..4eb182b 100644
Binary files a/docs/screenshots/desktop-interior-night.png and b/docs/screenshots/desktop-interior-night.png differ
diff --git a/docs/screenshots/desktop-interior-overhead.png b/docs/screenshots/desktop-interior-overhead.png
index 26adac4..c538f97 100644
Binary files a/docs/screenshots/desktop-interior-overhead.png and b/docs/screenshots/desktop-interior-overhead.png differ
diff --git a/docs/screenshots/desktop-interior-suncap.png b/docs/screenshots/desktop-interior-suncap.png
index cabb7f4..97cc702 100644
Binary files a/docs/screenshots/desktop-interior-suncap.png and b/docs/screenshots/desktop-interior-suncap.png differ
diff --git a/docs/screenshots/desktop-interior.png b/docs/screenshots/desktop-interior.png
index ae3a000..0f40dca 100644
Binary files a/docs/screenshots/desktop-interior.png and b/docs/screenshots/desktop-interior.png differ
diff --git a/docs/screenshots/mobile-exterior.png b/docs/screenshots/mobile-exterior.png
index 506c583..c7b432f 100644
Binary files a/docs/screenshots/mobile-exterior.png and b/docs/screenshots/mobile-exterior.png differ
diff --git a/docs/screenshots/mobile-interior.png b/docs/screenshots/mobile-interior.png
index ebdac10..1cc4721 100644
Binary files a/docs/screenshots/mobile-interior.png and b/docs/screenshots/mobile-interior.png differ
diff --git a/docs/screenshots/mobile-panel.png b/docs/screenshots/mobile-panel.png
index 7ab5ffd..4d3fc7c 100644
Binary files a/docs/screenshots/mobile-panel.png and b/docs/screenshots/mobile-panel.png differ
diff --git a/docs/screenshots/overview.png b/docs/screenshots/overview.png
index 313871b..548d8ef 100644
Binary files a/docs/screenshots/overview.png and b/docs/screenshots/overview.png differ
diff --git a/index.html b/index.html
index 3d9ae5c..89f11b2 100644
--- a/index.html
+++ b/index.html
@@ -61,6 +61,9 @@
       <div id="interior-options" class="hidden">
         <label>Eye height <span id="eye-value" class="dim"></span>
           <input id="f-eye" type="range" min="0" max="1000" step="1" value="0" /></label>
+        <label class="row"><input id="f-fly" type="checkbox" /> Fly</label>
+        <label>Speed <span id="fly-value" class="dim"></span>
+          <input id="f-fly-speed" type="range" min="0" max="3" step="1" value="1" /></label>
         <label>Time <span id="time-value" class="dim"></span>
           <input id="f-time" type="range" min="0" max="1439" step="1" value="720" /></label>
         <label class="row"><input id="f-cycle" type="checkbox" /> Run the clock</label>
diff --git a/src/ui/app.ts b/src/ui/app.ts
index 2f5b255..b978e56 100644
--- a/src/ui/app.ts
+++ b/src/ui/app.ts
@@ -196,6 +196,23 @@ export class App {
 
     /** World seconds per real second, slowest first: a Tower day in 24 min at 60x. */
     const RATES = [1, 60, 900, 3600, 14400]
+    /** Cruising speeds, slowest first. 0.6 km/s crosses the world in 15 minutes. */
+    const FLY = [0.15, 0.6, 2, 8]
+    const fly = $<HTMLInputElement>('f-fly')
+    const flySpeed = $<HTMLInputElement>('f-fly-speed')
+    const applyFly = () => {
+      const v = FLY[Number(flySpeed.value)]!
+      $('fly-value').textContent = `— ${v} km/s, the world end to end in ${fmtDuration(540 / v)}`
+      // Eye height is ground-relative and means nothing once you leave the ground.
+      $<HTMLInputElement>('f-eye').disabled = fly.checked
+      if (!this.interior) return
+      this.interior.flySpeedKmS = v
+      this.interior.setFlying(fly.checked)
+    }
+    fly.onchange = applyFly
+    flySpeed.oninput = applyFly
+    applyFly()
+
     const time = $<HTMLInputElement>('f-time')
     const showTime = () => {
       const m = Number(time.value)
@@ -318,6 +335,8 @@ export class App {
       eye.dispatchEvent(new Event('input'))
       $<HTMLSelectElement>('f-light').value = this.interior.lighting
       $<HTMLInputElement>('f-cycle').checked = this.interior.cycling
+      $<HTMLInputElement>('f-fly').checked = this.interior.flying
+      $<HTMLInputElement>('f-eye').disabled = this.interior.flying
       $<HTMLSelectElement>('f-light').disabled = this.interior.cycling
     }
     this.interior.resize()
@@ -823,9 +842,30 @@ export class App {
    * up is the one worth printing: it is the crispest far view in Tower (design.md
    * §1.8), and it is the fact readers find hardest to believe.
    */
+  /**
+   * Put the panel's clock where the world's clock actually is.
+   *
+   * The running cycle advances `dayPhase` inside the view, and the readout was
+   * reading it while the slider sat wherever it had last been dragged — two clocks
+   * in the same panel disagreeing. The slider is left alone while it has focus, so
+   * this cannot fight someone mid-drag.
+   */
+  private syncTime(): void {
+    const v = this.interior
+    if (!v) return
+    const t = $<HTMLInputElement>('f-time')
+    if (document.activeElement === t) return
+    const mins = Math.round(((v.dayPhase % 1) + 1) % 1 * 1440) % 1440
+    if (Number(t.value) === mins) return
+    t.value = String(mins)
+    $('time-value').textContent =
+      `— ${String(Math.floor(mins / 60)).padStart(2, '0')}:${String(mins % 60).padStart(2, '0')}`
+  }
+
   private renderStanding(): void {
     const v = this.interior
     if (!v) return
+    this.syncTime()
     const here = v.at
     const opposite = { theta: here.theta + Math.PI, z: here.z }
     const across = sightline(here, opposite)
@@ -843,7 +883,7 @@ export class App {
       && Math.abs(p.geometry.z - here.z) < 2000
       && Math.abs(p.geometry.theta - here.theta) < 0.04)?.name
     $('standing').textContent = [
-      `standing   ${name ?? rep.standing}`,
+      `${rep.verb.padEnd(9)}  ${name ?? rep.standing}`,
       ...(name ? [`           ${rep.standing}`] : []),
       ...rep.lines,
       `overhead   ${km(across.directDistanceM)} up · ${pct(across.transmission)} clear`,
diff --git a/src/views/interior.ts b/src/views/interior.ts
index b200e6a..50dad18 100644
--- a/src/views/interior.ts
+++ b/src/views/interior.ts
@@ -580,6 +580,14 @@ export class InteriorView {
   private spin = 0
   /** Time of day, 0..1. 0 is midnight, 0.5 is noon. */
   dayPhase = 0.5
+  /**
+   * Flight. The observer normally stands on the ground and `at` is the whole of
+   * their position; flying replaces that with a free point inside the shell, so
+   * `flyAt` becomes the truth and `at` follows it rather than the other way round.
+   */
+  flying = false
+  flySpeedKmS = 0.6
+  private flyAt = new Vector3()
   /** Whether the clock is running, and how many world seconds pass per real one. */
   cycling = false
   cycleRate = 900
@@ -862,6 +870,25 @@ export class InteriorView {
 
   /** Basis at the observer: up is toward the axis, deepward is +z, spinward is theta-hat. */
   private basis(): { up: Vector3; spinward: Vector3; deepward: Vector3; eye: Vector3 } {
+    if (this.flying) {
+      /**
+       * Up is still toward the axis, because that is what up *means* in here — it
+       * is the direction gravity does not point, at every radius including this
+       * one. On the axis itself the radial direction is undefined and the fallback
+       * keeps the horizon from tumbling; you are weightless there anyway (§1.2), so
+       * there is no physical up to get wrong.
+       */
+      const rad = Math.hypot(this.flyAt.x, this.flyAt.y)
+      const radial = rad > 1e-4
+        ? new Vector3(this.flyAt.x / rad, this.flyAt.y / rad, 0)
+        : new Vector3(1, 0, 0)
+      return {
+        up: radial.clone().negate(),
+        spinward: new Vector3(-radial.y, radial.x, 0),
+        deepward: new Vector3(0, 0, 1),
+        eye: this.flyAt.clone(),
+      }
+    }
     const { theta } = this.at
     const radial = new Vector3(Math.cos(theta), Math.sin(theta), 0)
     // Up is toward the axis, so standing on high ground means a *smaller* radius.
@@ -874,6 +901,54 @@ export class InteriorView {
     }
   }
 
+  /**
+   * Fly forward. There is no throttle and no brake — the mode is *flying*, so the
+   * only control is where you point, and the drag handlers already do that.
+   *
+   * Held inside the shell rather than allowed to leave it: the caps stop you at
+   * both ends, and the ground stops you underneath. The floor test samples the
+   * terrain rather than using the datum, so the mountains are solid.
+   */
+  private stepFlight(dt: number): void {
+    const { up, spinward, deepward } = this.basis()
+    const dir = deepward.clone().multiplyScalar(Math.cos(this.pitch) * Math.cos(this.yaw))
+      .addScaledVector(spinward, Math.cos(this.pitch) * Math.sin(this.yaw))
+      .addScaledVector(up, Math.sin(this.pitch))
+    this.flyAt.addScaledVector(dir, this.flySpeedKmS * dt)
+
+    this.flyAt.z = Math.min(L - 0.2, Math.max(0.2, this.flyAt.z))
+    const theta = normalizeTheta(Math.atan2(this.flyAt.y, this.flyAt.x))
+    const ceiling = R - this.groundKm({ theta, z: this.flyAt.z * KM }) - 0.05
+    const rad = Math.hypot(this.flyAt.x, this.flyAt.y)
+    if (rad > ceiling && rad > 1e-6) {
+      this.flyAt.x *= ceiling / rad
+      this.flyAt.y *= ceiling / rad
+    }
+    // Keep `at` honest: the readout, the labels and the haze all ask where we are.
+    this.at = { theta, z: this.flyAt.z * KM }
+    this.place()
+    this.cb.onLook()
+  }
+
+  setFlying(on: boolean): void {
+    if (on && !this.flying) {
+      // Take off from wherever the observer is standing, so the mode change does
+      // not also teleport them.
+      const { eye } = this.basis()
+      this.flyAt.copy(eye)
+    }
+    this.flying = on
+    this.place()
+    this.cb.onLook()
+    this.queueSave()
+  }
+
+  /** Height above the terrain directly below, in metres. Only meaningful flying. */
+  get altitudeM(): number {
+    const rad = Math.hypot(this.flyAt.x, this.flyAt.y)
+    return (R - this.groundKm(this.at) - rad) * KM
+  }
+
   /** Put the camera where the observer is, pointing where they are looking. */
   private place(): void {
     const { up, spinward, deepward, eye } = this.basis()
@@ -900,6 +975,13 @@ export class InteriorView {
    */
   visit(p: SurfacePoint, facing: SurfacePoint | null = null): void {
     this.at = { theta: normalizeTheta(p.theta), z: Math.min(LENGTH_M, Math.max(0, p.z)) }
+    if (this.flying) {
+      // Arrive at the height you were flying at, not on the ground.
+      const alt = Math.max(0.2, this.altitudeM / KM)
+      const rad = R - this.groundKm(this.at) - alt
+      this.flyAt.set(
+        Math.cos(this.at.theta) * rad, Math.sin(this.at.theta) * rad, this.at.z / KM)
+    }
     if (facing) this.yaw = this.headingTo(facing)
     // Arriving somewhere looking at your feet is never what was wanted.
     this.pitch = 0
@@ -1215,22 +1297,35 @@ export class InteriorView {
   // -------------------------------------------------------------------- readout
 
   /** What the instruments say from here. Rendered by the app into the overlay. */
-  report(): { standing: string; lines: string[] } {
+  report(): { standing: string; lines: string[]; verb: string } {
     const deg = (r: number) => ((r * 180) / Math.PI)
     const heading = normalizeTheta(this.yaw)
     const compass = ['deepward', 'spinward', 'sunward', 'antispinward'][
       Math.round(deg(heading) / 90) % 4
     ]!
     const elevation = this.elevationM
-    const overhead = RADIUS_M - elevation - this.eyeM
+    const m = (v: number) => (v < KM ? `${v.toFixed(0)} m` : `${(v / KM).toFixed(1)} km`)
+    /**
+     * How far the axis is, which is what sets the beam's apparent width — and while
+     * flying that is the distance from the flier to the axis, not from the ground.
+     */
+    const overhead = this.flying
+      ? Math.hypot(this.flyAt.x, this.flyAt.y) * KM
+      : RADIUS_M - elevation - this.eyeM
     const beamAngle = 2 * Math.atan(BEAM_RADIUS_M / overhead)
+    // Height above the datum, which is what gravity actually falls off with.
+    const aboveDatum = this.flying ? RADIUS_M - overhead : elevation + this.eyeM
     const eyeText = this.eyeM < KM
       ? `${this.eyeM.toFixed(1)} m` : `${(this.eyeM / KM).toFixed(1)} km`
     return {
+      verb: this.flying ? 'flying' : 'standing',
       standing: `θ ${deg(this.at.theta).toFixed(1)}°  z ${(this.at.z / KM).toFixed(1)} km`,
       lines: [
         `ground     ${elevation.toFixed(0)} m above the datum`,
-        `eye        ${eyeText} · ${(gravityAtHeight(elevation + this.eyeM) / G0).toFixed(4)} g`,
+        ...(this.flying
+          ? [`altitude   ${m(this.altitudeM)} over the ground, ${this.flySpeedKmS.toFixed(2)} km/s`,
+             `height     ${m(aboveDatum)} · ${(gravityAtHeight(aboveDatum) / G0).toFixed(4)} g`]
+          : [`eye        ${eyeText} · ${(gravityAtHeight(aboveDatum) / G0).toFixed(4)} g`]),
         `looking    ${deg(heading).toFixed(0)}° ${compass}, ${deg(this.pitch) >= 0 ? '+' : ''}${deg(this.pitch).toFixed(0)}°`,
         `field      ${this.fovDeg.toFixed(0)}°`,
         `beam       ${deg(beamAngle).toFixed(2)}° wide overhead`,
@@ -1270,6 +1365,7 @@ export class InteriorView {
         this.applyLighting()
         this.cb.onLook()
       }
+      if (this.flying) this.stepFlight(dt)
       this.spin = (this.spin + OMEGA_RAD_S * dt) % (2 * Math.PI)
       for (const m of this.shared) m.uniforms['uSpin']!.value = this.spin
       this.renderer.render(this.scene, this.camera)
diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs
index 4499d73..ae1042f 100644
--- a/tools/screenshots.mjs
+++ b/tools/screenshots.mjs
@@ -353,6 +353,33 @@ const SHOTS = [
       await settle(page, 1600)
     },
   },
+  {
+    id: 'interior-flying',
+    caption: 'Interior — flying: 4 km up, off the ground, with the world closing overhead',
+    on: ['desktop'],
+    run: async (page) => {
+      await openView(page, 'interior')
+      await pick(page, 'The Capital')
+      const s = await stage(page)
+      // Pitch up before taking off, so the nose is above the horizon on the way.
+      await drag(page, [s.x + s.w * 0.5, s.y + s.h * 0.34], [s.x + s.w * 0.5, s.y + s.h * 0.34 + 90])
+      await withPanel(page, async () => {
+        /**
+         * Take off from a known height rather than climbing to one. A flying camera
+         * never stops, so the only way to photograph it twice the same is to fix
+         * where the flight *starts* and keep the shutter short — 809 on the
+         * logarithmic eye-height slider is 4 km, and the slowest cruise covers
+         * 150 m a second, so the drift between runs is metres out of kilometres.
+         */
+        await page.locator('#f-eye').fill('809')
+        await page.locator('#f-eye').dispatchEvent('input')
+        await page.locator('#f-fly-speed').fill('0')
+        await page.locator('#f-fly-speed').dispatchEvent('input')
+        await page.check('#f-fly')
+      })
+      await settle(page, 900)
+    },
+  },
   {
     id: 'reader',
     caption: 'Reader mode (?reader=1) — terrain, places, a detail card and the probe, no editing',