fuzzyworld

tower / commit

feat(views): give the end caps a pattern

c44860b9ba2cc8adc05733070a81a3b0db48e69b · Pavel Safronov · 2026-09-06 23:28:45 -0700 · parents 5a57ebae60ab

feat(views): give the end caps a pattern

The caps were flat tinted discs. They are the only surfaces in Tower that are not
landscape — 100 km of structure closing each end — and blank they read as fog with
an edge. A checker also makes them the one object in the world with a known cell
size, so a glance at the far cap tells you roughly how far off it is, in a world
where design.md §1.8 says distance is otherwise unreadable.

Polar rather than square: a cap is a disc on a spinning cylinder, so its natural
coordinates are radius and angle, and a square grid meets the rim at every possible
angle and reads as something laid over the geometry. Cells stay roughly square by
doubling the sector count each time the radius doubles, which leaves a seam at each
doubling — what a segmented diaphragm would have anyway.

Drawn once into a canvas and shared, so the exterior and interior views cannot
drift apart. Three maps UVs across the bounding box for both CircleGeometry and
RingGeometry, so the same image lands correctly on the far cap's full disc and on
the sun cap's annulus.

Two things had to be walked back after looking at it. It must be mipmapped: a
high-contrast geometric pattern is the worst case for point sampling, and minified
without mipmaps the far cap moiréd into a dark cross-hatch that read as damage.
And six rings, not ten: finer cells looked better face-on and collapsed at the
grazing angle the caps are actually framed at, so the pattern had to be sized for
its worst viewing angle rather than its best.

The cap framing moves to ~40° off the axis rather than ~17°, since there is now
something on the face worth turning to see, and the caps carry an emissive copy of
the pattern for the same reason the hull carries one of the terrain: sunlight runs
along the axis, so a cap is edge-on to every real light and renders as a dark disc
exactly when it is being looked at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
 src/views/cap-pattern.ts | 109 +++++++++++++++++++++++++++++++++++++++++++++++
 src/views/exterior.ts    |  30 ++++++++++---
 2 files changed, 132 insertions(+), 7 deletions(-)

diff --git a/src/views/cap-pattern.ts b/src/views/cap-pattern.ts
new file mode 100644
index 0000000..0e64a09
--- /dev/null
+++ b/src/views/cap-pattern.ts
@@ -0,0 +1,109 @@
+/**
+ * The pattern on the end caps, drawn once and shared by both 3D views.
+ *
+ * The caps are the only surfaces in Tower that are not landscape: 100 km discs of
+ * structure closing each end. Blank, they read as fog with an edge. Checkered, they
+ * read as built — and they become the one thing in the world with a known scale, so
+ * a glance at the far cap tells you how far away it is.
+ *
+ * The checker is **polar**, not square. A cap is a disc on a spinning cylinder, so
+ * its natural coordinates are radius and angle; a square grid would meet the rim at
+ * every possible angle and look laid over the geometry rather than part of it.
+ *
+ * Cells are kept roughly square by doubling the number of sectors each time the
+ * radius doubles. That leaves a seam at each doubling, which is what a real
+ * segmented diaphragm would have anyway — the alternative is either slivers at the
+ * hub or slabs at the rim.
+ */
+import { CanvasTexture, LinearFilter, SRGBColorSpace, type Texture } from 'three'
+
+const RADIUS_KM = 50
+/**
+ * Six rings, so the cap reads as a board rather than a mesh. Finer cells looked
+ * better face-on from inside and collapsed into a dark cross-hatch from outside,
+ * where the caps are always seen near edge-on (D-030) and foreshortened ten to one.
+ * A pattern has to survive its worst viewing angle, not its best.
+ */
+const RINGS = 6
+const CELL_KM = RADIUS_KM / RINGS
+/** Sector count at the rim; halved inward at every halving of the radius. */
+const RIM_SECTORS = 32
+
+const LIGHT = '#c3cad6'
+const DARK = '#8d96a5'
+const RIB = '#dae1eb'
+
+/** Sectors at a given radius, chosen to keep cells about CELL_KM wide. */
+const sectorsAt = (rKm: number): number => {
+  const ideal = (2 * Math.PI * rKm) / CELL_KM
+  // Snap to a power-of-two fraction of the rim count, so rings nest exactly.
+  let n = RIM_SECTORS
+  while (n > 4 && n / 2 >= ideal) n /= 2
+  return n
+}
+
+/**
+ * Draw the pattern into a square canvas covering the cap's bounding box, which is
+ * exactly how Three maps UVs onto both CircleGeometry and RingGeometry — so the
+ * same image lands correctly on the full far cap and on the sun cap's annulus.
+ */
+export function drawCapPattern(size = 2048): HTMLCanvasElement {
+  const c = document.createElement('canvas')
+  c.width = size
+  c.height = size
+  const ctx = c.getContext('2d')!
+  const mid = size / 2
+  const px = mid / RADIUS_KM
+
+  ctx.fillStyle = DARK
+  ctx.fillRect(0, 0, size, size)
+
+  for (let i = 0; i < RINGS; i++) {
+    const r0 = i * CELL_KM
+    const r1 = r0 + CELL_KM
+    const n = sectorsAt(r1)
+    for (let s = 0; s < n; s++) {
+      if ((i + s) % 2 === 0) continue
+      const a0 = (s / n) * Math.PI * 2
+      const a1 = ((s + 1) / n) * Math.PI * 2
+      ctx.beginPath()
+      ctx.arc(mid, mid, r1 * px, a0, a1)
+      ctx.arc(mid, mid, r0 * px, a1, a0, true)
+      ctx.closePath()
+      ctx.fillStyle = LIGHT
+      ctx.fill()
+    }
+  }
+
+  // A brighter ring every 25 km, and one at the rim. Structure, and a coarser
+  // scale for when the checker itself is too fine to resolve through the haze.
+  ctx.strokeStyle = RIB
+  ctx.lineWidth = Math.max(1, size / 900)
+  for (const r of [25, 50]) {
+    ctx.beginPath()
+    ctx.arc(mid, mid, r * px - ctx.lineWidth / 2, 0, Math.PI * 2)
+    ctx.stroke()
+  }
+  return c
+}
+
+/** The pattern as a texture. Built once; both views share the one instance. */
+let cached: Texture | null = null
+export function capPatternTexture(): Texture {
+  if (cached) return cached
+  const tex = new CanvasTexture(drawCapPattern())
+  // Declared for the exterior's standard material, which decodes it; the interior's
+  // custom shader does its own decode and ignores this.
+  tex.colorSpace = SRGBColorSpace
+  /**
+   * Mipmapped, and it has to be. A high-contrast geometric pattern is the worst
+   * case for point sampling: minified without mipmaps the far cap moirés into a
+   * dark cross-hatch that reads as damage rather than structure. Averaging to grey
+   * once the cells fall below a pixel is the correct answer and the honest one —
+   * you cannot resolve 5 km squares from 540 km either.
+   */
+  tex.anisotropy = 16
+  tex.magFilter = LinearFilter
+  cached = tex
+  return cached
+}
diff --git a/src/views/exterior.ts b/src/views/exterior.ts
index 9497aa6..67acd12 100644
--- a/src/views/exterior.ts
+++ b/src/views/exterior.ts
@@ -21,6 +21,7 @@ import {
 } from '../canon/constants.js'
 import { type SurfacePoint, mapThetaOrigin, normalizeTheta } from '../canon/geometry.js'
 import type { Place } from '../world/schema.js'
+import { capPatternTexture } from './cap-pattern.js'
 import { KIND_COLOR } from './palette.js'
 
 const KM = 1000
@@ -199,10 +200,19 @@ export class ExteriorView {
   }
 
   private buildCaps(): void {
+    // The same pattern the interior view paints on their far side (D-041). A cap is
+    // structure, not landscape, and the checker is what says so from either side.
+    const pattern = capPatternTexture()
     // The sun cap is an annulus: the beam enters through the hole in the middle.
     const sun = new Mesh(
       new RingGeometry(BEAM_R, R, 96),
-      new MeshStandardMaterial({ color: 0xb3bccb, roughness: 0.5, metalness: 0.3, side: DoubleSide }))
+      new MeshStandardMaterial({
+        color: 0xdfe5ee, map: pattern, roughness: 0.5, metalness: 0.3, side: DoubleSide,
+        // Same licence as the hull's terrain: sunlight runs along the axis, so a cap
+        // is edge-on to every real light and renders as a dark disc exactly when it
+        // is being looked at. Enough emissive to keep the pattern legible.
+        emissiveMap: pattern, emissive: new Color(0xffffff), emissiveIntensity: 0.42,
+      }))
     this.tower.add(sun)
 
     const aperture = new Mesh(
@@ -213,7 +223,12 @@ export class ExteriorView {
 
     const far = new Mesh(
       new CircleGeometry(R, 96),
-      new MeshStandardMaterial({ color: 0x6c7583, roughness: 0.72, metalness: 0.25, side: DoubleSide }))
+      new MeshStandardMaterial({
+        // Lighter than the bare cap used to be: the pattern supplies the tone now,
+        // and this face is turned away from every light in the scene.
+        color: 0xd8dee8, map: pattern, roughness: 0.72, metalness: 0.25, side: DoubleSide,
+        emissiveMap: pattern, emissive: new Color(0xffffff), emissiveIntensity: 0.5,
+      }))
     far.position.z = L
     this.tower.add(far)
   }
@@ -484,16 +499,17 @@ export class ExteriorView {
 
     /**
      * A cap is a wall across the end, not a patch of ground, so the usual
-     * from-the-side framing shows only an edge. Keep the default view's oblique
-     * angle and slide it onto the cap: standing off along the axis instead looks
-     * down the barrel and foreshortens the whole 540 km behind an unlit disc.
+     * from-the-side framing shows only an edge. This sits about 40° off the axis:
+     * far enough round to see the cap's face — which now carries a pattern worth
+     * seeing (D-041) — without looking straight down the barrel and foreshortening
+     * the whole 540 km behind it.
      */
     if (isCapZ(z)) {
       const atSun = z < L / 2
       const face = atSun ? 0 : L
       const dir = atSun ? -1 : 1
-      this.controls.target.set(0, 0, face - dir * 40)
-      this.camera.position.set(R * 3, -R * 12, face + dir * 150)
+      this.controls.target.set(0, 0, face - dir * 30)
+      this.camera.position.set(R * 1.6, -R * 3.6, face + dir * 240)
       this.controls.update()
       return
     }