tower / interior-flight / files / src / views / cap-pattern.ts
Branch interior-flight · default branch · all branches
cap-pattern.ts
c44860b9 2026-09-07 Pavel Safronov | 1 | /** |
| 2 | * The pattern on the end caps, drawn once and shared by both 3D views. | |
| 3 | * | |
| 4 | * The caps are the only surfaces in Tower that are not landscape: 100 km discs of | |
| 5 | * structure closing each end. Blank, they read as fog with an edge. Checkered, they | |
| 6 | * read as built — and they become the one thing in the world with a known scale, so | |
| 7 | * a glance at the far cap tells you how far away it is. | |
| 8 | * | |
| 9 | * The checker is **polar**, not square. A cap is a disc on a spinning cylinder, so | |
| 10 | * its natural coordinates are radius and angle; a square grid would meet the rim at | |
| 11 | * every possible angle and look laid over the geometry rather than part of it. | |
| 12 | * | |
| 13 | * Cells are kept roughly square by doubling the number of sectors each time the | |
| 14 | * radius doubles. That leaves a seam at each doubling, which is what a real | |
| 15 | * segmented diaphragm would have anyway — the alternative is either slivers at the | |
| 16 | * hub or slabs at the rim. | |
| 17 | */ | |
| 18 | import { CanvasTexture, LinearFilter, SRGBColorSpace, type Texture } from 'three' | |
da6bcb0e 2026-09-08 Pavel Safronov | 19 | import { FAR_CAP_MIRROR_RADIUS_M } from '../canon/constants.js' |
c44860b9 2026-09-07 Pavel Safronov | 20 | |
| 21 | const RADIUS_KM = 50 | |
| 22 | /** | |
| 23 | * Six rings, so the cap reads as a board rather than a mesh. Finer cells looked | |
| 24 | * better face-on from inside and collapsed into a dark cross-hatch from outside, | |
| 25 | * where the caps are always seen near edge-on (D-030) and foreshortened ten to one. | |
| 26 | * A pattern has to survive its worst viewing angle, not its best. | |
| 27 | */ | |
| 28 | const RINGS = 6 | |
| 29 | const CELL_KM = RADIUS_KM / RINGS | |
| 30 | /** Sector count at the rim; halved inward at every halving of the radius. */ | |
| 31 | const RIM_SECTORS = 32 | |
| 32 | ||
| 33 | const LIGHT = '#c3cad6' | |
| 34 | const DARK = '#8d96a5' | |
| 35 | const RIB = '#dae1eb' | |
769726ea 2026-09-08 Pavel Safronov | 36 | /** The far cap's window frame. Lighter than the checker: it is seen against sky. */ |
| 37 | const MULLION = '#aeb7c6' | |
da6bcb0e 2026-09-08 Pavel Safronov | 38 | /** The bullseye mirror. Near-white: it is returning 1.7 MW/m² back up the axis. */ |
| 39 | const MIRROR = '#f2f6ff' | |
c44860b9 2026-09-07 Pavel Safronov | 40 | |
| 41 | /** Sectors at a given radius, chosen to keep cells about CELL_KM wide. */ | |
| 42 | const sectorsAt = (rKm: number): number => { | |
| 43 | const ideal = (2 * Math.PI * rKm) / CELL_KM | |
| 44 | // Snap to a power-of-two fraction of the rim count, so rings nest exactly. | |
| 45 | let n = RIM_SECTORS | |
| 46 | while (n > 4 && n / 2 >= ideal) n /= 2 | |
| 47 | return n | |
| 48 | } | |
| 49 | ||
| 50 | /** | |
| 51 | * Draw the pattern into a square canvas covering the cap's bounding box, which is | |
| 52 | * exactly how Three maps UVs onto both CircleGeometry and RingGeometry — so the | |
| 53 | * same image lands correctly on the full far cap and on the sun cap's annulus. | |
| 54 | */ | |
| 55 | export function drawCapPattern(size = 2048): HTMLCanvasElement { | |
| 56 | const c = document.createElement('canvas') | |
| 57 | c.width = size | |
| 58 | c.height = size | |
| 59 | const ctx = c.getContext('2d')! | |
| 60 | const mid = size / 2 | |
| 61 | const px = mid / RADIUS_KM | |
| 62 | ||
| 63 | ctx.fillStyle = DARK | |
| 64 | ctx.fillRect(0, 0, size, size) | |
| 65 | ||
| 66 | for (let i = 0; i < RINGS; i++) { | |
| 67 | const r0 = i * CELL_KM | |
| 68 | const r1 = r0 + CELL_KM | |
| 69 | const n = sectorsAt(r1) | |
| 70 | for (let s = 0; s < n; s++) { | |
| 71 | if ((i + s) % 2 === 0) continue | |
| 72 | const a0 = (s / n) * Math.PI * 2 | |
| 73 | const a1 = ((s + 1) / n) * Math.PI * 2 | |
| 74 | ctx.beginPath() | |
| 75 | ctx.arc(mid, mid, r1 * px, a0, a1) | |
| 76 | ctx.arc(mid, mid, r0 * px, a1, a0, true) | |
| 77 | ctx.closePath() | |
| 78 | ctx.fillStyle = LIGHT | |
| 79 | ctx.fill() | |
| 80 | } | |
| 81 | } | |
| 82 | ||
| 83 | // A brighter ring every 25 km, and one at the rim. Structure, and a coarser | |
| 84 | // scale for when the checker itself is too fine to resolve through the haze. | |
| 85 | ctx.strokeStyle = RIB | |
| 86 | ctx.lineWidth = Math.max(1, size / 900) | |
| 87 | for (const r of [25, 50]) { | |
| 88 | ctx.beginPath() | |
| 89 | ctx.arc(mid, mid, r * px - ctx.lineWidth / 2, 0, Math.PI * 2) | |
| 90 | ctx.stroke() | |
| 91 | } | |
| 92 | return c | |
| 93 | } | |
| 94 | ||
769726ea 2026-09-08 Pavel Safronov | 95 | /** |
| 96 | * The far cap's glazing: the same polar tessellation, drawn as the frame around | |
| 97 | * transparent panes instead of a filled checker. | |
| 98 | * | |
| 99 | * §1.4 makes the caps the only views out of Tower, and D-045 glazes the far one | |
| 100 | * whole. The checker cannot simply be deleted, because it is the only object in | |
| 101 | * the world with a known cell size and so the only rule anyone has for judging | |
| 102 | * distance (D-041). So it becomes the mullions: the cells stay exactly where they | |
| 103 | * were and at exactly the same pitch, and you look through them instead of at | |
| 104 | * them. The rule now reads against the sky, which is a better place for it. | |
| 105 | * | |
| 106 | * Alpha is the whole point of this canvas — opaque where there is structure, | |
| 107 | * clear where there is a pane. The colour channels carry the mullion's own tone. | |
| 108 | */ | |
9c891d2e 2026-09-08 Pavel Safronov | 109 | export function drawCapGlazing(size = 2048, bullseye = true): HTMLCanvasElement { |
769726ea 2026-09-08 Pavel Safronov | 110 | const c = document.createElement('canvas') |
| 111 | c.width = size | |
| 112 | c.height = size | |
| 113 | const ctx = c.getContext('2d')! | |
| 114 | const mid = size / 2 | |
| 115 | const px = mid / RADIUS_KM | |
| 116 | ||
| 117 | // Transparent ground: every pixel not drawn below is a pane. | |
| 118 | ctx.clearRect(0, 0, size, size) | |
| 119 | ctx.strokeStyle = MULLION | |
| 120 | ctx.lineCap = 'round' | |
| 121 | ||
| 122 | /** | |
| 123 | * Mullions are 400 m of structure across a 100 km window — heavy for a window | |
| 124 | * and slight for a wall, which is about right for something holding an | |
| 125 | * atmosphere back. Below a pixel on screen the line simply fades, and the cap | |
| 126 | * goes clear rather than moiring, which is the behaviour D-041 wanted. | |
| 127 | */ | |
| 128 | const mullionKm = 0.4 | |
| 129 | ctx.lineWidth = Math.max(1, mullionKm * px) | |
| 130 | ||
| 131 | for (let i = 0; i < RINGS; i++) { | |
| 132 | const r0 = i * CELL_KM | |
| 133 | const r1 = r0 + CELL_KM | |
| 134 | const n = sectorsAt(r1) | |
| 135 | // The ring itself. | |
| 136 | ctx.beginPath() | |
| 137 | ctx.arc(mid, mid, r1 * px, 0, Math.PI * 2) | |
| 138 | ctx.stroke() | |
| 139 | // The radial members bounding each cell in it. | |
| 140 | for (let s = 0; s < n; s++) { | |
| 141 | const a = (s / n) * Math.PI * 2 | |
| 142 | ctx.beginPath() | |
| 143 | ctx.moveTo(mid + Math.cos(a) * r0 * px, mid + Math.sin(a) * r0 * px) | |
| 144 | ctx.lineTo(mid + Math.cos(a) * r1 * px, mid + Math.sin(a) * r1 * px) | |
| 145 | ctx.stroke() | |
| 146 | } | |
| 147 | } | |
| 148 | ||
| 149 | // The same two heavy rings the checker has, so the coarse scale survives when | |
| 150 | // the cell pitch is too fine to resolve through 540 km of air. | |
| 151 | ctx.lineWidth = Math.max(1.5, mullionKm * 2.5 * px) | |
| 152 | for (const r of [25, 50]) { | |
| 153 | ctx.beginPath() | |
| 154 | ctx.arc(mid, mid, r * px - ctx.lineWidth / 2, 0, Math.PI * 2) | |
| 155 | ctx.stroke() | |
| 156 | } | |
| 157 | ||
da6bcb0e 2026-09-08 Pavel Safronov | 158 | /** |
| 159 | * The bullseye: not glass, and the only part of the far cap that is not (D-048). | |
| 160 | * The beam arrives collimated in its own cross-section, so what has to survive | |
| 161 | * 1.7 MW/m² is a 4 km disc on the axis and nothing else — the mirror that sends | |
| 162 | * it back up the column for a second pass. | |
| 163 | * | |
| 164 | * Alpha 1, so the star field does not show through it. It does not occult the | |
| 165 | * pole star, though the geometry invites that guess: the pinwheel turns about the | |
| 166 | * direction parallel to the axis, which from the floor is horizontal, so its | |
| 167 | * centre lands on the window at the observer's own radius — down at eye level — | |
| 168 | * while the mirror is an object on the axis, about 10° above it from 270 km. | |
| 169 | */ | |
9c891d2e 2026-09-08 Pavel Safronov | 170 | if (!bullseye) return c |
da6bcb0e 2026-09-08 Pavel Safronov | 171 | const mirrorKm = FAR_CAP_MIRROR_RADIUS_M / 1000 |
| 172 | ctx.fillStyle = MIRROR | |
| 173 | ctx.beginPath() | |
| 174 | ctx.arc(mid, mid, mirrorKm * px, 0, Math.PI * 2) | |
| 175 | ctx.fill() | |
| 176 | // A collar around it, so at a distance the mirror reads as set into structure | |
| 177 | // rather than as a bright fleck of nothing. | |
| 178 | ctx.strokeStyle = MULLION | |
| 179 | ctx.lineWidth = Math.max(1, mullionKm * 1.5 * px) | |
| 180 | ctx.beginPath() | |
| 181 | ctx.arc(mid, mid, mirrorKm * px, 0, Math.PI * 2) | |
| 182 | ctx.stroke() | |
769726ea 2026-09-08 Pavel Safronov | 183 | return c |
| 184 | } | |
| 185 | ||
da6bcb0e 2026-09-08 Pavel Safronov | 186 | /** |
| 187 | * How far out the mirror reaches, as a fraction of the cap's radius. The shaders | |
| 188 | * need it: the bullseye is the one part of the window that is lit by the beam, so | |
| 189 | * it brightens and banks with the beam instead of with the ambient light. | |
| 190 | */ | |
| 191 | export const MIRROR_UV_RADIUS = FAR_CAP_MIRROR_RADIUS_M / 1000 / RADIUS_KM | |
| 192 | ||
c44860b9 2026-09-07 Pavel Safronov | 193 | /** The pattern as a texture. Built once; both views share the one instance. */ |
| 194 | let cached: Texture | null = null | |
| 195 | export function capPatternTexture(): Texture { | |
| 196 | if (cached) return cached | |
| 197 | const tex = new CanvasTexture(drawCapPattern()) | |
| 198 | // Declared for the exterior's standard material, which decodes it; the interior's | |
| 199 | // custom shader does its own decode and ignores this. | |
| 200 | tex.colorSpace = SRGBColorSpace | |
| 201 | /** | |
| 202 | * Mipmapped, and it has to be. A high-contrast geometric pattern is the worst | |
| 203 | * case for point sampling: minified without mipmaps the far cap moirés into a | |
| 204 | * dark cross-hatch that reads as damage rather than structure. Averaging to grey | |
| 205 | * once the cells fall below a pixel is the correct answer and the honest one — | |
| 206 | * you cannot resolve 5 km squares from 540 km either. | |
| 207 | */ | |
| 208 | tex.anisotropy = 16 | |
| 209 | tex.magFilter = LinearFilter | |
| 210 | cached = tex | |
| 211 | return cached | |
| 212 | } | |
769726ea 2026-09-08 Pavel Safronov | 213 | |
| 214 | /** The glazing as a texture, on the same terms. Alpha is the pane mask. */ | |
| 215 | let glazingCached: Texture | null = null | |
| 216 | export function capGlazingTexture(): Texture { | |
| 217 | if (glazingCached) return glazingCached | |
| 218 | const tex = new CanvasTexture(drawCapGlazing()) | |
| 219 | tex.colorSpace = SRGBColorSpace | |
| 220 | // Mipmapped for the same reason the checker is, and it matters more here: an | |
| 221 | // aliasing window frame twinkles, which reads as the stars behind it moving. | |
| 222 | tex.anisotropy = 16 | |
| 223 | tex.magFilter = LinearFilter | |
| 224 | glazingCached = tex | |
| 225 | return glazingCached | |
| 226 | } | |
9c891d2e 2026-09-08 Pavel Safronov | 227 | |
| 228 | /** | |
| 229 | * The sun cap's glazing: the same frame, without the bullseye. Its middle is the | |
| 230 | * beam aperture — a hole in the ring, not a mirror — so drawing one there would put | |
| 231 | * a sliver of false structure around the rim of the opening. | |
| 232 | */ | |
| 233 | let sunGlazingCached: Texture | null = null | |
| 234 | export function sunCapGlazingTexture(): Texture { | |
| 235 | if (sunGlazingCached) return sunGlazingCached | |
| 236 | const tex = new CanvasTexture(drawCapGlazing(2048, false)) | |
| 237 | tex.colorSpace = SRGBColorSpace | |
| 238 | tex.anisotropy = 16 | |
| 239 | tex.magFilter = LinearFilter | |
| 240 | sunGlazingCached = tex | |
| 241 | return sunGlazingCached | |
| 242 | } |