fuzzyworld

tower / files / src / views / cap-pattern.ts

cap-pattern.ts

6.5 KB · history · blame

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 */
14import { CanvasTexture, LinearFilter, SRGBColorSpace, type Texture } from 'three'
15import { FAR_CAP_MIRROR_RADIUS_M } from '../canon/constants.js'
16
17const 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 */
23export const TRIANGLE_KM = RADIUS_KM / 6
24
25/** The window frame. Light: it is seen against sky. */
26const MULLION = '#aeb7c6'
27/** The bullseye mirror. Near-white: it is returning 1.7 MW/m² back up the axis. */
28const 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 */
36export 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 */
66export 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 */
132export const MIRROR_UV_RADIUS = FAR_CAP_MIRROR_RADIUS_M / 1000 / RADIUS_KM
133
134function 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. */
145let glazingCached: Texture | null = null
146export 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 */
155let sunGlazingCached: Texture | null = null
156export function sunCapGlazingTexture(): Texture {
157 return (sunGlazingCached ??= glazingTexture(false))
158}