tower / files / src / views / cap-pattern.ts
cap-pattern.ts
| 1 | /** |
| 2 | * The window frames on the end caps, drawn once and shared by both 3D views. |
| 3 | * |
| 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. |
| 8 | * |
| 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. |
| 13 | */ |
| 14 | import { CanvasTexture, LinearFilter, SRGBColorSpace, type Texture } from 'three' |
| 15 | import { FAR_CAP_MIRROR_RADIUS_M } from '../canon/constants.js' |
| 16 | |
| 17 | const RADIUS_KM = 50 |
| 18 | /** |
| 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. |
| 22 | */ |
| 23 | export const TRIANGLE_KM = RADIUS_KM / 6 |
| 24 | |
| 25 | /** The window frame. Light: it is seen against sky. */ |
| 26 | const MULLION = '#aeb7c6' |
| 27 | /** The bullseye mirror. Near-white: it is returning 1.7 MW/m² back up the axis. */ |
| 28 | const MIRROR = '#f2f6ff' |
| 29 | |
| 30 | /** |
| 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. |
| 35 | */ |
| 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]) |
| 52 | } |
| 53 | } |
| 54 | return out |
| 55 | } |
| 56 | |
| 57 | /** |
| 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. |
| 61 | * |
| 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. |
| 65 | */ |
| 66 | export function drawCapGlazing(size = 2048, bullseye = true): HTMLCanvasElement { |
| 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 | /** |
| 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. |
| 84 | */ |
| 85 | const mullionKm = 0.4 |
| 86 | ctx.lineWidth = Math.max(1, mullionKm * px) |
| 87 | for (const [x0, y0, x1, y1] of triangleMullions()) { |
| 88 | ctx.beginPath() |
| 89 | ctx.moveTo(mid + x0 * px, mid + y0 * px) |
| 90 | ctx.lineTo(mid + x1 * px, mid + y1 * px) |
| 91 | ctx.stroke() |
| 92 | } |
| 93 | |
| 94 | // The rim: heavier, closing the triangles the edge cuts, and the frame the glass sits in. |
| 95 | ctx.lineWidth = Math.max(1.5, mullionKm * 2.5 * px) |
| 96 | ctx.beginPath() |
| 97 | ctx.arc(mid, mid, RADIUS_KM * px - ctx.lineWidth / 2, 0, Math.PI * 2) |
| 98 | ctx.stroke() |
| 99 | |
| 100 | /** |
| 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. |
| 105 | * |
| 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. |
| 111 | */ |
| 112 | if (!bullseye) return c |
| 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() |
| 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. |
| 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() |
| 124 | return c |
| 125 | } |
| 126 | |
| 127 | /** |
| 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. |
| 131 | */ |
| 132 | export const MIRROR_UV_RADIUS = FAR_CAP_MIRROR_RADIUS_M / 1000 / RADIUS_KM |
| 133 | |
| 134 | function glazingTexture(bullseye: boolean): Texture { |
| 135 | const tex = new CanvasTexture(drawCapGlazing(2048, bullseye)) |
| 136 | tex.colorSpace = SRGBColorSpace |
| 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. |
| 139 | tex.anisotropy = 16 |
| 140 | tex.magFilter = LinearFilter |
| 141 | return tex |
| 142 | } |
| 143 | |
| 144 | /** The far cap's glazing, built once; both views share the one instance. */ |
| 145 | let glazingCached: Texture | null = null |
| 146 | export function capGlazingTexture(): Texture { |
| 147 | return (glazingCached ??= glazingTexture(true)) |
| 148 | } |
| 149 | |
| 150 | /** |
| 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. |
| 154 | */ |
| 155 | let sunGlazingCached: Texture | null = null |
| 156 | export function sunCapGlazingTexture(): Texture { |
| 157 | return (sunGlazingCached ??= glazingTexture(false)) |
| 158 | } |