fuzzyworld

tower / commit

feat(interior): flight is an autopilot; input turns the head, not the aircraft

41516065b5641e393249b177f074effe73a4f5ed · Pavel Safronov · 2026-09-08 11:26:57 -0700 · parents 1fcef8e63cab

feat(interior): flight is an autopilot; input turns the head, not the aircraft

D-056 flew the camera along the look direction, so dragging steered. That is
the obvious way to build it and the wrong way to use it: hand-flying makes
you navigate a world you came to look at, and every time something is worth
turning to see, turning to see it takes you somewhere else.

The two are separated now. The autopilot flies a course; the drag handlers
only turn your head. Takeoff aims you along the course so you start facing
where you are going, and after that the view is yours.

The course is a helix 18° off the axis. Straight down the axis would be
simpler and would show less — rolling around the world while crossing it is
the only way to see that the sky is ground. It reflects off the caps rather
than stopping, so the flight is genuinely permanent.

It is flown in the unrolled surface rather than in 3D, and that is what
makes it safe instead of carefully guarded: the constraints live in those
coordinates, since the caps are limits on z and theta simply wraps. There is
no collision test anywhere in the mode, because there is nothing it can hit.

Terrain-following is asymmetric on purpose — climb at once, sink slowly. An
autopilot that lags on the way up flies into the mountain; one that drops
into every valley as fast as it climbs reads as a fault rather than flight.

The eye-height slider becomes the altitude instead of being disabled as
D-056 had it. Same control, same 1.7 m to 25 km, and it is the only thing
left to fly with once the course is automatic — disabling it would have left
the mode with no controls at all. The label switches between "Eye height"
and "Altitude" so it is not quietly lying about which it is.

This simplified the view rather than complicating it. basis() had grown a
separate branch for flight with its own radial fallback for the axis, and
standing and flying turn out to differ in exactly one term: what you are
held above. The free 3D position is gone, and with it the fallback and the
ceiling clamp.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
 README.md                                      |  12 +--
 decisions.md                                   |  37 ++++++++
 docs/screenshots/desktop-exterior-true.png     | Bin 507719 -> 506587 bytes
 docs/screenshots/desktop-exterior.png          | Bin 332404 -> 332584 bytes
 docs/screenshots/desktop-interior-dawn.png     | Bin 491728 -> 491609 bytes
 docs/screenshots/desktop-interior-flying.png   | Bin 553298 -> 374442 bytes
 docs/screenshots/desktop-interior-night.png    | Bin 279347 -> 279389 bytes
 docs/screenshots/desktop-interior-overhead.png | Bin 545827 -> 545849 bytes
 docs/screenshots/desktop-interior-suncap.png   | Bin 402798 -> 402816 bytes
 docs/screenshots/desktop-interior.png          | Bin 547467 -> 547461 bytes
 docs/screenshots/mobile-exterior.png           | Bin 81802 -> 81692 bytes
 docs/screenshots/mobile-interior.png           | Bin 172341 -> 172349 bytes
 docs/screenshots/overview.png                  | Bin 1269165 -> 1246968 bytes
 index.html                                     |   2 +-
 src/ui/app.ts                                  |  11 ++-
 src/views/interior.ts                          | 119 ++++++++++++-------------
 tools/screenshots.mjs                          |  10 ++-
 17 files changed, 116 insertions(+), 75 deletions(-)

diff --git a/README.md b/README.md
index aeb019e..1c103b1 100644
--- a/README.md
+++ b/README.md
@@ -239,11 +239,13 @@ 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
-**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.
+**Fly** lets go of the ground altogether — an autopilot on a permanent helix, 18°
+off the axis, crossing the world lengthwise while it rolls around it and reflecting
+off the caps rather than stopping. **Your input turns your head, not the aircraft**,
+so you can watch whatever you like while it flies. The slider becomes the altitude
+it holds, terrain-following underneath: climbing at once, sinking slowly. Gravity
+falls linearly to zero at the axis, so height makes flight *easier* — 0.91 g at
+4.3 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
diff --git a/decisions.md b/decisions.md
index cb7a788..f4f10c9 100644
--- a/decisions.md
+++ b/decisions.md
@@ -1720,3 +1720,40 @@ too, so it widens as you climb.
 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%.
+
+## D-057 — Flight is an autopilot; input turns the head, not the aircraft
+
+D-056 flew the camera along the look direction: dragging steered. That is the
+obvious way to build it and the wrong way to use it. Hand-flying makes you navigate
+a world you came to *look* at — you end up watching your altitude and your heading
+instead of the view, and every time something is worth turning to see, turning to
+see it takes you somewhere else.
+
+So the two are separated. The autopilot flies a course; the drag handlers only turn
+your head. Takeoff aims you along the course so you start out facing where you are
+going, and after that the view is yours.
+
+**The course is a helix, 18° off the axis.** Straight down the axis would be simpler
+and would show less: rolling around the world while crossing it is the only way to
+see that the sky is ground. It reflects off the caps rather than stopping — flip the
+along-axis component, keep the way it rolls — so the flight is genuinely permanent.
+
+**It is flown in the unrolled surface (θ, z), not in 3D**, and that is what makes it
+safe rather than carefully guarded. The constraints live in those coordinates: the
+caps are limits on z and θ simply wraps. There is no collision test anywhere in the
+mode, because there is nothing it can hit.
+
+**Terrain-following is asymmetric on purpose**: climb at once, sink slowly. An
+autopilot that lags on the way up flies into the mountain; one that drops into every
+valley as fast as it climbs reads as a fault rather than as flight.
+
+**The eye-height slider becomes the altitude**, rather than being disabled as D-056
+had it. It is the same control over the same 1.7 m to 25 km, it is the only thing
+left to fly with once the course is automatic, and disabling it would have left the
+mode with no controls at all. The label switches between "Eye height" and "Altitude"
+so it is not quietly lying about which it is.
+
+This also simplified the view rather than complicating it: `basis()` had grown a
+separate branch for flight with its own radial fallback for the axis, and standing
+and flying turn out to differ in exactly one term — what you are held above. The
+free 3D position is gone, and with it the fallback and the ceiling clamp.
diff --git a/docs/screenshots/desktop-exterior-true.png b/docs/screenshots/desktop-exterior-true.png
index 888a6e4..8497f50 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 c617fe4..abf5858 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 c2ef621..42fad3f 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
index 597bf41..21ed2b5 100644
Binary files a/docs/screenshots/desktop-interior-flying.png 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 4eb182b..3466858 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 c538f97..e7822df 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 97cc702..d85eb50 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 0f40dca..a78354a 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 c7b432f..26d9e8a 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 1cc4721..0da77f0 100644
Binary files a/docs/screenshots/mobile-interior.png and b/docs/screenshots/mobile-interior.png differ
diff --git a/docs/screenshots/overview.png b/docs/screenshots/overview.png
index 548d8ef..46de6b0 100644
Binary files a/docs/screenshots/overview.png and b/docs/screenshots/overview.png differ
diff --git a/index.html b/index.html
index 89f11b2..5c61f2a 100644
--- a/index.html
+++ b/index.html
@@ -59,7 +59,7 @@
           zoom, click the hull to drop a point.</p>
       </div>
       <div id="interior-options" class="hidden">
-        <label>Eye height <span id="eye-value" class="dim"></span>
+        <label><span id="eye-label">Eye height</span> <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>
diff --git a/src/ui/app.ts b/src/ui/app.ts
index b978e56..bfa04a3 100644
--- a/src/ui/app.ts
+++ b/src/ui/app.ts
@@ -203,8 +203,13 @@ export class App {
     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
+      /**
+       * The eye-height slider is not disabled while flying — it becomes the
+       * altitude the autopilot holds. Same control, same 1.7 m to 25 km range, and
+       * it is the only thing left for the reader to fly with once the course is
+       * automatic, so taking it away would leave the mode with no controls at all.
+       */
+      $('eye-label').textContent = fly.checked ? 'Altitude' : 'Eye height'
       if (!this.interior) return
       this.interior.flySpeedKmS = v
       this.interior.setFlying(fly.checked)
@@ -336,7 +341,7 @@ export class App {
       $<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
+      $('eye-label').textContent = this.interior.flying ? 'Altitude' : 'Eye height'
       $<HTMLSelectElement>('f-light').disabled = this.interior.cycling
     }
     this.interior.resize()
diff --git a/src/views/interior.ts b/src/views/interior.ts
index 50dad18..09a41ba 100644
--- a/src/views/interior.ts
+++ b/src/views/interior.ts
@@ -581,13 +581,21 @@ export class InteriorView {
   /** 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.
+   * Flight, on autopilot. Where you *look* and where you *go* are separate: the
+   * course is flown for you and the drag handlers only turn your head. That is the
+   * point of the mode — hand-flying makes you navigate a world you came to look at,
+   * and you end up watching your altitude instead of the view.
    */
   flying = false
   flySpeedKmS = 0.6
-  private flyAt = new Vector3()
+  /**
+   * Heading over the unrolled surface: 0 is deepward, π/2 spinward. Deliberately
+   * not axis-aligned, so the course is a long helix — you cross the world lengthwise
+   * *and* roll around it, which is the only way to see that the sky is ground.
+   */
+  private flyCourse = (18 * Math.PI) / 180
+  /** The terrain height the autopilot is currently holding above, smoothed. */
+  private flyGroundKm = 0
   /** Whether the clock is running, and how many world seconds pass per real one. */
   cycling = false
   cycleRate = 900
@@ -870,29 +878,17 @@ 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.
-    const s = R - this.groundKm(this.at) - this.eyeM / KM
+    /**
+     * Standing and flying differ in exactly one term: what you are held above. On
+     * the ground that is the terrain under your feet; in the air it is the
+     * autopilot's smoothed idea of it. Up is toward the axis either way — that is
+     * what up means in here, the direction gravity does not point — so the rest of
+     * the frame is shared rather than duplicated.
+     */
+    const ground = this.flying ? this.flyGroundKm : this.groundKm(this.at)
+    const s = R - ground - this.eyeM / KM
     return {
       up: radial.clone().negate(),
       spinward: new Vector3(-Math.sin(theta), Math.cos(theta), 0),
@@ -902,40 +898,43 @@ 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.
+   * Fly the course. Nothing in here reads `yaw` or `pitch` — that is the whole
+   * decoupling: input turns the head, the autopilot flies the aircraft.
    *
-   * 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.
+   * Worked in the unrolled surface (θ, z) rather than in 3D, because that is where
+   * the constraints live: the caps are limits on z and θ simply wraps. It falls out
+   * that the autopilot cannot fly through the floor or out through an end, with no
+   * collision test anywhere.
    */
   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
+    const d = this.flySpeedKmS * dt
+    let z = this.at.z / KM + Math.cos(this.flyCourse) * d
+    if (z > L - 2 || z < 2) {
+      // Reflect off the cap: flip the along-axis component, keep the way it rolls.
+      this.flyCourse = Math.PI - this.flyCourse
+      z = Math.min(L - 2, Math.max(2, z))
     }
-    // Keep `at` honest: the readout, the labels and the haze all ask where we are.
-    this.at = { theta, z: this.flyAt.z * KM }
+    const theta = normalizeTheta(this.at.theta + (Math.sin(this.flyCourse) * d) / R)
+    this.at = { theta, z: z * KM }
+
+    /**
+     * Terrain-following, asymmetric on purpose: climb at once, sink slowly. An
+     * autopilot that lags on the way up flies into the mountain, and one that drops
+     * into every valley as fast as it climbs reads as a fault rather than as
+     * flight.
+     */
+    const g = this.groundKm(this.at)
+    this.flyGroundKm = Math.max(g, this.flyGroundKm + (g - this.flyGroundKm) * Math.min(1, dt * 0.6))
     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.flyGroundKm = this.groundKm(this.at)
+      // Start out looking where you are going. After that the view is yours.
+      this.yaw = this.flyCourse
+      this.pitch = 0
     }
     this.flying = on
     this.place()
@@ -943,11 +942,8 @@ export class InteriorView {
     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
-  }
+  /** What the autopilot holds: the slider's height, measured over the terrain. */
+  get altitudeM(): number { return this.eyeM }
 
   /** Put the camera where the observer is, pointing where they are looking. */
   private place(): void {
@@ -975,13 +971,9 @@ 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)
-    }
+    // The altitude is the slider's, so arriving somewhere resamples the ground
+    // under the new position and leaves you in the air rather than on it.
+    if (this.flying) this.flyGroundKm = this.groundKm(this.at)
     if (facing) this.yaw = this.headingTo(facing)
     // Arriving somewhere looking at your feet is never what was wanted.
     this.pitch = 0
@@ -1310,7 +1302,7 @@ export class InteriorView {
      * 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 - this.flyGroundKm * KM - this.eyeM
       : 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.
@@ -1324,6 +1316,9 @@ export class InteriorView {
         `ground     ${elevation.toFixed(0)} m above the datum`,
         ...(this.flying
           ? [`altitude   ${m(this.altitudeM)} over the ground, ${this.flySpeedKmS.toFixed(2)} km/s`,
+             `course     ${deg(normalizeTheta(this.flyCourse)).toFixed(0)}° ${
+               ['deepward', 'spinward', 'sunward', 'antispinward'][
+                 Math.round(deg(normalizeTheta(this.flyCourse)) / 90) % 4]!}, on autopilot`,
              `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)}°`,
diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs
index ae1042f..21078a5 100644
--- a/tools/screenshots.mjs
+++ b/tools/screenshots.mjs
@@ -355,14 +355,11 @@ const SHOTS = [
   },
   {
     id: 'interior-flying',
-    caption: 'Interior — flying: 4 km up, off the ground, with the world closing overhead',
+    caption: 'Interior — flying on autopilot at 4 km, looking off the course rather than along it',
     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
@@ -377,6 +374,11 @@ const SHOTS = [
         await page.locator('#f-fly-speed').dispatchEvent('input')
         await page.check('#f-fly')
       })
+      // Look away from the course, which is the whole point of the mode: takeoff
+      // aims you along it, and the drag only turns your head.
+      const s = await stage(page)
+      await drag(page, [s.x + s.w * 0.5, s.y + s.h * 0.38],
+                       [s.x + s.w * 0.5 - 230, s.y + s.h * 0.38 + 70])
       await settle(page, 900)
     },
   },