fuzzyworld

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

Branch interior-flight · default branch · all branches

cap-pattern.ts

9.4 KB · history · blame

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 */
18import { CanvasTexture, LinearFilter, SRGBColorSpace, type Texture } from 'three'
19import { FAR_CAP_MIRROR_RADIUS_M } from '../canon/constants.js'
20
21const 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 */
28const RINGS = 6
29const CELL_KM = RADIUS_KM / RINGS
30/** Sector count at the rim; halved inward at every halving of the radius. */
31const RIM_SECTORS = 32
32
33const LIGHT = '#c3cad6'
34const DARK = '#8d96a5'
35const RIB = '#dae1eb'
36/** The far cap's window frame. Lighter than the checker: it is seen against sky. */
37const MULLION = '#aeb7c6'
38/** The bullseye mirror. Near-white: it is returning 1.7 MW/m² back up the axis. */
39const MIRROR = '#f2f6ff'
40
41/** Sectors at a given radius, chosen to keep cells about CELL_KM wide. */
42const 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 */
55export 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
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 */
109export function drawCapGlazing(size = 2048, bullseye = true): HTMLCanvasElement {
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
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 */
170 if (!bullseye) return c
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()
183 return c
184}
185
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 */
191export const MIRROR_UV_RADIUS = FAR_CAP_MIRROR_RADIUS_M / 1000 / RADIUS_KM
192
193/** The pattern as a texture. Built once; both views share the one instance. */
194let cached: Texture | null = null
195export 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}
213
214/** The glazing as a texture, on the same terms. Alpha is the pane mask. */
215let glazingCached: Texture | null = null
216export 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}
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 */
233let sunGlazingCached: Texture | null = null
234export 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}