tower / files / src / views / cap-pattern.ts
cap-pattern.ts
c44860b9 2026-09-07 Pavel Safronov | 1 | /** |
c954a775 2026-09-28 Pavel Safronov | 2 | * The window frames on the end caps, drawn once and shared by both 3D views. |
c44860b9 2026-09-07 Pavel Safronov | 3 | * |
c954a775 2026-09-28 Pavel Safronov | 4 | * Both caps are glazed (D-045, D-050), and the structure holding the glass is a lattice |
| 5 | * of **identical equilateral triangles** (D-078): three families of straight mullions at | |
| 6 | * 60° to each other, with a vertex on the axis where six triangles meet. It replaced a | |
| 7 | * polar tessellation of rings and spokes, at the author's request. | |
c44860b9 2026-09-07 Pavel Safronov | 8 | * |
c954a775 2026-09-28 Pavel Safronov | 9 | * The triangles keep the old cells' pitch, CELL_KM on a side, so the cap is still the one |
| 10 | * object in the world with a known scale (D-041): a glance at the far cap's lattice tells | |
| 11 | * you how far away it is. A disc cannot be tiled by whole triangles, so the rim cuts the | |
| 12 | * outermost ones; the rim mullion closes them. | |
c44860b9 2026-09-07 Pavel Safronov | 13 | */ |
| 14 | import { CanvasTexture, LinearFilter, SRGBColorSpace, type Texture } from 'three' | |
da6bcb0e 2026-09-08 Pavel Safronov | 15 | import { FAR_CAP_MIRROR_RADIUS_M } from '../canon/constants.js' |
c44860b9 2026-09-07 Pavel Safronov | 16 | |
| 17 | const RADIUS_KM = 50 | |
| 18 | /** | |
c954a775 2026-09-28 Pavel Safronov | 19 | * A triangle's side, km: the old polar cells' pitch, six across the radius. Finer looked |
| 20 | * better face-on from inside and collapsed into a dark cross-hatch from outside, where | |
| 21 | * the caps are seen near edge-on (D-030) and foreshortened ten to one. | |
c44860b9 2026-09-07 Pavel Safronov | 22 | */ |
c954a775 2026-09-28 Pavel Safronov | 23 | export const TRIANGLE_KM = RADIUS_KM / 6 |
c44860b9 2026-09-07 Pavel Safronov | 24 | |
c954a775 2026-09-28 Pavel Safronov | 25 | /** The window frame. Light: it is seen against sky. */ |
769726ea 2026-09-08 Pavel Safronov | 26 | const MULLION = '#aeb7c6' |
da6bcb0e 2026-09-08 Pavel Safronov | 27 | /** The bullseye mirror. Near-white: it is returning 1.7 MW/m² back up the axis. */ |
| 28 | const MIRROR = '#f2f6ff' | |
c44860b9 2026-09-07 Pavel Safronov | 29 | |
| 30 | /** | |
c954a775 2026-09-28 Pavel Safronov | 31 | * The lattice's mullions as line segments in km, centred on the axis and clipped to the |
| 32 | * cap: for each of three directions 60° apart, the lines of that family, one triangle | |
| 33 | * height apart. Every line through the lattice is one of these, so the triangles they | |
| 34 | * bound are all the same triangle. | |
c44860b9 2026-09-07 Pavel Safronov | 35 | */ |
c954a775 2026-09-28 Pavel Safronov | 36 | export function triangleMullions(): [number, number, number, number][] { |
| 37 | const height = (TRIANGLE_KM * Math.sqrt(3)) / 2 | |
| 38 | const out: [number, number, number, number][] = [] | |
| 39 | for (let f = 0; f < 3; f++) { | |
| 40 | const a = (f * Math.PI) / 3 | |
| 41 | const dx = Math.cos(a) | |
| 42 | const dy = Math.sin(a) | |
| 43 | // Offset perpendicular to the line: (−dy, dx). | |
| 44 | const reach = Math.floor(RADIUS_KM / height) | |
| 45 | for (let k = -reach; k <= reach; k++) { | |
| 46 | const off = k * height | |
| 47 | const half = Math.sqrt(Math.max(0, RADIUS_KM * RADIUS_KM - off * off)) | |
| 48 | if (half <= 0) continue | |
| 49 | const cx = -dy * off | |
| 50 | const cy = dx * off | |
| 51 | out.push([cx - dx * half, cy - dy * half, cx + dx * half, cy + dy * half]) | |
c44860b9 2026-09-07 Pavel Safronov | 52 | } |
| 53 | } | |
c954a775 2026-09-28 Pavel Safronov | 54 | return out |
c44860b9 2026-09-07 Pavel Safronov | 55 | } |
| 56 | ||
769726ea 2026-09-08 Pavel Safronov | 57 | /** |
c954a775 2026-09-28 Pavel Safronov | 58 | * The glazing: frame around transparent panes. Alpha is the whole point of this canvas — |
| 59 | * opaque where there is structure, clear where there is a pane; the colour channels | |
| 60 | * carry the mullion's own tone. | |
769726ea 2026-09-08 Pavel Safronov | 61 | * |
c954a775 2026-09-28 Pavel Safronov | 62 | * Drawn into a square canvas covering the cap's bounding box, which is how Three maps UVs |
| 63 | * onto both CircleGeometry and RingGeometry, so the one image lands correctly on the far | |
| 64 | * cap's disc and on the sun cap's annulus. | |
769726ea 2026-09-08 Pavel Safronov | 65 | */ |
9c891d2e 2026-09-08 Pavel Safronov | 66 | export function drawCapGlazing(size = 2048, bullseye = true): HTMLCanvasElement { |
769726ea 2026-09-08 Pavel Safronov | 67 | const c = document.createElement('canvas') |
| 68 | c.width = size | |
| 69 | c.height = size | |
| 70 | const ctx = c.getContext('2d')! | |
| 71 | const mid = size / 2 | |
| 72 | const px = mid / RADIUS_KM | |
| 73 | ||
| 74 | // Transparent ground: every pixel not drawn below is a pane. | |
| 75 | ctx.clearRect(0, 0, size, size) | |
| 76 | ctx.strokeStyle = MULLION | |
| 77 | ctx.lineCap = 'round' | |
| 78 | ||
| 79 | /** | |
c954a775 2026-09-28 Pavel Safronov | 80 | * Mullions are 400 m of structure across a 100 km window — heavy for a window and |
| 81 | * slight for a wall, which is about right for something holding an atmosphere back. | |
| 82 | * Below a pixel on screen the line simply fades, and the cap goes clear rather than | |
| 83 | * moiring, which is the behaviour D-041 wanted. | |
769726ea 2026-09-08 Pavel Safronov | 84 | */ |
| 85 | const mullionKm = 0.4 | |
| 86 | ctx.lineWidth = Math.max(1, mullionKm * px) | |
c954a775 2026-09-28 Pavel Safronov | 87 | for (const [x0, y0, x1, y1] of triangleMullions()) { |
769726ea 2026-09-08 Pavel Safronov | 88 | ctx.beginPath() |
c954a775 2026-09-28 Pavel Safronov | 89 | ctx.moveTo(mid + x0 * px, mid + y0 * px) |
| 90 | ctx.lineTo(mid + x1 * px, mid + y1 * px) | |
769726ea 2026-09-08 Pavel Safronov | 91 | ctx.stroke() |
| 92 | } | |
| 93 | ||
c954a775 2026-09-28 Pavel Safronov | 94 | // The rim: heavier, closing the triangles the edge cuts, and the frame the glass sits in. |
769726ea 2026-09-08 Pavel Safronov | 95 | ctx.lineWidth = Math.max(1.5, mullionKm * 2.5 * px) |
c954a775 2026-09-28 Pavel Safronov | 96 | ctx.beginPath() |
| 97 | ctx.arc(mid, mid, RADIUS_KM * px - ctx.lineWidth / 2, 0, Math.PI * 2) | |
| 98 | ctx.stroke() | |
769726ea 2026-09-08 Pavel Safronov | 99 | |
da6bcb0e 2026-09-08 Pavel Safronov | 100 | /** |
c954a775 2026-09-28 Pavel Safronov | 101 | * The bullseye: not glass, and the only part of the far cap that is not (D-048). The |
| 102 | * beam arrives collimated in its own cross-section, so what has to survive 1.7 MW/m² | |
| 103 | * is a 4 km disc on the axis and nothing else — the mirror that sends it back up the | |
| 104 | * column for a second pass. It sits on the lattice's centre vertex. | |
da6bcb0e 2026-09-08 Pavel Safronov | 105 | * |
c954a775 2026-09-28 Pavel Safronov | 106 | * Alpha 1, so the star field does not show through it. It does not occult the pole |
| 107 | * star, though the geometry invites that guess: the pinwheel turns about the direction | |
| 108 | * parallel to the axis, which from the floor is horizontal, so its centre lands on the | |
| 109 | * window at the observer's own radius — down at eye level — while the mirror is an | |
| 110 | * object on the axis, about 10° above it from 270 km. | |
da6bcb0e 2026-09-08 Pavel Safronov | 111 | */ |
9c891d2e 2026-09-08 Pavel Safronov | 112 | if (!bullseye) return c |
da6bcb0e 2026-09-08 Pavel Safronov | 113 | const mirrorKm = FAR_CAP_MIRROR_RADIUS_M / 1000 |
| 114 | ctx.fillStyle = MIRROR | |
| 115 | ctx.beginPath() | |
| 116 | ctx.arc(mid, mid, mirrorKm * px, 0, Math.PI * 2) | |
| 117 | ctx.fill() | |
c954a775 2026-09-28 Pavel Safronov | 118 | // A collar around it, so at a distance the mirror reads as set into structure rather |
| 119 | // than as a bright fleck of nothing. | |
da6bcb0e 2026-09-08 Pavel Safronov | 120 | ctx.lineWidth = Math.max(1, mullionKm * 1.5 * px) |
| 121 | ctx.beginPath() | |
| 122 | ctx.arc(mid, mid, mirrorKm * px, 0, Math.PI * 2) | |
| 123 | ctx.stroke() | |
769726ea 2026-09-08 Pavel Safronov | 124 | return c |
| 125 | } | |
| 126 | ||
da6bcb0e 2026-09-08 Pavel Safronov | 127 | /** |
c954a775 2026-09-28 Pavel Safronov | 128 | * How far out the mirror reaches, as a fraction of the cap's radius. The shaders need it: |
| 129 | * the bullseye is the one part of the window that is lit by the beam, so it brightens and | |
| 130 | * banks with the beam instead of with the ambient light. | |
da6bcb0e 2026-09-08 Pavel Safronov | 131 | */ |
| 132 | export const MIRROR_UV_RADIUS = FAR_CAP_MIRROR_RADIUS_M / 1000 / RADIUS_KM | |
| 133 | ||
c954a775 2026-09-28 Pavel Safronov | 134 | function glazingTexture(bullseye: boolean): Texture { |
| 135 | const tex = new CanvasTexture(drawCapGlazing(2048, bullseye)) | |
c44860b9 2026-09-07 Pavel Safronov | 136 | tex.colorSpace = SRGBColorSpace |
c954a775 2026-09-28 Pavel Safronov | 137 | // Mipmapped: an aliasing window frame twinkles, which reads as the stars behind it |
| 138 | // moving. Averaged away once the mullions fall below a pixel, the cap goes clear. | |
c44860b9 2026-09-07 Pavel Safronov | 139 | tex.anisotropy = 16 |
| 140 | tex.magFilter = LinearFilter | |
c954a775 2026-09-28 Pavel Safronov | 141 | return tex |
c44860b9 2026-09-07 Pavel Safronov | 142 | } |
769726ea 2026-09-08 Pavel Safronov | 143 | |
c954a775 2026-09-28 Pavel Safronov | 144 | /** The far cap's glazing, built once; both views share the one instance. */ |
769726ea 2026-09-08 Pavel Safronov | 145 | let glazingCached: Texture | null = null |
| 146 | export function capGlazingTexture(): Texture { | |
c954a775 2026-09-28 Pavel Safronov | 147 | return (glazingCached ??= glazingTexture(true)) |
769726ea 2026-09-08 Pavel Safronov | 148 | } |
9c891d2e 2026-09-08 Pavel Safronov | 149 | |
| 150 | /** | |
c954a775 2026-09-28 Pavel Safronov | 151 | * The sun cap's glazing: the same frame, without the bullseye. Its middle is the beam |
| 152 | * aperture — a hole in the ring, not a mirror — so drawing one there would put a sliver | |
| 153 | * of false structure around the rim of the opening. | |
9c891d2e 2026-09-08 Pavel Safronov | 154 | */ |
| 155 | let sunGlazingCached: Texture | null = null | |
| 156 | export function sunCapGlazingTexture(): Texture { | |
c954a775 2026-09-28 Pavel Safronov | 157 | return (sunGlazingCached ??= glazingTexture(false)) |
9c891d2e 2026-09-08 Pavel Safronov | 158 | } |