fuzzyworld

tower / commit

feat(interior): one finger looks, two fingers move

8cb6755f479c128727a02c5edbc0de84d2465cd7 · Pavel Safronov · 2026-09-08 23:27:06 -0700 · parents 6f10dcda342c

Claude Code usage: this commit ~87.0K out / ~6.0M cached · cost unknown (no per-model trailer) · session 1 tally 200 in / 100.0K out / 7.3M cached · usage by commit and session

feat(interior): one finger looks, two fingers move

Standing inside Tower could not be walked through. Dragging looked around,
the wheel zoomed, and the only way to change where you stood was to select
a place and be walked there (D-038) — fine for the places that exist and
useless for the ground between them. On a phone it left the whole view as
something to be turned on the spot.

The finger count is the mode, so nothing is drawn on the world. One pointer
looks, two move the observer and pinch the field of view. A Look/Move toggle
in the corner was the other candidate and it is honest, but it is a second
pill bar over a canvas already carrying the view switcher, the standing
readout and the place captions, and on a phone that is the scarcest surface
in the application (D-043). A mouse has only ever one pointer, so shift-drag
or the middle button stands in for the second finger; without that the
feature would exist only on touchscreens, which is not what was wrong.

Moving means grabbing the ground rather than steering. The ray through the
pointer is met with the ground plane before the move and after it, and the
observer is shifted by the difference, so the land under the fingers stays
under them. The point of doing it this way is that no speed has to be
chosen: it falls out of the eye height and the field of view, and the tilt
of your head becomes the gearing for free — level, a screen of drag walks
you 1.9 km; tipped 23 degrees down it steps 26 m.

Two caps stop it flinging where the horizon ray runs off to infinity: 1,200
eye heights, and 60 km outright. Both measured on the running view at five
eye heights, because the first pass capped at 150 and a screen of drag moved
four metres. The grab plane is flat on purpose — meeting the terrain would
make the point jump as the ray crossed a ridge half a screen away and throw
the observer with it.

Verified through CDP touch events on a mobile profile: one finger changed
heading and moved position by 0.00 m, two fingers moved with the heading
held, pinch took the field from 60 to 23.7 degrees, and shift-drag moved
9.2 km with the heading held.

The cost is discoverability, and it is paid in the panel: a gesture with no
chrome is a gesture nobody finds, so the interior hint now leads with it.

Usage: 0.2K in / 100.0K out / 7.3M cached (session tally)
rtk:   3.3M saved, 38.2% over 3357 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
 README.md             |  13 +++--
 decisions.md          |  63 ++++++++++++++++++++++
 index.html            |   4 +-
 src/views/interior.ts | 144 +++++++++++++++++++++++++++++++++++++++++++++-----
 4 files changed, 207 insertions(+), 17 deletions(-)

diff --git a/README.md b/README.md
index 9b8d9dd..5a17d21 100644
--- a/README.md
+++ b/README.md
@@ -226,9 +226,16 @@ every pixel, which reproduces the strangest fact about the place: the land 100 k
 straight up is **78%** clear while the ground 100 km away is **31%** — distant
 geography here is best seen by looking up.
 
-Drag to look, scroll to zoom in like a pair of binoculars, and select a place — in
-the list or by its caption — to **walk there**. Most of the world is beyond the haze
-horizon from any given spot, so travelling is more use than turning.
+**One finger looks and two fingers move** (D-059). Dragging with two takes hold of
+the ground under them and keeps it under them, so the distance covered comes out
+right by itself and the tilt of your head is the gearing: level, a screen of drag
+walks you 1.9 km, and tipped 23° down it steps 26 m. The same two fingers pinch the
+field of view like a pair of binoculars. A
+mouse has only ever one pointer, so shift-drag or the middle button stands in for
+the second finger, and the wheel zooms. Selecting a place — in the list or by its
+caption — still **walks you there**, which is the only way to cross the world in
+one move: most of it is beyond the haze horizon from any given spot, so travelling
+is more use than turning.
 
 **The far cap is a window** — 100 km of it, looking into deep anti-sun space. By day
 it is simply a disc of blue, because 540 km of air in front of it scatters more
diff --git a/decisions.md b/decisions.md
index 4698123..f85d4d7 100644
--- a/decisions.md
+++ b/decisions.md
@@ -1799,3 +1799,66 @@ threshold that caught the fault would have fired every time anyone looked at the
 horizon. The recorder therefore records and the human decides; a quarter-minute of
 history is long enough that noticing the fault, reaching for the console and typing
 still catches the frames on both sides of the change.
+
+## D-059 — Inside, the finger count is the mode
+
+Until now the interior could not be moved through at all. Dragging looked around,
+the wheel zoomed, and the only way to change where you stood was to select a place
+and be walked there (D-038). That is fine for the places that exist and useless for
+the ground between them, and on a phone it left the whole view as something to be
+turned on the spot.
+
+**The gesture had to carry the mode, because a switch would have to live on the
+world.** The alternatives were considered and dropped. A Look/Move toggle in the
+corner is the CAD convention and it is honest, but it is a second pill bar over a
+canvas that is already carrying the view switcher, the standing readout and the
+place captions, and on a phone that is the scarcest surface in the application
+(D-043). A virtual joystick is the mobile-FPS convention and reads as a game rather
+than a map. So: **one pointer looks, two move.** The count is the mode, nothing is
+drawn, and the gesture is the one every map on the device already uses.
+
+**The cost is discoverability, and it is paid in the panel.** A gesture with no
+chrome is a gesture nobody finds, so the interior hint now leads with it in bold
+rather than mentioning it in passing. This is the known trade and it was taken
+deliberately.
+
+**A mouse has only ever one pointer**, so shift-drag or the middle button stands in
+for the second finger. Without that the feature would exist only on touchscreens,
+which is not what was wrong.
+
+**Moving means grabbing the ground, not steering.** The ray through the pointer is
+met with the ground plane before the move and after it, and the observer is shifted
+by the difference — so the patch of land under the finger stays under the finger.
+The point of doing it this way is that no speed has to be chosen: the distance
+covered falls out of the eye height and the field of view, and the same drag is a
+few metres of pavement at 1.7 m and a hundred kilometres of plain from 10 km up.
+Tower is 540 km deep and 314 km around, and no fixed rate is right at both ends of
+that.
+
+**Two caps keep it from flinging.** A ray at the horizon meets the ground at
+infinity and one above it never does, so the grab distance is capped at 1,200 eye
+heights and at 60 km outright. Both numbers were measured rather than guessed, by
+dragging the running view a screen at a time at five eye heights: a screen of level
+drag carries 1.9 km at a standing 1.7 m, and 56 km from 100 m up, where the ceiling
+has taken over. The ceiling sits above the honest grab distance from the highest
+the eye goes — 25 km up, tilted well down, meets the ground about 60 km out — so it
+only ever bites near the skyline and never lags the finger on a view aimed at the
+ground. A first pass capped at 150 eye heights and a screen of drag moved four
+metres, which is how the numbers came to be measured.
+
+**The tilt is the gearing, and it was not arranged.** Looking level grabs the far
+ground and covers kilometres; looking down grabs what is underfoot and covers
+metres — 26 m to the screen at standing height, tilted 23° down. That falls out of
+grabbing the ground rather than being a control, which is the argument for the
+whole approach: no speed had to be chosen and none can be got wrong.
+
+**The grab plane is flat, deliberately.** Meeting the ray with the terrain instead
+would make the grab point jump as the ray crossed a ridge half a screen away, and
+throw the observer with it. The plane through the observer's feet keeps the drag
+smooth, and the height they actually stand at is resolved afterwards, as it always
+was.
+
+**Both readings of the pointer set are taken over the same set.** The centroid is
+sampled before the moving pointer is folded in and after, never across a finger
+arriving or leaving — otherwise the second finger touching down would jerk the
+world by half the distance between the two.
diff --git a/index.html b/index.html
index 3d9ae5c..d704ba3 100644
--- a/index.html
+++ b/index.html
@@ -74,7 +74,9 @@
             <option value="temperate" selected>Temperate — 200 W/m²</option>
             <option value="full">Full — 319 W/m²</option>
           </select></label>
-        <p class="hint">Drag to look, scroll to zoom in like a pair of binoculars.
+        <p class="hint"><b>One finger looks. Two fingers move</b> — drag with two
+          and the ground follows them, pinch to zoom in like a pair of binoculars.
+          With a mouse: drag to look, shift-drag to move, scroll to zoom.
           Selecting a place walks you there. Nothing inside here rotates: you, the
           ground and the air all turn together. The far cap is a window, so at
           night the sky through it wheels once every 7 min 29 s about a single
diff --git a/src/views/interior.ts b/src/views/interior.ts
index ee834fe..fe749e8 100644
--- a/src/views/interior.ts
+++ b/src/views/interior.ts
@@ -112,6 +112,28 @@ export type Lighting = keyof typeof LIGHTING
 export const isLighting = (v: unknown): v is Lighting =>
   typeof v === 'string' && v in LIGHTING
 
+/**
+ * How far a two-finger drag can reach, as a multiple of eye height, and as a
+ * hard ceiling in kilometres.
+ *
+ * The first cap is what makes the gesture stable near the skyline, where the grab
+ * point runs away to infinity. Both were measured rather than guessed, by dragging
+ * the running view a screen at a time at five eye heights. What matters is how far
+ * a screen of drag carries when looking level, which is when the cap governs: at a
+ * standing 1.7 m that is about 1.9 km, walking pace made useful; from 100 m up the
+ * ceiling takes over at some 56 km. The ceiling is set above the honest grab
+ * distance from the highest the eye goes — 25 km up, tilted well down, meets the
+ * ground around 60 km out — so that it only ever bites near the skyline and never
+ * lags the finger on a view that is aimed at the ground.
+ *
+ * Tilt is the gearing, and that falls out rather than being arranged: looking level
+ * grabs the far ground and covers kilometres, looking down grabs what is underfoot
+ * and covers metres — 26 m to the screen at standing height, tilted 23° down. It is
+ * how every map behaves and it wants no control of its own.
+ */
+const GRAB_REACH = 1200
+const GRAB_MAX_KM = 60
+
 const STATE_KEY = 'tower.interior'
 const STATE_SAVE_MS = 400
 
@@ -608,8 +630,14 @@ export class InteriorView {
   private shared: ShaderMaterial[] = []
   private running = false
   private lastFrame = 0
-  private dragging = false
-  private lastPointer = { x: 0, y: 0 }
+  /**
+   * Every pointer currently down on the canvas, in client coordinates.
+   *
+   * A count rather than a mode is what decides the gesture (D-059): one pointer
+   * looks, two move the observer over the ground and pinch the field of view. So
+   * the whole set has to be tracked, not just the last one to move.
+   */
+  private pointers = new Map<number, { x: number; y: number }>()
   private saveTimer: ReturnType<typeof setTimeout> | null = null
   /**
    * The flight recorder (D-058). A ring of what the last few seconds looked like,
@@ -1247,27 +1275,117 @@ export class InteriorView {
 
   // --------------------------------------------------------------- interaction
 
+  /** The mean of the live pointers, and the span between the first two. */
+  private gesture(): { x: number; y: number; span: number } {
+    let x = 0
+    let y = 0
+    for (const p of this.pointers.values()) { x += p.x; y += p.y }
+    const n = Math.max(1, this.pointers.size)
+    const [a, b] = [...this.pointers.values()]
+    return { x: x / n, y: y / n, span: a && b ? Math.hypot(a.x - b.x, a.y - b.y) : 0 }
+  }
+
+  /**
+   * Turn on the spot. `dx`/`dy` are pixels of pointer travel.
+   */
+  private look(dx: number, dy: number): void {
+    // Scale with the field of view, so zoomed in does not mean flung around.
+    const rate = (this.fovDeg / 60) * 0.0032
+    this.yaw = normalizeTheta(this.yaw + dx * rate)
+    this.pitch = Math.max(-1.55, Math.min(1.55, this.pitch + dy * rate))
+  }
+
+  /**
+   * Where the ray through a pixel meets the ground, as an offset from the
+   * observer in metres along the axis and metres around it.
+   *
+   * The ground is taken as the plane through the observer's feet rather than as
+   * the terrain, because this answers a question about the gesture and not about
+   * the world: the hills under the far half of the screen would make the grab
+   * point jump about as the ray crossed a ridge, and the observer would be flung
+   * with it. A plane keeps the drag smooth, and the height they end up standing
+   * at is resolved by `place()` afterwards anyway.
+   */
+  private groundOffset(clientX: number, clientY: number): { along: number; around: number } {
+    const r = this.canvas.getBoundingClientRect()
+    const dir = new Vector3(
+      ((clientX - r.left) / r.width) * 2 - 1,
+      -((clientY - r.top) / r.height) * 2 + 1,
+      0.5,
+    ).unproject(this.camera).sub(this.camera.position).normalize()
+
+    const { up, spinward, deepward } = this.basis()
+    /**
+     * How far below the horizontal the ray runs. Floored, because a ray at the
+     * horizon meets the ground at infinity and one above it never does: without
+     * the floor a pixel of drag near the skyline would throw the observer across
+     * a continent. The floor is what caps the reach at `GRAB_REACH` eye heights.
+     */
+    const down = Math.max(-dir.dot(up), 1 / GRAB_REACH)
+    const t = Math.min((this.eyeM / KM) / down, GRAB_MAX_KM)
+    const v = dir.multiplyScalar(t)
+    return { along: v.dot(deepward) * KM, around: v.dot(spinward) * KM }
+  }
+
+  /**
+   * Move so that the ground under the pointer stays under the pointer.
+   *
+   * The grab point is worked out for where the pointer was and for where it now
+   * is, both from the observer's present position, and the observer is shifted by
+   * the difference — so the world is dragged rather than the observer steered,
+   * and the distance covered comes out right by itself at every eye height and
+   * field of view, from a metre of pavement to a hundred kilometres of plain.
+   */
+  private moveOver(from: { x: number; y: number }, to: { x: number; y: number }): void {
+    const a = this.groundOffset(from.x, from.y)
+    const b = this.groundOffset(to.x, to.y)
+    // Radius at the observer's feet: an arc of ground is r * dTheta.
+    const radius = Math.max(1, RADIUS_M - this.elevationM)
+    this.at = {
+      theta: normalizeTheta(this.at.theta + (a.around - b.around) / radius),
+      z: Math.min(LENGTH_M, Math.max(0, this.at.z + (a.along - b.along))),
+    }
+  }
+
+  private zoom(factor: number): void {
+    this.fovDeg = Math.max(3, Math.min(90, this.fovDeg * factor))
+  }
+
+  /**
+   * One pointer looks, two move — the count is the mode, so there is no switch to
+   * find and nothing on screen to get in the way of the view (D-059). A mouse has
+   * only ever one pointer, so shift or the middle button stands in for the second
+   * finger.
+   */
   private attach(): void {
     this.canvas.addEventListener('pointerdown', (e) => {
-      this.dragging = true
-      this.lastPointer = { x: e.clientX, y: e.clientY }
+      this.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
       this.canvas.setPointerCapture(e.pointerId)
     })
     this.canvas.addEventListener('pointermove', (e) => {
-      if (!this.dragging) return
-      const dx = e.clientX - this.lastPointer.x
-      const dy = e.clientY - this.lastPointer.y
-      this.lastPointer = { x: e.clientX, y: e.clientY }
-      // Scale with the field of view, so zoomed in does not mean flung around.
-      const rate = (this.fovDeg / 60) * 0.0032
-      this.yaw = normalizeTheta(this.yaw + dx * rate)
-      this.pitch = Math.max(-1.55, Math.min(1.55, this.pitch + dy * rate))
+      if (!this.pointers.has(e.pointerId)) return
+      /**
+       * Both readings are taken over the same set of pointers, one before this
+       * event is folded in and one after, so a finger arriving or leaving never
+       * shows up as travel. Otherwise the second finger touching down would jerk
+       * the world by half the distance between the two.
+       */
+      const before = this.gesture()
+      this.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
+      const after = this.gesture()
+
+      if (this.pointers.size === 1 && !e.shiftKey && (e.buttons & 4) === 0) {
+        this.look(after.x - before.x, after.y - before.y)
+      } else {
+        this.moveOver(before, after)
+        if (before.span > 0 && after.span > 0) this.zoom(before.span / after.span)
+      }
       this.place()
       this.cb.onLook()
       this.queueSave()
     })
     const end = (e: PointerEvent) => {
-      this.dragging = false
+      this.pointers.delete(e.pointerId)
       this.canvas.releasePointerCapture?.(e.pointerId)
     }
     this.canvas.addEventListener('pointerup', end)