fuzzyworld

tower / commit

feat(caps): frame the cap windows in identical triangles (D-078)

c954a775b37acb91ea1cadff154a56e7817ddd66 · Pavel Safronov · 2026-09-27 22:13:33 -0700 · parents 6368edcf8e99

Claude Code usage: this commit ~28.6K out / ~8.4M cached · cost unknown (no per-model trailer) · session 3 tally 600 in / 382.2K out / 54.6M cached · usage by commit and session

feat(caps): frame the cap windows in identical triangles (D-078)

Both caps' mullions become a lattice of equilateral triangles, 8.3 km
on a side with six meeting at the axis, in place of the polar rings
and spokes. The pitch is the old cells', so the frame is still the
world's one known scale. The unused polar checker goes with it.

Usage: 0.6K in / 382.2K out / 54.6M cached (session tally)
rtk:   963.1K saved, 15.9% over 2851 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 README.md                |   8 +-
 decisions.md             |  23 +++++
 design.md                |  13 ++-
 src/views/cap-pattern.ts | 238 +++++++++++++++--------------------------------
 test/cap-pattern.test.ts |  35 +++++++
 5 files changed, 145 insertions(+), 172 deletions(-)

diff --git a/README.md b/README.md
index 3dfddc0..58f3cd2 100644
--- a/README.md
+++ b/README.md
@@ -377,10 +377,10 @@ it back up the column for a second pass, which lifts the beam's utilisation from
 to 94%. By day the sun takes the whole beam before it gets there (below), so the mirror
 is lit only at night, by the banked beam: a faint disc where the rod ends.
 
-The checker survives on the far cap as the window's **mullions** — the same polar
-tessellation at the same pitch, so it is still the only object in the world with a
-known cell size and still the only rule anyone has for judging distance. It is just
-read against sky now instead of against a plate.
+Both caps' windows are framed by **mullions** in a lattice of identical equilateral
+triangles, 8.3 km on a side, six meeting at the axis. That is the pitch of the checker the
+caps once had, so the frame is still the only object in the world with a known size and
+the only rule anyone has for judging distance, read against sky.
 
 **Tower has a sun, and it crosses the world once a day.** The mirrorlets that fly in the
 beam gather into one shoal and throw the beam's light out sideways there, which makes a
diff --git a/decisions.md b/decisions.md
index b0f9dd2..290b1b5 100644
--- a/decisions.md
+++ b/decisions.md
@@ -1194,6 +1194,8 @@ whichever axial direction has the most room and prints the distance it used.
 
 ## D-041 — The caps get a pattern, and it is polar
 
+*Superseded by D-078 for the pattern: the caps' frames are identical triangles, at this pitch.*
+
 *Half superseded by D-045: the far cap is now a window, and this pattern survives
 on it as the window's mullions rather than as a filled checker. Everything below
 still holds — the tessellation, the pitch, the mipmapping and the reason for all
@@ -1356,6 +1358,8 @@ that change the constant would have been a number in a file and nothing else.
 
 ## D-045 — The far cap is a window, and the checker becomes its frame
 
+*Amended by D-078: the mullions are a triangle lattice, not the polar tessellation.*
+
 §1.4 has said since the first draft that the caps are the only views out of Tower
 and that the sky through them turns rather than sweeps — a pinwheel about the axis
 with one motionless star at the middle, one revolution every 7 min 29 s. It was
@@ -2597,3 +2601,22 @@ discs would stand inside Tower and be seen through the windows. The plating is t
 so that, from inside the tube, it hides the far side's parts wherever the ground has not
 been generated yet. All the parts are instanced: five draw calls for a few thousand pieces.
 Seeded, so it is the same plant on every load, and built only when first chosen.
+
+## D-078 — The cap windows are framed in identical triangles
+
+**Asked for** (author, 2026-09-27): "the endcaps have circular window patterns. please
+change these to be identical triangle pieces."
+
+Both caps' mullions are now a lattice of **identical equilateral triangles**: three
+families of straight members at 60° to each other, spaced one triangle height apart, with a
+vertex on the axis where six triangles meet. The rings and spokes of the polar tessellation
+(D-041, D-045), including the heavy ring at 25 km, are gone. The side is 8.3 km — the old
+cells' pitch, six across the radius — so the frame keeps its job as the one object in the
+world with a known size (D-041), and it is coarse enough to survive the near edge-on views
+from outside (D-030).
+
+A disc cannot be tiled by whole triangles, so the rim cuts the outermost ones; the heavier
+rim mullion closes them. The far cap's mirror sits on the centre vertex, and the sun cap's
+aperture on the same point of its own lattice. `test/cap-pattern.test.ts` pins the lattice:
+three directions 60° apart, every line a whole triangle height from the axis. The polar
+checker drawing, which nothing had used since both caps became windows, was removed with it.
diff --git a/design.md b/design.md
index 954554e..3615b63 100644
--- a/design.md
+++ b/design.md
@@ -318,8 +318,8 @@ The only views out are at the **end caps**, and they are peculiar:
   used, and more names are added as they come into use (author, 2026-09-27).
 - The **far cap is glazed whole** — a 100 km window looking into deep anti-sun
   space, not a wall with windows at its foot (D-045). Its structure survives as the
-  window's **mullions**, on the same polar tessellation the checker used and at the
-  same cell pitch, so the one object in the world with a known scale still is one:
+  window's **mullions**, a lattice of identical equilateral triangles 8.3 km on a side
+  (D-078) — the old checker's pitch — so the one object in the world with a known scale still is one:
   the rule now reads against the sky instead of against a plate. The **sun cap**
   faces the collector rather than the sun, and is **glazed too** (D-050): §1.3.0
   retires the "heavy glare management" this section used to demand of it, because no
@@ -957,11 +957,10 @@ pair of binoculars, and captions on every place still visible through the haze.
 Selecting a place walks the observer to it (D-038) — the haze horizon closes at
 100–150 km, so turning to face something usually just points at haze.
 
-The caps are no longer the same object. **The sun cap is checkered** (D-041) —
-polar, six rings, cells about 8 km. **The far cap is glazed** (D-045), and the
-checker survives on it as the window's mullions: the same tessellation at the same
-pitch, so it is still the only object in the world with a known size and still
-doubles as a scale for judging distance, now read against sky rather than plate.
+**Both caps are glazed** (D-045, D-050), framed by a lattice of identical equilateral
+triangles 8.3 km on a side (D-078), with a vertex on the axis where six meet. That is the
+pitch of the polar checker it replaced (D-041), so it is still the only object in the world
+with a known size and still doubles as a scale for judging distance, read against sky.
 The exterior draws these same caps — the same meshes and shaders, not a copy of
 them (D-060).
 
diff --git a/src/views/cap-pattern.ts b/src/views/cap-pattern.ts
index 13fb19e..4ac33ca 100644
--- a/src/views/cap-pattern.ts
+++ b/src/views/cap-pattern.ts
@@ -1,110 +1,67 @@
 /**
- * The pattern on the end caps, drawn once and shared by both 3D views.
+ * The window frames 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.
+ * Both caps are glazed (D-045, D-050), and the structure holding the glass is a lattice
+ * of **identical equilateral triangles** (D-078): three families of straight mullions at
+ * 60° to each other, with a vertex on the axis where six triangles meet. It replaced a
+ * polar tessellation of rings and spokes, at the author's request.
  *
- * 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.
+ * The triangles keep the old cells' pitch, CELL_KM on a side, so the cap is still the one
+ * object in the world with a known scale (D-041): a glance at the far cap's lattice tells
+ * you how far away it is. A disc cannot be tiled by whole triangles, so the rim cuts the
+ * outermost ones; the rim mullion closes them.
  */
 import { CanvasTexture, LinearFilter, SRGBColorSpace, type Texture } from 'three'
 import { FAR_CAP_MIRROR_RADIUS_M } from '../canon/constants.js'
 
 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.
+ * A triangle's side, km: the old polar cells' pitch, six across the radius. Finer looked
+ * better face-on from inside and collapsed into a dark cross-hatch from outside, where
+ * the caps are seen near edge-on (D-030) and foreshortened ten to one.
  */
-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
+export const TRIANGLE_KM = RADIUS_KM / 6
 
-const LIGHT = '#c3cad6'
-const DARK = '#8d96a5'
-const RIB = '#dae1eb'
-/** The far cap's window frame. Lighter than the checker: it is seen against sky. */
+/** The window frame. Light: it is seen against sky. */
 const MULLION = '#aeb7c6'
 /** The bullseye mirror. Near-white: it is returning 1.7 MW/m² back up the axis. */
 const MIRROR = '#f2f6ff'
 
-/** 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.
+ * The lattice's mullions as line segments in km, centred on the axis and clipped to the
+ * cap: for each of three directions 60° apart, the lines of that family, one triangle
+ * height apart. Every line through the lattice is one of these, so the triangles they
+ * bound are all the same triangle.
  */
-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()
+export function triangleMullions(): [number, number, number, number][] {
+  const height = (TRIANGLE_KM * Math.sqrt(3)) / 2
+  const out: [number, number, number, number][] = []
+  for (let f = 0; f < 3; f++) {
+    const a = (f * Math.PI) / 3
+    const dx = Math.cos(a)
+    const dy = Math.sin(a)
+    // Offset perpendicular to the line: (−dy, dx).
+    const reach = Math.floor(RADIUS_KM / height)
+    for (let k = -reach; k <= reach; k++) {
+      const off = k * height
+      const half = Math.sqrt(Math.max(0, RADIUS_KM * RADIUS_KM - off * off))
+      if (half <= 0) continue
+      const cx = -dy * off
+      const cy = dx * off
+      out.push([cx - dx * half, cy - dy * half, cx + dx * half, cy + dy * half])
     }
   }
-
-  // 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
+  return out
 }
 
 /**
- * The far cap's glazing: the same polar tessellation, drawn as the frame around
- * transparent panes instead of a filled checker.
- *
- * §1.4 makes the caps the only views out of Tower, and D-045 glazes the far one
- * whole. The checker cannot simply be deleted, because it is the only object in
- * the world with a known cell size and so the only rule anyone has for judging
- * distance (D-041). So it becomes the mullions: the cells stay exactly where they
- * were and at exactly the same pitch, and you look through them instead of at
- * them. The rule now reads against the sky, which is a better place for it.
+ * The glazing: frame around transparent panes. Alpha is the whole point of this canvas —
+ * opaque where there is structure, clear where there is a pane; the colour channels
+ * carry the mullion's own tone.
  *
- * Alpha is the whole point of this canvas — opaque where there is structure,
- * clear where there is a pane. The colour channels carry the mullion's own tone.
+ * Drawn into a square canvas covering the cap's bounding box, which is how Three maps UVs
+ * onto both CircleGeometry and RingGeometry, so the one image lands correctly on the far
+ * cap's disc and on the sun cap's annulus.
  */
 export function drawCapGlazing(size = 2048, bullseye = true): HTMLCanvasElement {
   const c = document.createElement('canvas')
@@ -120,52 +77,37 @@ export function drawCapGlazing(size = 2048, bullseye = true): HTMLCanvasElement
   ctx.lineCap = 'round'
 
   /**
-   * Mullions are 400 m of structure across a 100 km window — heavy for a window
-   * and slight for a wall, which is about right for something holding an
-   * atmosphere back. Below a pixel on screen the line simply fades, and the cap
-   * goes clear rather than moiring, which is the behaviour D-041 wanted.
+   * Mullions are 400 m of structure across a 100 km window — heavy for a window and
+   * slight for a wall, which is about right for something holding an atmosphere back.
+   * Below a pixel on screen the line simply fades, and the cap goes clear rather than
+   * moiring, which is the behaviour D-041 wanted.
    */
   const mullionKm = 0.4
   ctx.lineWidth = Math.max(1, mullionKm * px)
-
-  for (let i = 0; i < RINGS; i++) {
-    const r0 = i * CELL_KM
-    const r1 = r0 + CELL_KM
-    const n = sectorsAt(r1)
-    // The ring itself.
+  for (const [x0, y0, x1, y1] of triangleMullions()) {
     ctx.beginPath()
-    ctx.arc(mid, mid, r1 * px, 0, Math.PI * 2)
+    ctx.moveTo(mid + x0 * px, mid + y0 * px)
+    ctx.lineTo(mid + x1 * px, mid + y1 * px)
     ctx.stroke()
-    // The radial members bounding each cell in it.
-    for (let s = 0; s < n; s++) {
-      const a = (s / n) * Math.PI * 2
-      ctx.beginPath()
-      ctx.moveTo(mid + Math.cos(a) * r0 * px, mid + Math.sin(a) * r0 * px)
-      ctx.lineTo(mid + Math.cos(a) * r1 * px, mid + Math.sin(a) * r1 * px)
-      ctx.stroke()
-    }
   }
 
-  // The same two heavy rings the checker has, so the coarse scale survives when
-  // the cell pitch is too fine to resolve through 540 km of air.
+  // The rim: heavier, closing the triangles the edge cuts, and the frame the glass sits in.
   ctx.lineWidth = Math.max(1.5, mullionKm * 2.5 * px)
-  for (const r of [25, 50]) {
-    ctx.beginPath()
-    ctx.arc(mid, mid, r * px - ctx.lineWidth / 2, 0, Math.PI * 2)
-    ctx.stroke()
-  }
+  ctx.beginPath()
+  ctx.arc(mid, mid, RADIUS_KM * px - ctx.lineWidth / 2, 0, Math.PI * 2)
+  ctx.stroke()
 
   /**
-   * The bullseye: not glass, and the only part of the far cap that is not (D-048).
-   * The beam arrives collimated in its own cross-section, so what has to survive
-   * 1.7 MW/m² is a 4 km disc on the axis and nothing else — the mirror that sends
-   * it back up the column for a second pass.
+   * The bullseye: not glass, and the only part of the far cap that is not (D-048). The
+   * beam arrives collimated in its own cross-section, so what has to survive 1.7 MW/m²
+   * is a 4 km disc on the axis and nothing else — the mirror that sends it back up the
+   * column for a second pass. It sits on the lattice's centre vertex.
    *
-   * Alpha 1, so the star field does not show through it. It does not occult the
-   * pole star, though the geometry invites that guess: the pinwheel turns about the
-   * direction parallel to the axis, which from the floor is horizontal, so its
-   * centre lands on the window at the observer's own radius — down at eye level —
-   * while the mirror is an object on the axis, about 10° above it from 270 km.
+   * Alpha 1, so the star field does not show through it. It does not occult the pole
+   * star, though the geometry invites that guess: the pinwheel turns about the direction
+   * parallel to the axis, which from the floor is horizontal, so its centre lands on the
+   * window at the observer's own radius — down at eye level — while the mirror is an
+   * object on the axis, about 10° above it from 270 km.
    */
   if (!bullseye) return c
   const mirrorKm = FAR_CAP_MIRROR_RADIUS_M / 1000
@@ -173,9 +115,8 @@ export function drawCapGlazing(size = 2048, bullseye = true): HTMLCanvasElement
   ctx.beginPath()
   ctx.arc(mid, mid, mirrorKm * px, 0, Math.PI * 2)
   ctx.fill()
-  // A collar around it, so at a distance the mirror reads as set into structure
-  // rather than as a bright fleck of nothing.
-  ctx.strokeStyle = MULLION
+  // A collar around it, so at a distance the mirror reads as set into structure rather
+  // than as a bright fleck of nothing.
   ctx.lineWidth = Math.max(1, mullionKm * 1.5 * px)
   ctx.beginPath()
   ctx.arc(mid, mid, mirrorKm * px, 0, Math.PI * 2)
@@ -184,59 +125,34 @@ export function drawCapGlazing(size = 2048, bullseye = true): HTMLCanvasElement
 }
 
 /**
- * How far out the mirror reaches, as a fraction of the cap's radius. The shaders
- * need it: the bullseye is the one part of the window that is lit by the beam, so
- * it brightens and banks with the beam instead of with the ambient light.
+ * How far out the mirror reaches, as a fraction of the cap's radius. The shaders need it:
+ * the bullseye is the one part of the window that is lit by the beam, so it brightens and
+ * banks with the beam instead of with the ambient light.
  */
 export const MIRROR_UV_RADIUS = FAR_CAP_MIRROR_RADIUS_M / 1000 / RADIUS_KM
 
-/** 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.
+function glazingTexture(bullseye: boolean): Texture {
+  const tex = new CanvasTexture(drawCapGlazing(2048, bullseye))
   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.
-   */
+  // Mipmapped: an aliasing window frame twinkles, which reads as the stars behind it
+  // moving. Averaged away once the mullions fall below a pixel, the cap goes clear.
   tex.anisotropy = 16
   tex.magFilter = LinearFilter
-  cached = tex
-  return cached
+  return tex
 }
 
-/** The glazing as a texture, on the same terms. Alpha is the pane mask. */
+/** The far cap's glazing, built once; both views share the one instance. */
 let glazingCached: Texture | null = null
 export function capGlazingTexture(): Texture {
-  if (glazingCached) return glazingCached
-  const tex = new CanvasTexture(drawCapGlazing())
-  tex.colorSpace = SRGBColorSpace
-  // Mipmapped for the same reason the checker is, and it matters more here: an
-  // aliasing window frame twinkles, which reads as the stars behind it moving.
-  tex.anisotropy = 16
-  tex.magFilter = LinearFilter
-  glazingCached = tex
-  return glazingCached
+  return (glazingCached ??= glazingTexture(true))
 }
 
 /**
- * The sun cap's glazing: the same frame, without the bullseye. Its middle is the
- * beam aperture — a hole in the ring, not a mirror — so drawing one there would put
- * a sliver of false structure around the rim of the opening.
+ * The sun cap's glazing: the same frame, without the bullseye. Its middle is the beam
+ * aperture — a hole in the ring, not a mirror — so drawing one there would put a sliver
+ * of false structure around the rim of the opening.
  */
 let sunGlazingCached: Texture | null = null
 export function sunCapGlazingTexture(): Texture {
-  if (sunGlazingCached) return sunGlazingCached
-  const tex = new CanvasTexture(drawCapGlazing(2048, false))
-  tex.colorSpace = SRGBColorSpace
-  tex.anisotropy = 16
-  tex.magFilter = LinearFilter
-  sunGlazingCached = tex
-  return sunGlazingCached
+  return (sunGlazingCached ??= glazingTexture(false))
 }
diff --git a/test/cap-pattern.test.ts b/test/cap-pattern.test.ts
new file mode 100644
index 0000000..c232ab3
--- /dev/null
+++ b/test/cap-pattern.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from 'vitest'
+import { TRIANGLE_KM, triangleMullions } from '../src/views/cap-pattern.js'
+
+describe('the cap windows are identical triangles (D-078)', () => {
+  const lines = triangleMullions()
+  const height = (TRIANGLE_KM * Math.sqrt(3)) / 2
+
+  it('runs its mullions in three directions, 60° apart', () => {
+    const dirs = new Set(lines.map(([x0, y0, x1, y1]) => {
+      const a = (Math.atan2(y1 - y0, x1 - x0) * 180) / Math.PI
+      return Math.round(((a % 180) + 180) % 180)
+    }))
+    expect([...dirs].sort((a, b) => a - b)).toEqual([0, 60, 120])
+  })
+
+  it('spaces every line a whole triangle height from the axis, so every triangle is the same', () => {
+    for (const [x0, y0, x1, y1] of lines) {
+      const len = Math.hypot(x1 - x0, y1 - y0)
+      // Distance from the axis to the line: |cross(p0, d)|.
+      const off = Math.abs(x0 * (y1 - y0) - y0 * (x1 - x0)) / len
+      const k = off / height
+      expect(Math.abs(k - Math.round(k))).toBeLessThan(1e-9)
+    }
+  })
+
+  it('stays inside the cap, and puts a vertex on the axis', () => {
+    for (const [x0, y0, x1, y1] of lines) {
+      expect(Math.hypot(x0, y0)).toBeLessThanOrEqual(50 + 1e-9)
+      expect(Math.hypot(x1, y1)).toBeLessThanOrEqual(50 + 1e-9)
+    }
+    // One line of each family passes through the centre.
+    const through = lines.filter(([x0, y0, x1, y1]) => Math.abs(x0 * y1 - y0 * x1) < 1e-9)
+    expect(through).toHaveLength(3)
+  })
+})