fuzzyworld

tower / commit

refactor(views): drop the two disagreeing grids, and fix what they hid

04d6779e9a2ba78a9a6b5f390cb2c6824637ba08 · Pavel Safronov · 2026-09-06 21:08:30 -0700 · parents 3b520afd8f74

refactor(views): drop the two disagreeing grids, and fix what they hid

The exterior drew structural ribs every 20 km and the map a graticule every
50 km. Neither was wrong alone, but together they invited a comparison that
does not hold: twenty rings against ten lines, counting nothing in common.
Neither was load-bearing — the map keeps its scale bar, the exterior its
spin readout, and with the terrain drawn the ground carries distance better
than either grid did.

Removing them left a stray hairline across the middle of the map, which the
grid had been hiding. Regions that ring the world were four-cornered
polygons running theta from 0 to 2pi, and both of those normalise to the
same map row, so all four corners landed on one line and the region
collapsed to a one-pixel streak.

Bands are now their own geometry. On a cylinder, "the whole circumference
between two z" is genuinely not a polygon: it has no theta extent to give
because it has all of it. Tower's geography is built from these — D-020 made
the desert ring the world precisely because a band cannot be walked around —
so the map draws it as a full-height rect, hit-testing is a z range, and the
editor reports "rings the world, z 148.0 km to 218.0 km (70.0 km deep)".

Both changes touch the same two view files, so they share a commit; they are
recorded separately as D-026 and D-027.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
 decisions.md          | 41 ++++++++++++++++++++++++
 src/ui/app.ts         |  7 +++-
 src/views/exterior.ts | 19 +++--------
 src/views/map.ts      | 43 +++++++++++++------------
 src/world/schema.ts   | 15 ++++++++-
 static/world.json     | 88 +++++++--------------------------------------------
 test/world.test.ts    | 25 ++++++++++++++-
 tools/terrain.mjs     |  9 ++----
 8 files changed, 128 insertions(+), 119 deletions(-)

diff --git a/decisions.md b/decisions.md
index 2506936..e0126d5 100644
--- a/decisions.md
+++ b/decisions.md
@@ -802,3 +802,44 @@ and reseeding after a local edit returns 23 to 22.
 over shipped content will, sooner or later, win with nothing in it. Precedence rules
 between a cache and its source need a defined answer for the empty case, and a
 manual override for every other case.
+
+---
+
+## D-026 — Two grids that did not agree, both removed
+
+**Removed at the author's request.** The exterior had structural ribs every 20 km;
+the map had a graticule every 50 km. Neither was wrong on its own, but side by side
+they invited a comparison that does not hold — twenty rings against ten lines,
+counting nothing in common.
+
+Both are gone. Neither was load-bearing: the map keeps its scale bar and the
+exterior its spin readout, and with the terrain drawn the ground itself carries the
+sense of distance far better than either grid did. A reference grid that has to be
+mentally reconciled with another reference grid is worse than none.
+
+**Worth generalising:** two independent scales shown in two views of the same object
+must agree or one must go. It is not enough for each to be individually correct.
+
+---
+
+## D-027 — A band is its own geometry, not a polygon
+
+**Fixed.** Regions that ring the world — the desert, the forest, both cap seas — are
+now `{ type: 'band', zFrom, zTo }` rather than four-cornered polygons.
+
+**Found while removing the graticule.** With the grid lines gone, a stray hairline
+was left across the middle of the map. A band written as a polygon runs theta from
+0 to 2pi, and both of those normalise to *the same map row* — so all four corners
+land on one line and the region collapses into a one-pixel streak. It had been
+hiding among the graticule lines the whole time.
+
+**Why a new type rather than a workaround.** On a cylinder, "the whole circumference
+between two z" is a genuinely different shape from a polygon: it has no theta
+extent to give, because it has all of it. Tower's geography is built out of these —
+D-020 made the desert ring the world precisely because a band cannot be walked
+around — so it deserves to be a first-class shape rather than a polygon that happens
+to wrap. The map draws it as a full-height rectangle, hit-testing is a z range, and
+the editor reports it as *rings the world, z 148.0 km to 218.0 km (70.0 km deep)*.
+
+Tests assert the four ringing regions survive as bands through parse and public
+export, and that a band with unusable bounds is rejected like any other geometry.
diff --git a/src/ui/app.ts b/src/ui/app.ts
index a39e644..9a1bd39 100644
--- a/src/ui/app.ts
+++ b/src/ui/app.ts
@@ -219,7 +219,9 @@ export class App {
     const g = p.geometry
     const point = g.type === 'point'
       ? { theta: g.theta, z: g.z }
-      : { theta: g.points[0]![0], z: g.points[0]![1] }
+      : g.type === 'band'
+        ? { theta: 0, z: (g.zFrom + g.zTo) / 2 }
+        : { theta: g.points[0]![0], z: g.points[0]![1] }
     this.map.centreOn(point)
     if (this.view === 'exterior') this.exterior?.lookAt(point)
   }
@@ -612,6 +614,9 @@ export class App {
           : `land strip ${landStripIndex(g.theta)} (${km(landStripWidth())} wide)`
       return `z ${km(g.z)} from the sun cap · ${where}`
     }
+    if (g.type === 'band') {
+      return `rings the world, z ${km(g.zFrom)} to ${km(g.zTo)} (${km(g.zTo - g.zFrom)} deep)`
+    }
     if (g.type === 'polyline') {
       return `${g.points.length} points, ${km(pathLength(g.points))} long`
     }
diff --git a/src/views/exterior.ts b/src/views/exterior.ts
index 6b63a1c..e9a7c2e 100644
--- a/src/views/exterior.ts
+++ b/src/views/exterior.ts
@@ -12,7 +12,7 @@ import {
   CylinderGeometry, DirectionalLight, DoubleSide, Float32BufferAttribute, Group, Mesh,
   MeshBasicMaterial, MeshStandardMaterial, PerspectiveCamera, Points, PointsMaterial,
   Raycaster, RepeatWrapping, RingGeometry, SRGBColorSpace, Scene, SphereGeometry,
-  TextureLoader, TorusGeometry, Vector2, Vector3, WebGLRenderer,
+  TextureLoader, Vector2, Vector3, WebGLRenderer,
 } from 'three'
 import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
 import {
@@ -45,7 +45,6 @@ export class ExteriorView {
   private markers = new Group()
   private hull: Mesh
   private hullMat: MeshStandardMaterial
-  private ribs: Mesh[] = []
   private terrainMap: Texture | null = null
   private _showTerrain = true
   private raycaster = new Raycaster()
@@ -97,16 +96,6 @@ export class ExteriorView {
       color: 0x8892a3, roughness: 0.62, metalness: 0.28, side: DoubleSide,
     })
     const hull = new Mesh(g, this.hullMat)
-
-    // Structural ribs, to give the eye a sense of the 540 km. They read as
-    // gridlines once the terrain is on, which is useful rather than not.
-    const ribMat = new MeshStandardMaterial({ color: 0xaab3c2, roughness: 0.45, metalness: 0.35 })
-    for (let z = 20; z < L; z += 20) {
-      const rib = new Mesh(new TorusGeometry(R * 1.004, 0.4, 6, 96), ribMat)
-      rib.position.z = z
-      hull.add(rib)
-      this.ribs.push(rib)
-    }
     this.loadTerrain()
     return hull
   }
@@ -176,7 +165,6 @@ export class ExteriorView {
     this.hullMat.metalness = on ? 0.06 : 0.28
     this.hullMat.roughness = on ? 0.9 : 0.62
     this.hullMat.needsUpdate = true
-    for (const rib of this.ribs) (rib.material as MeshStandardMaterial).opacity = on ? 0.5 : 1
   }
 
   set showTerrain(v: boolean) {
@@ -277,7 +265,10 @@ export class ExteriorView {
       const g = p.geometry
       const at: SurfacePoint = g.type === 'point'
         ? { theta: g.theta, z: g.z }
-        : { theta: g.points[0]![0], z: g.points[0]![1] }
+        : g.type === 'band'
+          // A band rings the world, so any theta is as good as another.
+          ? { theta: 0, z: (g.zFrom + g.zTo) / 2 }
+          : { theta: g.points[0]![0], z: g.points[0]![1] }
       const selected = p.id === selectedId
       const r = selected ? 6.5 : 4.2
       const dot = new Mesh(
diff --git a/src/views/map.ts b/src/views/map.ts
index 804e9e1..9215500 100644
--- a/src/views/map.ts
+++ b/src/views/map.ts
@@ -213,6 +213,13 @@ export class TowerMap {
       this.ctx.lineWidth = 10
       if (this.ctx.isPointInStroke(px, py)) return p
     }
+    for (let i = this.places.length - 1; i >= 0; i--) {
+      const p = this.places[i]!
+      if (p.geometry.type !== 'band') continue
+      const [x0] = this.worldToScreen(p.geometry.zFrom, 0)
+      const [x1] = this.worldToScreen(p.geometry.zTo, 0)
+      if (px >= x0 && px <= x1) return p
+    }
     for (let i = this.places.length - 1; i >= 0; i--) {
       const p = this.places[i]!
       if (p.geometry.type !== 'polygon') continue
@@ -323,25 +330,6 @@ export class TowerMap {
       }
     }
 
-    // 50 km graticule.
-    ctx.strokeStyle = 'rgba(255,255,255,0.055)'
-    ctx.lineWidth = 1
-    ctx.beginPath()
-    for (let z = 0; z <= LENGTH_M; z += 50_000) {
-      const [sx] = this.worldToScreen(z, 0)
-      ctx.moveTo(sx, y0)
-      ctx.lineTo(sx, y0 + h)
-    }
-    for (let i = 0; i < landStripCount(); i++) {
-      const [, sy] = this.worldToScreen(0, toMap({ theta: landStripCentre(i), z: 0 }).y)
-      ctx.moveTo(x0, sy)
-      ctx.lineTo(x0 + w, sy)
-    }
-    ctx.stroke()
-
-    // Naming each strip is what keeps the wrapped axis legible.
-    ctx.font = '10px ui-monospace, SFMono-Regular, Menlo, monospace'
-    ctx.fillStyle = 'rgba(160,200,170,0.5)'
     if (slatCount() === 0) {
       // The single strip is centred on the map edge, so label it in the middle
       // where it can actually be read.
@@ -429,7 +417,22 @@ export class TowerMap {
       const selected = p.id === this.selectedId
       ctx.globalAlpha = p.public ? 1 : 0.55
 
-      if (p.geometry.type === 'polyline') {
+      if (p.geometry.type === 'band') {
+        const [x0, y0] = this.worldToScreen(p.geometry.zFrom, 0)
+        const [x1] = this.worldToScreen(p.geometry.zTo, 0)
+        const h = CIRCUMFERENCE_M * this.scale
+        const overTerrain = this.showTerrain && this.terrain
+        if (!overTerrain || selected) {
+          ctx.fillStyle = color + (selected ? '2e' : '30')
+          ctx.fillRect(x0, y0, x1 - x0, h)
+        }
+        ctx.strokeStyle = overTerrain && !selected ? color + '66' : color
+        ctx.lineWidth = selected ? 2.5 : 1.5
+        ctx.setLineDash(p.public ? [] : [5, 4])
+        ctx.strokeRect(x0, y0, x1 - x0, h)
+        ctx.setLineDash([])
+        this.label(p.name, (x0 + x1) / 2, y0 + h - 10, color, true)
+      } else if (p.geometry.type === 'polyline') {
         ctx.beginPath()
         this.tracePath(p.geometry.points)
         ctx.strokeStyle = color
diff --git a/src/world/schema.ts b/src/world/schema.ts
index cc0e593..0dc57d3 100644
--- a/src/world/schema.ts
+++ b/src/world/schema.ts
@@ -32,6 +32,13 @@ export type Geometry =
   | { type: 'point'; theta: number; z: number }
   | { type: 'polyline'; points: [number, number][] }
   | { type: 'polygon'; points: [number, number][] }
+  /**
+   * A slice of the whole circumference between two z. Not expressible as a
+   * polygon: theta 0 and theta 2pi normalise to the same map row, so a band
+   * written as four corners collapses into a hairline. Tower's desert and forest
+   * both ring the world, so this is a first-class shape here, not an edge case.
+   */
+  | { type: 'band'; zFrom: number; zTo: number }
 
 export interface Place {
   id: string
@@ -109,11 +116,17 @@ export function parseWorld(raw: unknown): World {
 }
 
 function parseGeometry(g: unknown): Geometry | null {
-  const src = g as { type?: string; theta?: number; z?: number; points?: unknown }
+  const src = g as {
+    type?: string; theta?: number; z?: number; points?: unknown
+    zFrom?: number; zTo?: number
+  }
   if (!src) return null
   if (src.type === 'point' && Number.isFinite(src.theta) && Number.isFinite(src.z)) {
     return { type: 'point', theta: src.theta!, z: src.z! }
   }
+  if (src.type === 'band' && Number.isFinite(src.zFrom) && Number.isFinite(src.zTo)) {
+    return { type: 'band', zFrom: src.zFrom!, zTo: src.zTo! }
+  }
   if ((src.type === 'polygon' || src.type === 'polyline') && Array.isArray(src.points)) {
     const points = src.points
       .filter((pt): pt is [number, number] =>
diff --git a/static/world.json b/static/world.json
index 93d62e2..9ee8dc0 100644
--- a/static/world.json
+++ b/static/world.json
@@ -6,25 +6,9 @@
       "name": "The Sunward Sea",
       "kind": "lake",
       "geometry": {
-        "type": "polygon",
-        "points": [
-          [
-            0,
-            0
-          ],
-          [
-            6.283185306179586,
-            0
-          ],
-          [
-            6.283185306179586,
-            42000
-          ],
-          [
-            0,
-            42000
-          ]
-        ]
+        "type": "band",
+        "zFrom": 0,
+        "zTo": 42000
       },
       "blurb": ""
     },
@@ -33,25 +17,9 @@
       "name": "The Deepward Sea",
       "kind": "lake",
       "geometry": {
-        "type": "polygon",
-        "points": [
-          [
-            0,
-            496000
-          ],
-          [
-            6.283185306179586,
-            496000
-          ],
-          [
-            6.283185306179586,
-            540000
-          ],
-          [
-            0,
-            540000
-          ]
-        ]
+        "type": "band",
+        "zFrom": 496000,
+        "zTo": 540000
       },
       "blurb": ""
     },
@@ -660,25 +628,9 @@
       "name": "The Dry Reach",
       "kind": "desert",
       "geometry": {
-        "type": "polygon",
-        "points": [
-          [
-            0,
-            148000
-          ],
-          [
-            6.283185306179586,
-            148000
-          ],
-          [
-            6.283185306179586,
-            218000
-          ],
-          [
-            0,
-            218000
-          ]
-        ]
+        "type": "band",
+        "zFrom": 148000,
+        "zTo": 218000
       },
       "blurb": ""
     },
@@ -950,25 +902,9 @@
       "name": "Greatwood",
       "kind": "forest",
       "geometry": {
-        "type": "polygon",
-        "points": [
-          [
-            0,
-            214000
-          ],
-          [
-            6.283185306179586,
-            214000
-          ],
-          [
-            6.283185306179586,
-            430000
-          ],
-          [
-            0,
-            430000
-          ]
-        ]
+        "type": "band",
+        "zFrom": 214000,
+        "zTo": 430000
       },
       "blurb": ""
     },
diff --git a/test/world.test.ts b/test/world.test.ts
index 0becafc..69963fc 100644
--- a/test/world.test.ts
+++ b/test/world.test.ts
@@ -80,6 +80,22 @@ describe('geometry kinds', () => {
       { name: 'poly', geometry: { type: 'polygon', points: two } },
     ] }).places.map((p) => p.name)).toEqual(['line'])
   })
+  it('keeps a band as a band, which a polygon cannot express', () => {
+    // theta 0 and theta 2pi normalise to the same map row, so four corners
+    // collapse to a hairline. Tower's desert and forest both ring the world.
+    const w = parseWorld({ places: [
+      { id: 'd', name: 'D', kind: 'desert',
+        geometry: { type: 'band', zFrom: 148000, zTo: 218000 } }] })
+    expect(w.places[0]!.geometry).toEqual({ type: 'band', zFrom: 148000, zTo: 218000 })
+    expect(toPublicWorld(w).places[0]!.geometry).toEqual(
+      { type: 'band', zFrom: 148000, zTo: 218000 })
+  })
+
+  it('rejects a band with unusable bounds', () => {
+    expect(parseWorld({ places: [
+      { name: 'x', geometry: { type: 'band', zFrom: NaN, zTo: 1 } }] }).places).toEqual([])
+  })
+
   it('round-trips a polyline through the public export', () => {
     const pts: [number, number][] = [[0.1, 1000], [0.2, 2000], [0.3, 3000]]
     const w = parseWorld({ places: [
@@ -98,7 +114,7 @@ describe('the seeded world loads without losing anything', () => {
   it('knows every kind and geometry the tool uses', () => {
     for (const p of seed.places) {
       expect(PLACE_KINDS).toContain(p.kind)
-      expect(['point', 'polyline', 'polygon']).toContain(p.geometry.type)
+      expect(['point', 'polyline', 'polygon', 'band']).toContain(p.geometry.type)
     }
   })
   it('keeps the river and the canyon, which are polylines', () => {
@@ -106,6 +122,13 @@ describe('the seeded world loads without losing anything', () => {
     expect(names).toContain('The Long Water')
     expect(names).toContain('The Narrows')
   })
+
+  it('keeps the ringing regions as bands', () => {
+    const byName = new Map(parseWorld(seed).places.map((p) => [p.name, p]))
+    for (const n of ['The Dry Reach', 'Greatwood', 'The Sunward Sea', 'The Deepward Sea']) {
+      expect(byName.get(n)?.geometry.type).toBe('band')
+    }
+  })
 })
 
 describe('the public export is the privacy boundary', () => {
diff --git a/tools/terrain.mjs b/tools/terrain.mjs
index 267a93b..01a425b 100644
--- a/tools/terrain.mjs
+++ b/tools/terrain.mjs
@@ -638,12 +638,9 @@ function toPlaceGeometry(g) {
     return { type: 'polygon', points: [
       [g.thetaFrom, g.zFrom], [g.thetaTo, g.zFrom], [g.thetaTo, g.zTo], [g.thetaFrom, g.zTo]] }
   }
-  // A band rings the world, so as a place it is a polygon spanning all of theta.
-  if (g.type === 'band') {
-    const T = 2 * Math.PI - 1e-9
-    return { type: 'polygon', points: [
-      [0, g.zFrom], [T, g.zFrom], [T, g.zTo], [0, g.zTo]] }
-  }
+  // A band rings the world. As a polygon it would collapse — theta 0 and theta 2pi
+  // land on the same map row — so it stays its own shape all the way through.
+  if (g.type === 'band') return { type: 'band', zFrom: g.zFrom, zTo: g.zTo }
   throw new Error(`cannot convert geometry ${g.type}`)
 }