fuzzyworld

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

Branch interior-flight · default branch · all branches

interior.ts

Blame: the commit that last changed each line · file · history

7cc177f6 2026-09-07 Pavel Safronov1/**
2 * The interior view: standing inside Tower. See design.md §1.5, §1.8, §2.4.
3 *
4 * Scene units are kilometres, as in the exterior view, so the numbers in the code
5 * are the numbers in canon. The observer stands on the inner surface with the axis
6 * overhead; nothing in this scene rotates, because the observer, the ground and
7 * the air all co-rotate together. That is not a simplification — it is the point.
8 * Inside a sealed cylinder the spin is genuinely invisible, and the only thing that
9 * gives it away is what falls (src/canon/motion.ts).
10 *
11 * Three things carry the illusion, and all three are computed rather than styled:
12 *
13 * - the ground curving up on both sides and closing overhead, which is just the
14 * real cylinder seen from the inside;
15 * - haze, integrated per fragment through Tower's rotational density profile, so
16 * the far side reads clearer than the ground at the same distance (§1.8);
17 * - the beam, a self-luminous shaft 3.65° wide and the only thing in the sky that
18 * is not land.
19 */
20import type { Texture } from 'three'
21import {
22 AdditiveBlending, BackSide, CircleGeometry, Color, CylinderGeometry, DoubleSide,
e27aaf20 2026-09-08 Pavel Safronov23 Mesh, PerspectiveCamera, RepeatWrapping, RingGeometry,
7cc177f6 2026-09-07 Pavel Safronov24 Scene, ShaderMaterial, TextureLoader, Vector3, WebGLRenderer,
25} from 'three'
26import {
2bb3e0de 2026-09-08 Pavel Safronov27 BEAM_RADIUS_M, BRIGHTNESS_FULL_W_M2, COLLECTOR_DIAMETER_M, COLLECTOR_STANDOFF_M,
17ccee99 2026-09-08 Pavel Safronov28 COLLECTOR_CLOCK_HAND_FRACTION, COLLECTOR_CLOCK_MARK_M, COLLECTOR_RIM_M,
29 DAY_LENGTH_S, DAY_SWEEP_FRACTION, BRIGHTNESS_NIGHT_W_M2, BRIGHTNESS_TEMPERATE_W_M2,
da6bcb0e 2026-09-08 Pavel Safronov30 BRIGHTNESS_TWILIGHT_W_M2, G0, HULL_DEPTH_M, LENGTH_M, OMEGA_RAD_S, RADIUS_M,
de65ab10 2026-09-08 Pavel Safronov31 RAYLEIGH_FLOOR_PER_M,
7cc177f6 2026-09-07 Pavel Safronov32 SPECIFIC_GAS_CONSTANT, TEMPERATURE_K, gravityAtHeight,
33} from '../canon/constants.js'
34import { type SurfacePoint, mapThetaOrigin, normalizeTheta } from '../canon/geometry.js'
35import type { Place } from '../world/schema.js'
36import { EYE_MAX_M, EYE_MIN_M } from '../ui/scales.js'
9c891d2e 2026-09-08 Pavel Safronov37import {
38 MIRROR_UV_RADIUS, capGlazingTexture, sunCapGlazingTexture,
39} from './cap-pattern.js'
7cc177f6 2026-09-07 Pavel Safronov40import {
41 COLOR_RASTER, HEIGHT_RASTER, RASTER_HEIGHT, RASTER_HEIGHT_SPAN_M,
42 RASTER_MIN_HEIGHT_M, RASTER_WIDTH,
43} from './raster.js'
44
45const KM = 1000
46const R = RADIUS_M / KM
47const L = LENGTH_M / KM
48const BEAM_R = BEAM_RADIUS_M / KM
da6bcb0e 2026-09-08 Pavel Safronov49/** Wall depth in km. The far cap's window is set this far into its own frame. */
de65ab10 2026-09-08 Pavel Safronov50const HULL_D = HULL_DEPTH_M / KM
9c891d2e 2026-09-08 Pavel Safronov51/** The collector, in km: 122 km of radius flying 320 km ahead of the sun cap. */
52const COLLECTOR_R = COLLECTOR_DIAMETER_M / KM / 2
53const COLLECTOR_Z = -COLLECTOR_STANDOFF_M / KM
49b0d64d 2026-09-08 Pavel Safronov54/** Width of the sunlit edge structure, in km (D-054). */
55const COLLECTOR_RIM = COLLECTOR_RIM_M / KM
17ccee99 2026-09-08 Pavel Safronov56/** Clock geometry on the collector face, in km (D-055). */
57const CLOCK_MARK = COLLECTOR_CLOCK_MARK_M / KM
58const CLOCK_HAND_R = COLLECTOR_R * COLLECTOR_CLOCK_HAND_FRACTION
7cc177f6 2026-09-07 Pavel Safronov59
60/**
61 * Rayleigh extinction per kilometre at floor density, per channel.
62 *
63 * Canon quotes 550 nm (green). Scattering goes as lambda^-4, so red survives about
64 * twice as far as green and blue about half as far — which is the whole reason
65 * distant land in Tower goes blue rather than merely grey, and it costs nothing to
66 * carry three numbers instead of one.
67 */
68const K_GREEN = RAYLEIGH_FLOOR_PER_M * KM
69const K_RGB: [number, number, number] = [
70 K_GREEN * (550 / 650) ** 4, K_GREEN, K_GREEN * (550 / 440) ** 4,
71]
72/** Density falls as exp(-a(R^2 - s^2)) — the rotational profile, in 1/km^2. */
73const DENSITY_A = ((OMEGA_RAD_S ** 2 / (2 * SPECIFIC_GAS_CONSTANT * TEMPERATURE_K)) * KM ** 2)
74
75/**
76 * Airlight: the radiance the haze itself adds. It is deliberately NEUTRAL.
77 *
78 * The obvious move is to make it sky blue, and it is wrong: blue already scatters
79 * 2.4x harder than green in K_RGB, so a blue source colours the haze twice and
80 * every distant thing turns mauve regardless of what it is made of. With a neutral
81 * source the blue falls out of the physics, and the far side of the world stays
82 * recognisably forest and desert at the 78% canon says it should be.
83 */
84const HAZE_DAY = 0x9aa3ad
85const HAZE_DUSK = 0x8d8792
86
87
88/** Shell tessellation. 0.7 km triangles: distant relief is real, your feet are not. */
89const SEG_AROUND = 512
90const SEG_ALONG = 768
91
392bb8a7 2026-09-08 Pavel Safronov92/**
93 * The five settings, darkest first.
94 *
95 * `dark` is the beam shut down entirely and is the only one that is genuinely
96 * black — with 54 TW off there is no airlight, no lit ground and no rod overhead,
97 * and the only thing left in the world is the sky through the far cap. `night` is
98 * the beam banked rather than killed, which is what night has to be here: §1.3
99 * gives the glow to the beam and the air rather than to the diffusers, so aiming
100 * the swarm away would not darken anything (D-046).
101 */
e27aaf20 2026-09-08 Pavel Safronov102/** The pinwheel's period, the one interval in Tower you can time against the sky. */
103const fmtSpin = (): string => {
104 const s = (2 * Math.PI) / OMEGA_RAD_S
105 return `${Math.floor(s / 60)} min ${Math.round(s % 60)} s`
106}
107
7cc177f6 2026-09-07 Pavel Safronov108export const LIGHTING = {
392bb8a7 2026-09-08 Pavel Safronov109 dark: 0,
110 night: BRIGHTNESS_NIGHT_W_M2,
7cc177f6 2026-09-07 Pavel Safronov111 twilight: BRIGHTNESS_TWILIGHT_W_M2,
112 temperate: BRIGHTNESS_TEMPERATE_W_M2,
113 full: BRIGHTNESS_FULL_W_M2,
114} as const
115export type Lighting = keyof typeof LIGHTING
392bb8a7 2026-09-08 Pavel Safronov116export const isLighting = (v: unknown): v is Lighting =>
117 typeof v === 'string' && v in LIGHTING
7cc177f6 2026-09-07 Pavel Safronov118
119const STATE_KEY = 'tower.interior'
120const STATE_SAVE_MS = 400
121
122export interface InteriorCallbacks {
123 onPickPlace: (id: string) => void
124 onLook: () => void
125}
126
127// ---------------------------------------------------------------------- shaders
128
129/**
130 * Transmittance from `a` to `b` through Tower's air, per channel.
131 *
132 * The integral has no closed form worth having — the exponent is a(s^2 - R^2) with
133 * s^2 quadratic along the ray — so it is sampled. The integrand is smooth and
134 * monotonic on each side of the ray's closest approach to the axis, so 24 midpoint
135 * samples land within a percent of the 512-sample version the instruments use, and
136 * the two agree on the canon table in design.md §1.8.
137 */
138const HAZE_GLSL = /* glsl */`
139uniform vec3 uCam;
140uniform vec3 uK;
141uniform vec3 uHaze;
142uniform float uExposure;
143const float A = ${DENSITY_A.toExponential(8)};
144const float RR = ${(R * R).toFixed(4)};
145
146vec3 towerTransmittance(vec3 from, vec3 to) {
147 vec3 d = to - from;
148 float len = length(d);
149 const int N = 24;
150 float tau = 0.0;
151 for (int i = 0; i < N; i++) {
152 float t = (float(i) + 0.5) / float(N);
153 vec2 p = from.xy + d.xy * t;
154 tau += exp(-A * (RR - dot(p, p)));
155 }
156 return exp(-uK * (tau * len / float(N)));
157}
158`
159
160const GROUND_VERT = /* glsl */`
161#include <common>
162#include <logdepthbuf_pars_vertex>
163uniform sampler2D uHeight;
164uniform vec2 uTexel;
165uniform float uMppKm;
166varying vec2 vUv;
167varying vec3 vWorld;
168varying vec3 vNormal2;
169
170/** The raster is stretched across a declared working range, not 0..anything. */
171float elevation(vec2 at) {
172 return ${(RASTER_MIN_HEIGHT_M / KM).toFixed(6)}
173 + texture2D(uHeight, at).r * ${(RASTER_HEIGHT_SPAN_M / KM).toFixed(6)};
174}
175
176void main() {
177 vUv = uv;
178 vec3 radial = normalize(vec3(position.xy, 0.0));
179 vec3 up = -radial;
180 // Spinward: z-hat x s-hat. The floor's other tangent is the axis itself.
181 vec3 spinward = vec3(-radial.y, radial.x, 0.0);
182 vec3 deepward = vec3(0.0, 0.0, 1.0);
183
184 float h = elevation(uv);
185 // Central differences on the raster, in km of rise per km of run.
186 float dz = (elevation(uv + vec2(uTexel.x, 0.0)) - elevation(uv - vec2(uTexel.x, 0.0)))
187 / (2.0 * uMppKm);
188 float dt = (elevation(uv + vec2(0.0, uTexel.y)) - elevation(uv - vec2(0.0, uTexel.y)))
189 / (2.0 * uMppKm);
190 vNormal2 = normalize(up - deepward * dz - spinward * dt);
191
192 vec3 p = position + up * h;
193 vWorld = p;
194 gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
195 #include <logdepthbuf_vertex>
196}
197`
198
199const GROUND_FRAG = /* glsl */`
200#include <common>
201#include <logdepthbuf_pars_fragment>
202uniform sampler2D uColor;
2bb3e0de 2026-09-08 Pavel Safronov203uniform float uFrontZ;
204uniform float uFrontWidth;
205uniform float uBelowFront;
206uniform float uAboveFront;
7cc177f6 2026-09-07 Pavel Safronov207varying vec2 vUv;
208varying vec3 vWorld;
209varying vec3 vNormal2;
210${HAZE_GLSL}
211
212/** Cheap value noise, to keep the ground from being flat colour underfoot. */
213float hash(vec2 p) {
214 return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
215}
216float vnoise(vec2 p) {
217 vec2 i = floor(p), f = fract(p);
218 f = f * f * (3.0 - 2.0 * f);
219 return mix(mix(hash(i), hash(i + vec2(1, 0)), f.x),
220 mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), f.x), f.y);
221}
222
223void main() {
224 #include <logdepthbuf_fragment>
225 // The raster is an sRGB image and every uniform Colour here is already linear,
226 // so the texture is the one thing that has to be brought into the working space.
227 // Three inserts that decode into its own materials and never into a custom one.
228 vec3 base = pow(texture2D(uColor, vUv).rgb, vec3(2.2));
229
230 /**
231 * The raster is 264 m per pixel, so within a few hundred metres of the observer
232 * one texel fills the screen. This is texture, not terrain: it breaks up the
233 * flat block without claiming there is anything there. It fades out past 4 km,
234 * where the raster is carrying real detail again.
235 */
236 float near = 1.0 - smoothstep(0.4, 4.0, distance(uCam, vWorld));
237 // Unrolled surface coordinates. World xyz shears the pattern into radial streaks
238 // exactly where the ground is most grazing, which is most of the view.
239 vec2 surf = vec2(vWorld.z, atan(vWorld.y, vWorld.x) * ${R.toFixed(3)});
240 // Rotated and off-harmonic octaves. Axis-aligned value noise at one scale reads
241 // as a woven diamond lattice, which is worse than the flat colour it replaced.
242 vec2 rot = vec2(surf.x * 0.86 - surf.y * 0.51, surf.x * 0.51 + surf.y * 0.86);
243 float grain = vnoise(surf * 173.0) * 0.55 + vnoise(rot * 611.0) * 0.45;
244 base *= 1.0 + (grain - 0.5) * 0.12 * near;
245
246 // The beam is a line source on the axis, so the light comes from straight up.
247 vec3 radial = normalize(vec3(vWorld.xy, 0.0));
248 float lambert = clamp(dot(vNormal2, -radial), 0.0, 1.0);
2bb3e0de 2026-09-08 Pavel Safronov249 /**
250 * The dawn front (D-053). The beam's own glow comes up everywhere at once — it is
251 * one beam — but the diffusers hand the light down the axis, so the *ground*
252 * lights as a line running deepward at 225 m/s. Below the front and above it are
253 * different times of day, and this is the only place in Tower where that is true.
254 *
255 * Outside dawn and dusk both sides are 1.0 and this costs a mix.
256 */
257 float side = smoothstep(uFrontZ - uFrontWidth, uFrontZ, vWorld.z);
258 float daylight = mix(uBelowFront, uAboveFront, side);
259
7cc177f6 2026-09-07 Pavel Safronov260 // The rest of the world is lit land, and lit land is what fills the shadows.
2bb3e0de 2026-09-08 Pavel Safronov261 vec3 lit = base * (0.34 + 0.80 * lambert) * uExposure * daylight;
7cc177f6 2026-09-07 Pavel Safronov262
263 vec3 T = towerTransmittance(uCam, vWorld);
264 gl_FragColor = vec4(lit * T + uHaze * uExposure * (1.0 - T), 1.0);
265 #include <colorspace_fragment>
266}
267`
268
269const FLAT_VERT = /* glsl */`
270#include <common>
271#include <logdepthbuf_pars_vertex>
272varying vec3 vWorld;
273void main() {
274 vWorld = (modelMatrix * vec4(position, 1.0)).xyz;
275 gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
276 #include <logdepthbuf_vertex>
277}
278`
279
769726ea 2026-09-08 Pavel Safronov280/**
281 * The sky through the far cap window (§1.4, D-045).
282 *
283 * Sampled by view direction, in an azimuthal-equidistant projection about +z.
284 *
285 * The projection is the whole trick. +z is the spin axis and therefore the
286 * celestial pole, so polar angle becomes radius and azimuth becomes angle, and
287 * adding the spin phase to the azimuth *is* the pinwheel: every star runs a circle
288 * about the axis once every 7 min 29 s and the one on the axis does not move.
289 *
290 * Two projections were wrong before this one. Gnomonic (xy/z) blows up as the ray
291 * goes perpendicular to the axis, which is the ordinary grazing view from the foot
292 * of the cap. Painting the field on the cap plane instead is stable but has no
293 * fixed angular scale — from a kilometre away the whole 60° field lands inside one
294 * star cell, so the sky comes out empty. Equidistant has neither failure: it is
295 * defined for every direction but exactly sunward, and stars stay the same angular
296 * size however far away the window is, because they are at infinity and that is
297 * what being at infinity means.
298 */
299const STAR_GLSL = /* glsl */`
300uniform float uSpin;
301uniform float uStarGain;
302
303float starHash(vec2 p) {
304 vec3 q = fract(vec3(p.xyx) * 0.1031);
305 q += dot(q, q.yzx + 33.33);
306 return fract((q.x + q.y) * q.z);
307}
308vec2 starHash2(vec2 p) {
309 vec3 q = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973));
310 q += dot(q, q.yzx + 33.33);
311 return fract((q.xx + q.yz) * q.zy);
312}
313
314// Cells per radian. ~1.2 deg cells, which at 22% occupancy lands near the naked-eye
315// sky's real density of one star per 7 square degrees.
316const float STAR_SCALE = 46.0;
317
318vec3 starField(vec3 dir) {
319 vec3 d = normalize(dir);
320 float th = acos(clamp(d.z, -1.0, 1.0));
321 float ph = atan(d.y, d.x) + uSpin;
322 vec2 p = vec2(cos(ph), sin(ph)) * th * STAR_SCALE;
323 vec2 cell = floor(p);
324 vec2 f = p - cell;
325 vec3 sum = vec3(0.0);
326 for (int j = -1; j <= 1; j++) {
327 for (int i = -1; i <= 1; i++) {
328 vec2 o = vec2(float(i), float(j));
329 vec2 g = cell + o;
330 float mag = starHash(g);
331 if (mag < 0.78) continue; // most of the sky is empty
332 float b = pow(max(0.0, 1.0 - length(o + starHash2(g + 11.7) - f) / 0.09), 4.0)
333 * ((mag - 0.78) / 0.22);
334 // A few warm ones among the cool. Without it the field reads as noise.
335 sum += b * mix(vec3(0.72, 0.80, 1.0), vec3(1.0, 0.86, 0.68), starHash(g + 5.1));
336 }
337 }
338 /**
339 * The pole star, placed rather than left to the hash. §1.4 promises one
340 * motionless star at the centre of the pinwheel; it is the fixed point the whole
341 * phenomenon is read against, and a sky that happened not to roll one there
342 * would be missing the only part of it anybody in Tower would have named.
343 *
344 * It lands at the observer's own radius on the window — the axis is horizontal
345 * from the floor, so this is a star on the horizon, not overhead.
346 */
347 sum += pow(max(0.0, 1.0 - th * STAR_SCALE / 0.11), 4.0) * vec3(1.0, 0.95, 0.86) * 1.6;
348 return sum * 2.6 * uStarGain;
349}
350`
351
9c891d2e 2026-09-08 Pavel Safronov352/** The bore between the world's end and the glazing. Plain structure, hazed. */
353const REVEAL_FRAG = /* glsl */`
7cc177f6 2026-09-07 Pavel Safronov354#include <common>
355#include <logdepthbuf_pars_fragment>
356uniform vec3 uTint;
357varying vec3 vWorld;
358${HAZE_GLSL}
359void main() {
360 #include <logdepthbuf_fragment>
361 vec3 T = towerTransmittance(uCam, vWorld);
9c891d2e 2026-09-08 Pavel Safronov362 gl_FragColor = vec4(uTint * uExposure * T + uHaze * uExposure * (1.0 - T), 1.0);
7cc177f6 2026-09-07 Pavel Safronov363 #include <colorspace_fragment>
364}
365`
366
9c891d2e 2026-09-08 Pavel Safronov367/**
368 * The sun cap: also a window, and what fills it is the back of the collector.
369 *
370 * §1.4 gave this cap "heavy glare management" and no view, on the assumption that
371 * it faces the sun. It does not face the sun — it faces the **collector**, which is
372 * 41.8° across from here against the sun's 0.53° and sits squarely between the two.
373 * Tower is inside its umbra permanently, so no direct sunlight has ever reached
374 * this cap and there is nothing here to manage (D-050).
375 *
376 * What there is instead is a 244 km disc of unlit structure. Its front is a mirror
377 * pointed at the sun; its back radiates the collector's own waste heat at about
378 * 245 K, which is infrared and therefore black. So the sky through this window is a
379 * black disc with stars around the edge of it — and the disc does not turn with
380 * them, because the collector flies in formation rather than spinning.
381 */
382const SUN_CAP_FRAG = /* glsl */`
da6bcb0e 2026-09-08 Pavel Safronov383#include <common>
384#include <logdepthbuf_pars_fragment>
385uniform vec3 uTint;
9c891d2e 2026-09-08 Pavel Safronov386uniform sampler2D uGlazing;
17ccee99 2026-09-08 Pavel Safronov387uniform float uClockAngle;
da6bcb0e 2026-09-08 Pavel Safronov388varying vec3 vWorld;
389${HAZE_GLSL}
9c891d2e 2026-09-08 Pavel Safronov390${STAR_GLSL}
17ccee99 2026-09-08 Pavel Safronov391
392/**
393 * One radial line on the collector face: from the hub out to rMax, at space angle
394 * a0. Polar rather than cartesian because the hit is already polar — r*sin(da) is
395 * the perpendicular distance to the line, and r*cos(da) is how far along it the
396 * point sits, which doubles as the end test.
397 *
398 * (No backticks in here: this whole shader is a JS template literal.)
399 */
400float radialMark(float r, float a, float a0, float rMax) {
401 float da = a - a0;
402 float along = r * cos(da);
403 if (along < 0.0 || along > rMax) return 0.0;
404 return 1.0 - smoothstep(${(CLOCK_MARK * 0.35).toFixed(3)},
405 ${CLOCK_MARK.toFixed(3)}, abs(r * sin(da)));
406}
407
da6bcb0e 2026-09-08 Pavel Safronov408void main() {
409 #include <logdepthbuf_fragment>
9c891d2e 2026-09-08 Pavel Safronov410 vec2 uv = vWorld.xy / ${(2 * R).toFixed(1)} + 0.5;
411 vec4 frame = texture2D(uGlazing, uv);
412 vec3 mullion = pow(frame.rgb, vec3(2.2)) * uTint * uExposure;
413
414 vec3 dir = normalize(vWorld - uCam);
415 vec3 sky = starField(dir);
49b0d64d 2026-09-08 Pavel Safronov416 /**
417 * Cast on through the window to the collector's plane. Anything landing on the
418 * disc sees its unlit back, which is as black as the sky is not — and the edge,
419 * which is the opposite: structure standing proud of the mirror plane with the
420 * sun striking it side-on (D-054). So the collector reads the way any backlit
421 * thing does, as a hole in the stars with a lit rim, rather than as an absence.
422 */
9c891d2e 2026-09-08 Pavel Safronov423 float t = (${COLLECTOR_Z.toFixed(1)} - vWorld.z) / min(dir.z, -1e-4);
49b0d64d 2026-09-08 Pavel Safronov424 if (t > 0.0) {
425 float r = length(vWorld.xy + dir.xy * t);
426 sky = mix(sky, vec3(0.0), step(r, ${COLLECTOR_R.toFixed(2)}));
427 // Tight core for the structure itself, and a wide dim skirt for the scatter
428 // around it. One exponential alone reads as a drawn outline rather than light.
17ccee99 2026-09-08 Pavel Safronov429 /**
430 * The clock (D-055). Both marks are read in the collector's own frame, which
431 * is why the observed azimuth has uSpin added to it exactly as the star field
432 * does: the collector does not co-rotate, so from inside its whole face wheels
433 * once every 7 min 29 s. That is also why the time is the *angle between* the
434 * two lines rather than the position of either — the pair survives a rotation
435 * that neither line does alone.
436 */
437 vec2 hp = vWorld.xy + dir.xy * t;
438 float a = atan(hp.y, hp.x) + uSpin;
439 sky += vec3(0.78, 0.86, 1.0)
440 * (radialMark(r, a, ${(Math.PI / 2).toFixed(5)}, ${COLLECTOR_R.toFixed(2)})
441 + radialMark(r, a, ${(Math.PI / 2).toFixed(5)} + uClockAngle, ${CLOCK_HAND_R.toFixed(2)}))
442 * (0.3 + 0.7 * uStarGain) * 0.55;
443
49b0d64d 2026-09-08 Pavel Safronov444 float d = abs(r - ${COLLECTOR_R.toFixed(2)});
445 float rim = exp(-d / ${COLLECTOR_RIM.toFixed(3)})
446 + 0.10 * exp(-d / ${(COLLECTOR_RIM * 3).toFixed(3)});
447 /**
448 * Not scaled by uExposure — the sun does not dim when Tower banks its beam.
449 * It does fade as the eye adapts, like the stars, but from a much higher floor:
450 * this is sunlit structure, and it stays visible at noon where a star does not.
451 */
452 sky += vec3(1.0, 0.93, 0.78) * rim * (0.3 + 0.7 * uStarGain) * 0.42;
453 }
9c891d2e 2026-09-08 Pavel Safronov454
455 vec3 face = mix(sky, mullion, frame.a);
da6bcb0e 2026-09-08 Pavel Safronov456 vec3 T = towerTransmittance(uCam, vWorld);
9c891d2e 2026-09-08 Pavel Safronov457 gl_FragColor = vec4(face * T + uHaze * uExposure * (1.0 - T), 1.0);
da6bcb0e 2026-09-08 Pavel Safronov458 #include <colorspace_fragment>
459}
460`
461
769726ea 2026-09-08 Pavel Safronov462/**
463 * The far cap: a window, and the frame holding it.
464 *
465 * `uGlazing`'s alpha is the mask — opaque where there is a mullion, clear where
466 * there is a pane. Through a pane is deep anti-sun space (§1.4): black, with stars
467 * in it, and the haze in front doing the rest. By day the airlight term swamps
468 * everything and the cap is simply blue, which is what §1.5 already said would
469 * happen to anything 540 km away. At night the airlight collapses with the beam
470 * and what is left is the sky.
471 */
472const GLAZED_CAP_FRAG = /* glsl */`
473#include <common>
474#include <logdepthbuf_pars_fragment>
475uniform vec3 uTint;
476uniform sampler2D uGlazing;
477varying vec3 vWorld;
478${HAZE_GLSL}
479${STAR_GLSL}
480void main() {
481 #include <logdepthbuf_fragment>
482 vec2 uv = vWorld.xy / ${(2 * R).toFixed(1)} + 0.5;
483 vec4 frame = texture2D(uGlazing, uv);
484 vec3 mullion = pow(frame.rgb, vec3(2.2)) * uTint * uExposure;
485 /**
486 * The stars are NOT scaled by uExposure. Nothing Tower does to its own beam
487 * changes how bright Vega is — the exposure models the beam lighting the world,
488 * and the sky outside is not lit by it. uStarGain carries the other half of the
489 * same fact: the eye adapts, so the stars are there at noon and unseeable.
490 */
491 vec3 sky = starField(vWorld - uCam);
492 vec3 face = mix(sky, mullion, frame.a);
da6bcb0e 2026-09-08 Pavel Safronov493 /**
494 * The bullseye mirror (D-048) is returning 1.7 MW/m² back up the column, so it
495 * is lit by the beam rather than by the room: it blazes at noon, banks at night
496 * and goes out entirely when the beam does. The stars behind it do none of
497 * those, which is the whole difference between the two things in this shader.
498 *
499 * It is on the axis, but that is NOT the centre of the pinwheel: starField turns
500 * about the direction parallel to the axis, which lands on the window at the
501 * camera's own radius rather than at the cap's middle. The two are ~10° apart
502 * from the floor and coincide only for an observer standing on the axis.
503 */
504 float rc = length(vWorld.xy) / ${R.toFixed(1)};
505 float mirror = 1.0 - smoothstep(${(MIRROR_UV_RADIUS * 0.86).toFixed(5)},
506 ${MIRROR_UV_RADIUS.toFixed(5)}, rc);
507 face += mirror * vec3(1.0, 0.96, 0.88) * uExposure * 2.6;
769726ea 2026-09-08 Pavel Safronov508 vec3 T = towerTransmittance(uCam, vWorld);
509 gl_FragColor = vec4(face * T + uHaze * uExposure * (1.0 - T), 1.0);
510 #include <colorspace_fragment>
511}
512`
513
514
7cc177f6 2026-09-07 Pavel Safronov515const BEAM_VERT = /* glsl */`
516#include <common>
517#include <logdepthbuf_pars_vertex>
518varying vec3 vWorld;
519varying vec3 vNormal2;
520void main() {
521 vWorld = (modelMatrix * vec4(position, 1.0)).xyz;
522 vNormal2 = normalize(mat3(modelMatrix) * normal);
523 gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
524 #include <logdepthbuf_vertex>
525}
526`
527
528/**
529 * The beam is glowing air, not a surface, so it is brightest where the sightline
530 * passes through the most of it — down the middle — and fades at the limb. A tube
531 * shaded the usual way does the exact opposite and reads as a chrome pipe.
532 */
533const BEAM_FRAG = /* glsl */`
534#include <common>
535#include <logdepthbuf_pars_fragment>
536uniform vec3 uBeam;
537varying vec3 vWorld;
538varying vec3 vNormal2;
539${HAZE_GLSL}
540void main() {
541 #include <logdepthbuf_fragment>
542 vec3 view = normalize(vWorld - uCam);
543 // Additive white clips long before the limb, so the falloff has to start early
544 // or the shaft renders as a hard-edged slab instead of a glowing rod.
545 float through = pow(clamp(abs(dot(vNormal2, view)), 0.0, 1.0), 1.1);
546 vec3 T = towerTransmittance(uCam, vWorld);
547 gl_FragColor = vec4(uBeam * uExposure * through * T, 1.0);
548 #include <colorspace_fragment>
549}
550`
551
552// ------------------------------------------------------------------------- view
553
554export class InteriorView {
555 private renderer: WebGLRenderer
556 private scene = new Scene()
557 private camera: PerspectiveCamera
558 private ground: Mesh
559 private groundMat: ShaderMaterial
560 private shared: ShaderMaterial[] = []
561 private running = false
562 private lastFrame = 0
563 private dragging = false
564 private lastPointer = { x: 0, y: 0 }
565 private saveTimer: ReturnType<typeof setTimeout> | null = null
566 /** The height raster, read back so the observer can stand on the ground. */
567 private heights: { data: Uint8ClampedArray; w: number; h: number } | null = null
568
569 /** Where the observer stands. Default is the capital, at the centre of the world. */
570 at: SurfacePoint = { theta: 0, z: LENGTH_M / 2 }
571 /** Eye height above the floor, in metres. */
572 eyeM = EYE_MIN_M
573 /** Heading, radians clockwise from deepward (+z). */
574 yaw = 0
575 /** Elevation angle, radians. Clamped just short of straight up and down. */
576 pitch = 0
577 fovDeg = 60
578 lighting: Lighting = 'temperate'
769726ea 2026-09-08 Pavel Safronov579 /** Phase of the star pinwheel, radians. The only thing in the view that turns. */
580 private spin = 0
2bb3e0de 2026-09-08 Pavel Safronov581 /** Time of day, 0..1. 0 is midnight, 0.5 is noon. */
582 dayPhase = 0.5
1fcef8e6 2026-09-08 Pavel Safronov583 /**
41516065 2026-09-08 Pavel Safronov584 * Flight, on autopilot. Where you *look* and where you *go* are separate: the
585 * course is flown for you and the drag handlers only turn your head. That is the
586 * point of the mode — hand-flying makes you navigate a world you came to look at,
587 * and you end up watching your altitude instead of the view.
1fcef8e6 2026-09-08 Pavel Safronov588 */
589 flying = false
590 flySpeedKmS = 0.6
41516065 2026-09-08 Pavel Safronov591 /**
592 * Heading over the unrolled surface: 0 is deepward, π/2 spinward. Deliberately
593 * not axis-aligned, so the course is a long helix — you cross the world lengthwise
594 * *and* roll around it, which is the only way to see that the sky is ground.
595 */
596 private flyCourse = (18 * Math.PI) / 180
597 /** The terrain height the autopilot is currently holding above, smoothed. */
598 private flyGroundKm = 0
2bb3e0de 2026-09-08 Pavel Safronov599 /** Whether the clock is running, and how many world seconds pass per real one. */
600 cycling = false
601 cycleRate = 900
602 /**
603 * Whether the clock, rather than the Light menu, is deciding the light. Set by
604 * the panel: ticking the box or touching the time slider hands control over.
605 */
606 clockDriven = false
7cc177f6 2026-09-07 Pavel Safronov607
608 private labels: { place: Place; el: HTMLSpanElement }[] = []
609
610 constructor(
611 private canvas: HTMLCanvasElement,
612 private cb: InteriorCallbacks,
613 private labelHost?: HTMLElement,
614 ) {
615 this.renderer = new WebGLRenderer({ canvas, antialias: true, logarithmicDepthBuffer: true })
616 this.renderer.setClearColor(HAZE_DAY)
617 /**
618 * Half a metre to 1,500 km is a depth range no fixed-point buffer survives, and
619 * both ends are required: the near plane has to clear an eye height of 1.7 m,
620 * and the far plane has to reach the far cap 540 km away. Hence the logarithmic
621 * depth buffer above.
622 */
623 this.camera = new PerspectiveCamera(this.fovDeg, 1, 0.0005, 1500)
624
625 this.groundMat = this.buildGroundMaterial()
626 this.ground = this.buildGround()
627 this.scene.add(this.ground)
628 this.buildCaps()
629 this.buildBeam()
630
631 this.restoreState()
632 this.applyLighting()
633 this.attach()
634 this.place()
635 this.resize()
636 // The canvas is hidden until this view is opened, so its box is zero-sized at
637 // construction time when the view is restored on load. Same trap as D-029.
638 new ResizeObserver(() => this.resize()).observe(canvas)
639 }
640
641 // ------------------------------------------------------------------ geometry
642
643 private buildGroundMaterial(): ShaderMaterial {
644 const m = new ShaderMaterial({
645 vertexShader: GROUND_VERT,
646 fragmentShader: GROUND_FRAG,
647 side: BackSide,
648 uniforms: {
649 uColor: { value: null }, uHeight: { value: null },
650 uTexel: { value: [1 / RASTER_WIDTH, 1 / RASTER_HEIGHT] },
651 uMppKm: { value: L / RASTER_WIDTH },
652 ...this.sharedUniforms(),
653 },
654 })
655 this.shared.push(m)
656 void this.loadRasters(m)
657 return m
658 }
659
660 private sharedUniforms(): Record<string, { value: unknown }> {
661 return {
662 uCam: { value: new Vector3() },
663 uK: { value: new Vector3(...K_RGB) },
664 uHaze: { value: new Color(HAZE_DAY) },
665 uExposure: { value: 1 },
769726ea 2026-09-08 Pavel Safronov666 // Only the glazed cap reads these, but every material carries them so the
667 // per-frame update can walk one list instead of knowing who wants what.
668 uSpin: { value: 0 },
669 uStarGain: { value: 0 },
2bb3e0de 2026-09-08 Pavel Safronov670 // Only the ground declares these; carried here so one loop can set them.
17ccee99 2026-09-08 Pavel Safronov671 uClockAngle: { value: 0 },
2bb3e0de 2026-09-08 Pavel Safronov672 uFrontZ: { value: 0 },
673 uFrontWidth: { value: 24 },
674 uBelowFront: { value: 1 },
675 uAboveFront: { value: 1 },
7cc177f6 2026-09-07 Pavel Safronov676 }
677 }
678
679 private buildGround(): Mesh {
680 const g = new CylinderGeometry(R, R, L, SEG_AROUND, SEG_ALONG, true)
681 g.rotateX(Math.PI / 2)
682 g.translate(0, 0, L / 2)
683 /**
684 * The rasters run x = z and y = theta from the map's cut; Three's cylinder runs
685 * u around and v along. Deriving theta from vertex positions instead would smear
686 * a column of triangles across the image, because the seam vertices are
687 * duplicated and atan2 gives both the same angle (D-023).
688 *
689 * Three builds the ring as x = R sin t, z = R cos t, so after rotateX the angle
690 * in the XY plane is t - pi/2; canon theta is measured from +X.
691 */
692 const uv = g.attributes['uv']!
693 const shift = 0.25 + mapThetaOrigin() / (2 * Math.PI)
694 for (let i = 0; i < uv.count; i++) {
695 uv.setXY(i, uv.getY(i), uv.getX(i) - shift)
696 }
697 uv.needsUpdate = true
698 return new Mesh(g, this.groundMat)
699 }
700
701 private async loadRasters(m: ShaderMaterial): Promise<void> {
702 const load = (name: string): Promise<Texture> =>
703 new Promise((resolve, reject) => {
704 new TextureLoader().load(`${import.meta.env.BASE_URL}${name}`, (tex) => {
705 tex.wrapS = RepeatWrapping
706 tex.wrapT = RepeatWrapping
707 // uv.y is computed rather than read from the file's row order, so no flip.
708 tex.flipY = false
709 tex.anisotropy = this.renderer.capabilities.getMaxAnisotropy()
710 resolve(tex)
711 }, undefined, reject)
712 })
713 try {
714 const [color, height] = await Promise.all([load(COLOR_RASTER), load(HEIGHT_RASTER)])
715 m.uniforms['uColor']!.value = color
716 m.uniforms['uHeight']!.value = height
717 m.needsUpdate = true
718 this.readHeights(height)
719 this.place()
720 // The panel's elevation readout was drawn before any of this existed.
721 this.cb.onLook()
722 } catch {
723 // No rasters yet. The shell still renders; it is simply blank.
724 }
725 }
726
727 /**
728 * Read the height raster back onto the CPU.
729 *
730 * The shell is displaced on the GPU, which is fine until somebody has to stand on
731 * it: the camera would otherwise sit 1.7 m above the *undisplaced* cylinder, which
732 * is underground everywhere the land is higher than that — which is everywhere.
733 * From inside the rock the ground is invisible (its underside is a front face, and
734 * the shell is drawn back-side only), so the view showed sky below the horizon and
735 * nothing else. There is no way to ask the GPU where the ground is, so the answer
736 * has to be computed here from the same data the shader displaces by.
737 */
738 private readHeights(tex: Texture): void {
739 const img = tex.image as CanvasImageSource & { width: number; height: number }
740 const canvas = document.createElement('canvas')
741 canvas.width = img.width
742 canvas.height = img.height
743 const ctx = canvas.getContext('2d', { willReadFrequently: false })
744 if (!ctx) return
745 ctx.drawImage(img, 0, 0)
746 try {
747 const { data } = ctx.getImageData(0, 0, img.width, img.height)
748 this.heights = { data, w: img.width, h: img.height }
749 } catch {
750 // A tainted canvas would only mean standing on the reference cylinder again.
751 }
752 }
753
754 /** Bilinear sample of the height raster, in km. Wraps in theta, clamps in z. */
755 private sampleHeightKm(u: number, v: number): number {
756 const src = this.heights
757 if (!src) return 0
758 const x = Math.min(src.w - 1.001, Math.max(0, u * src.w - 0.5))
759 const y = ((v * src.h - 0.5) % src.h + src.h) % src.h
760 const x0 = Math.floor(x), y0 = Math.floor(y)
761 const fx = x - x0, fy = y - y0
762 const at = (px: number, py: number): number =>
763 src.data[((py % src.h) * src.w + Math.min(src.w - 1, px)) * 4]! / 255
764 const top = at(x0, y0) * (1 - fx) + at(x0 + 1, y0) * fx
765 const bot = at(x0, y0 + 1) * (1 - fx) + at(x0 + 1, y0 + 1) * fx
766 return (RASTER_MIN_HEIGHT_M + (top * (1 - fy) + bot * fy) * RASTER_HEIGHT_SPAN_M) / KM
767 }
768
769 /**
770 * Height of the rendered ground under a surface point, in km.
771 *
772 * This has to agree with the *mesh*, not with the raster: the shell's cells are
773 * 0.7 km across and the raster's are 0.26 km, so the surface actually drawn is the
774 * raster sampled at the cell corners and stretched flat between them. Sampling the
775 * raster directly would put the observer's feet a few tens of metres off in rough
776 * country, and underground is a far worse error than hovering — so the four corners
777 * are interpolated, and half the spread across the cell is added as clearance. On
778 * the plains that clearance is nil; on a ridge it lifts you clear of the facet.
779 */
780 private groundKm(p: SurfacePoint): number {
781 if (!this.heights) return 0
782 const shift = 0.25 + mapThetaOrigin() / (2 * Math.PI)
783 const jf = (p.z / LENGTH_M) * SEG_ALONG
784 const iF = (normalizeTheta(p.theta - mapThetaOrigin()) / (2 * Math.PI) + shift) * SEG_AROUND
785 const j0 = Math.max(0, Math.min(SEG_ALONG - 1, Math.floor(jf)))
786 const i0 = Math.floor(iF)
787 const fj = Math.min(1, Math.max(0, jf - j0))
788 const fi = iF - i0
789 const corner = (di: number, dj: number): number =>
790 this.sampleHeightKm((j0 + dj) / SEG_ALONG, ((i0 + di) / SEG_AROUND - shift))
791 const c = [corner(0, 0), corner(1, 0), corner(0, 1), corner(1, 1)]
792 const lo = c[0]! * (1 - fj) + c[2]! * fj
793 const hi = c[1]! * (1 - fj) + c[3]! * fj
794 return lo * (1 - fi) + hi * fi + (Math.max(...c) - Math.min(...c)) / 2
795 }
796
797 /** Ground elevation under the observer, in metres. Reported by the panel. */
798 get elevationM(): number {
799 return this.groundKm(this.at) * KM
800 }
801
9c891d2e 2026-09-08 Pavel Safronov802 private glazedCapMaterial(): ShaderMaterial {
7cc177f6 2026-09-07 Pavel Safronov803 const m = new ShaderMaterial({
9c891d2e 2026-09-08 Pavel Safronov804 vertexShader: FLAT_VERT, fragmentShader: GLAZED_CAP_FRAG, side: DoubleSide,
7cc177f6 2026-09-07 Pavel Safronov805 uniforms: {
9c891d2e 2026-09-08 Pavel Safronov806 uTint: { value: new Color(0xbcc2cc) },
807 uGlazing: { value: capGlazingTexture() },
7cc177f6 2026-09-07 Pavel Safronov808 ...this.sharedUniforms(),
809 },
810 })
811 this.shared.push(m)
812 return m
813 }
814
9c891d2e 2026-09-08 Pavel Safronov815 private sunCapMaterial(): ShaderMaterial {
769726ea 2026-09-08 Pavel Safronov816 const m = new ShaderMaterial({
9c891d2e 2026-09-08 Pavel Safronov817 vertexShader: FLAT_VERT, fragmentShader: SUN_CAP_FRAG, side: DoubleSide,
769726ea 2026-09-08 Pavel Safronov818 uniforms: {
9c891d2e 2026-09-08 Pavel Safronov819 uTint: { value: new Color(0xffffff) },
820 uGlazing: { value: sunCapGlazingTexture() },
769726ea 2026-09-08 Pavel Safronov821 ...this.sharedUniforms(),
822 },
823 })
824 this.shared.push(m)
825 return m
826 }
827
de65ab10 2026-09-08 Pavel Safronov828 private revealMaterial(tint: number): ShaderMaterial {
829 const m = new ShaderMaterial({
830 vertexShader: FLAT_VERT, fragmentShader: REVEAL_FRAG, side: BackSide,
831 uniforms: { uTint: { value: new Color(tint) }, ...this.sharedUniforms() },
832 })
833 this.shared.push(m)
834 return m
835 }
836
7cc177f6 2026-09-07 Pavel Safronov837 /**
838 * The caps close the world at both ends. From the midpoint they are 270 km away
839 * and 4% transmitted (§1.8), so they are all but pure haze — which is the correct
840 * and slightly unnerving result, and worth drawing precisely because it is.
da6bcb0e 2026-09-08 Pavel Safronov841 *
842 * They are not the same object any more. The sun cap faces the sun and the
843 * collector and keeps its checker; the far cap looks into deep anti-sun space and
844 * is glazed (D-045), set a kilometre into its own frame so the wall depth of
845 * D-044 has somewhere to be visible from.
7cc177f6 2026-09-07 Pavel Safronov846 */
847 private buildCaps(): void {
da6bcb0e 2026-09-08 Pavel Safronov848 // The tint is a multiplier over the pattern, not the colour of the cap.
9c891d2e 2026-09-08 Pavel Safronov849 const sun = new Mesh(new RingGeometry(BEAM_R, R, 128), this.sunCapMaterial())
7cc177f6 2026-09-07 Pavel Safronov850 this.scene.add(sun)
da6bcb0e 2026-09-08 Pavel Safronov851
769726ea 2026-09-08 Pavel Safronov852 const far = new Mesh(new CircleGeometry(R, 128), this.glazedCapMaterial())
de65ab10 2026-09-08 Pavel Safronov853 far.position.z = L + HULL_D
7cc177f6 2026-09-07 Pavel Safronov854 this.scene.add(far)
de65ab10 2026-09-08 Pavel Safronov855
da6bcb0e 2026-09-08 Pavel Safronov856 // The reveal: a kilometre of bore standing between the end of the world and
857 // the glass. BackSide, because it is only ever seen from inside.
de65ab10 2026-09-08 Pavel Safronov858 const bore = new CylinderGeometry(R, R, HULL_D, 128, 1, true)
859 bore.rotateX(Math.PI / 2)
860 bore.translate(0, 0, L + HULL_D / 2)
861 this.scene.add(new Mesh(bore, this.revealMaterial(0x9aa0aa)))
7cc177f6 2026-09-07 Pavel Safronov862 }
863
864 private buildBeam(): void {
865 const g = new CylinderGeometry(BEAM_R, BEAM_R, L, 48, 1, true)
866 g.rotateX(Math.PI / 2)
867 g.translate(0, 0, L / 2)
868 const m = new ShaderMaterial({
869 vertexShader: BEAM_VERT, fragmentShader: BEAM_FRAG,
870 uniforms: { uBeam: { value: new Color(0xfff2d6).multiplyScalar(0.85) }, ...this.sharedUniforms() },
871 transparent: true, blending: AdditiveBlending, depthWrite: false, side: DoubleSide,
872 })
873 this.shared.push(m)
874 this.scene.add(new Mesh(g, m))
875 }
876
877 // -------------------------------------------------------------------- camera
878
879 /** Basis at the observer: up is toward the axis, deepward is +z, spinward is theta-hat. */
880 private basis(): { up: Vector3; spinward: Vector3; deepward: Vector3; eye: Vector3 } {
881 const { theta } = this.at
882 const radial = new Vector3(Math.cos(theta), Math.sin(theta), 0)
41516065 2026-09-08 Pavel Safronov883 /**
884 * Standing and flying differ in exactly one term: what you are held above. On
885 * the ground that is the terrain under your feet; in the air it is the
886 * autopilot's smoothed idea of it. Up is toward the axis either way — that is
887 * what up means in here, the direction gravity does not point — so the rest of
888 * the frame is shared rather than duplicated.
889 */
890 const ground = this.flying ? this.flyGroundKm : this.groundKm(this.at)
891 const s = R - ground - this.eyeM / KM
7cc177f6 2026-09-07 Pavel Safronov892 return {
893 up: radial.clone().negate(),
894 spinward: new Vector3(-Math.sin(theta), Math.cos(theta), 0),
895 deepward: new Vector3(0, 0, 1),
896 eye: new Vector3(radial.x * s, radial.y * s, this.at.z / KM),
897 }
898 }
899
1fcef8e6 2026-09-08 Pavel Safronov900 /**
41516065 2026-09-08 Pavel Safronov901 * Fly the course. Nothing in here reads `yaw` or `pitch` — that is the whole
902 * decoupling: input turns the head, the autopilot flies the aircraft.
1fcef8e6 2026-09-08 Pavel Safronov903 *
41516065 2026-09-08 Pavel Safronov904 * Worked in the unrolled surface (θ, z) rather than in 3D, because that is where
905 * the constraints live: the caps are limits on z and θ simply wraps. It falls out
906 * that the autopilot cannot fly through the floor or out through an end, with no
907 * collision test anywhere.
1fcef8e6 2026-09-08 Pavel Safronov908 */
909 private stepFlight(dt: number): void {
41516065 2026-09-08 Pavel Safronov910 const d = this.flySpeedKmS * dt
911 let z = this.at.z / KM + Math.cos(this.flyCourse) * d
912 if (z > L - 2 || z < 2) {
913 // Reflect off the cap: flip the along-axis component, keep the way it rolls.
914 this.flyCourse = Math.PI - this.flyCourse
915 z = Math.min(L - 2, Math.max(2, z))
1fcef8e6 2026-09-08 Pavel Safronov916 }
41516065 2026-09-08 Pavel Safronov917 const theta = normalizeTheta(this.at.theta + (Math.sin(this.flyCourse) * d) / R)
918 this.at = { theta, z: z * KM }
919
920 /**
921 * Terrain-following, asymmetric on purpose: climb at once, sink slowly. An
922 * autopilot that lags on the way up flies into the mountain, and one that drops
923 * into every valley as fast as it climbs reads as a fault rather than as
924 * flight.
925 */
926 const g = this.groundKm(this.at)
927 this.flyGroundKm = Math.max(g, this.flyGroundKm + (g - this.flyGroundKm) * Math.min(1, dt * 0.6))
1fcef8e6 2026-09-08 Pavel Safronov928 this.place()
929 this.cb.onLook()
930 }
931
932 setFlying(on: boolean): void {
933 if (on && !this.flying) {
41516065 2026-09-08 Pavel Safronov934 this.flyGroundKm = this.groundKm(this.at)
935 // Start out looking where you are going. After that the view is yours.
936 this.yaw = this.flyCourse
937 this.pitch = 0
1fcef8e6 2026-09-08 Pavel Safronov938 }
939 this.flying = on
940 this.place()
941 this.cb.onLook()
942 this.queueSave()
943 }
944
41516065 2026-09-08 Pavel Safronov945 /** What the autopilot holds: the slider's height, measured over the terrain. */
946 get altitudeM(): number { return this.eyeM }
1fcef8e6 2026-09-08 Pavel Safronov947
7cc177f6 2026-09-07 Pavel Safronov948 /** Put the camera where the observer is, pointing where they are looking. */
949 private place(): void {
950 const { up, spinward, deepward, eye } = this.basis()
951 const dir = deepward.clone().multiplyScalar(Math.cos(this.pitch) * Math.cos(this.yaw))
952 .addScaledVector(spinward, Math.cos(this.pitch) * Math.sin(this.yaw))
953 .addScaledVector(up, Math.sin(this.pitch))
954 this.camera.position.copy(eye)
955 this.camera.up.copy(up)
956 this.camera.lookAt(eye.clone().add(dir))
957 this.camera.fov = this.fovDeg
958 this.camera.updateProjectionMatrix()
959
960 for (const m of this.shared) (m.uniforms['uCam']!.value as Vector3).copy(eye)
961 }
962
963 /**
964 * Go and stand at a place.
965 *
966 * Selecting something moves the observer rather than turning them toward it: at
967 * ground level almost everything in Tower is over the haze horizon, so turning to
968 * face a place usually just points at haze. `facing` is for places with extent —
969 * stand at the head of a river and look down it — and is null for a point, where
970 * arriving *is* the answer and there is nothing to aim at.
971 */
972 visit(p: SurfacePoint, facing: SurfacePoint | null = null): void {
973 this.at = { theta: normalizeTheta(p.theta), z: Math.min(LENGTH_M, Math.max(0, p.z)) }
41516065 2026-09-08 Pavel Safronov974 // The altitude is the slider's, so arriving somewhere resamples the ground
975 // under the new position and leaves you in the air rather than on it.
976 if (this.flying) this.flyGroundKm = this.groundKm(this.at)
7cc177f6 2026-09-07 Pavel Safronov977 if (facing) this.yaw = this.headingTo(facing)
978 // Arriving somewhere looking at your feet is never what was wanted.
979 this.pitch = 0
980 this.place()
981 this.queueSave()
982 }
983
984 /** Heading from where the observer stands to a surface point, as a yaw. */
985 private headingTo(p: SurfacePoint): number {
986 const dz = (p.z - this.at.z) / KM
987 const dTheta = normalizeTheta(p.theta - this.at.theta)
988 const around = (dTheta > Math.PI ? dTheta - 2 * Math.PI : dTheta) * R
989 return Math.abs(around) + Math.abs(dz) < 1e-6 ? this.yaw : Math.atan2(around, dz)
990 }
991
2bb3e0de 2026-09-08 Pavel Safronov992 /**
993 * Where the day is, as a light level and a front position.
994 *
995 * Dawn and dusk are the interesting part and they are not a dimmer. The beam
996 * glows by itself (§1.3, D-046), so its power — and with it the sky, the airlight
997 * and the rod overhead — has to come up globally; there is no way to make one end
998 * of the axis brighter than the other. What the swarm *can* do is choose where to
999 * put the light once it is there, and it hands it down the world as a front. So a
1000 * Tower morning is a bright sky over dark ground, with a line of daylight coming
1001 * toward you at 225 m/s.
1002 */
1003 private sampleDay(): { watts: number; frontZ: number; below: number; above: number; kind: 'dawn' | 'dusk' | null } {
1004 const p = ((this.dayPhase % 1) + 1) % 1
1005 const sweep = DAY_SWEEP_FRACTION
1006 const DAWN = 0.25
1007 const DUSK = 0.75
1008 const smooth = (e0: number, e1: number, x: number): number => {
1009 const t = Math.min(1, Math.max(0, (x - e0) / (e1 - e0)))
1010 return t * t * (3 - 2 * t)
1011 }
1012 // Daylight everywhere, ignoring the front: up through dawn, down through dusk.
1013 const up = smooth(DAWN, DAWN + sweep, p)
1014 const down = smooth(DUSK, DUSK + sweep, p)
1015 const day = up - down
1016 // Noon is Earth clear-sky full; the rest of the day sits nearer temperate.
1017 const shape = day * (0.62 + 0.38 * Math.sin(Math.PI * Math.min(1, Math.max(0, (p - DAWN) / (DUSK - DAWN)))))
1018 const watts = BRIGHTNESS_NIGHT_W_M2 + (BRIGHTNESS_FULL_W_M2 - BRIGHTNESS_NIGHT_W_M2) * shape
1019
1020 /**
1021 * The front only exists while it is crossing. `below`/`above` are multipliers on
1022 * the ground's share of the light, so the side that has not had its morning yet
1023 * stays dark under a sky that is already bright.
1024 */
1025 let frontZ = -1e6, below = 1, above = 1
1026 let kind: 'dawn' | 'dusk' | null = null
1027 const nightShare = 0.04
1028 if (p >= DAWN && p < DAWN + sweep) {
1029 frontZ = ((p - DAWN) / sweep) * L
1030 below = 1
1031 above = nightShare
1032 kind = 'dawn'
1033 } else if (p >= DUSK && p < DUSK + sweep) {
1034 frontZ = ((p - DUSK) / sweep) * L
1035 below = nightShare
1036 above = 1
1037 kind = 'dusk'
1038 }
1039 return { watts, frontZ, below, above, kind }
1040 }
1041
1042 /**
1043 * Where the dawn line is relative to the observer, when there is one. Naming the
1044 * distance is the point: a front 90 km deepward arrives in about seven minutes,
1045 * and that is a thing you can wait for.
1046 */
1047 private frontNote(): string {
1048 const { frontZ, kind } = this.sampleDay()
1049 if (!kind) return ''
1050 /**
1051 * Sign matters and is easy to get backwards: z counts deepward from the sun
1052 * cap, and the front always travels deepward. So a front at a *lower* z than
1053 * the observer is sunward of them and still coming; a higher one has already
1054 * gone past.
1055 */
1056 const d = frontZ - this.at.z / KM
1057 if (Math.abs(d) < 1) return ` · the ${kind} line is here`
1058 return d < 0
1059 ? ` · ${kind} ${(-d).toFixed(0)} km sunward, coming`
1060 : ` · ${kind} passed, ${d.toFixed(0)} km deepward`
1061 }
1062
1063 /** Human clock, for the readout. Tower keeps Earth's 24 hours (D-053). */
1064 get clock(): string {
1065 const secs = (((this.dayPhase % 1) + 1) % 1) * DAY_LENGTH_S
1066 const h = Math.floor(secs / 3600)
1067 const m = Math.floor((secs % 3600) / 60)
1068 return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
1069 }
1070
1071 /**
1072 * Put the clock at a given time of day. Works whether or not the cycle is
1073 * running — the front and the light level are a function of the phase, not of
1074 * anything animating, so scrubbing is the same operation as ticking.
1075 */
1076 setDayPhase(p: number): void {
1077 this.dayPhase = ((p % 1) + 1) % 1
1078 this.applyLighting()
1079 this.cb.onLook()
1080 this.queueSave()
1081 }
1082
1083 setCycling(on: boolean): void {
1084 this.cycling = on
1085 if (on) this.clockDriven = true
1086 if (on) this.applyLighting()
1087 else {
1088 // Hand the manual settings back an unswept world.
1089 for (const m of this.shared) {
1090 m.uniforms['uBelowFront']!.value = 1
1091 m.uniforms['uAboveFront']!.value = 1
1092 }
1093 this.applyLighting()
1094 }
1095 this.queueSave()
1096 }
1097
7cc177f6 2026-09-07 Pavel Safronov1098 private applyLighting(): void {
1099 /**
1100 * Eyes adapt, so twilight is not 6.7x darker than temperate to look at. The
1101 * exponent compresses the canon watts into something a screen can show while
1102 * keeping the ordering and the ratios honest.
1103 */
17ccee99 2026-09-08 Pavel Safronov1104 // The hand tracks the time of day always — the clock runs whether or not it is
1105 // the thing deciding how bright the world is.
1106 for (const m of this.shared) {
1107 m.uniforms['uClockAngle']!.value = 2 * Math.PI * (((this.dayPhase % 1) + 1) % 1)
1108 }
2bb3e0de 2026-09-08 Pavel Safronov1109 const day = this.clockDriven ? this.sampleDay() : null
1110 const watts = day ? day.watts : LIGHTING[this.lighting]
1111 if (day) {
1112 for (const m of this.shared) {
1113 m.uniforms['uFrontZ']!.value = day.frontZ
1114 m.uniforms['uBelowFront']!.value = day.below
1115 m.uniforms['uAboveFront']!.value = day.above
1116 }
1117 }
392bb8a7 2026-09-08 Pavel Safronov1118 const e = (watts / BRIGHTNESS_TEMPERATE_W_M2) ** 0.35
769726ea 2026-09-08 Pavel Safronov1119 /**
1120 * The other half of "eyes adapt": the stars are always exactly as bright as
1121 * they are, and whether you can see them depends entirely on what your eyes
1122 * are currently set to. Full at night and below, gone by noon — 2% at
1123 * temperate, which is the honest answer to why nobody looks out at lunchtime.
1124 */
1125 const starGain = (BRIGHTNESS_NIGHT_W_M2 / Math.max(watts, BRIGHTNESS_NIGHT_W_M2)) ** 0.6
392bb8a7 2026-09-08 Pavel Safronov1126 const haze = new Color(watts <= BRIGHTNESS_TWILIGHT_W_M2 ? HAZE_DUSK : HAZE_DAY)
1127 for (const m of this.shared) {
1128 m.uniforms['uExposure']!.value = e
769726ea 2026-09-08 Pavel Safronov1129 m.uniforms['uStarGain']!.value = starGain
392bb8a7 2026-09-08 Pavel Safronov1130 ;(m.uniforms['uHaze']!.value as Color).copy(haze)
1131 }
1132 // Scaled, so `dark` clears to black rather than to a dusk-coloured sky that
1133 // nothing in the scene is bright enough to justify.
1134 this.renderer.setClearColor(haze.clone().multiplyScalar(e), 1)
7cc177f6 2026-09-07 Pavel Safronov1135 }
1136
1137 setLighting(l: Lighting): void {
1138 this.lighting = l
1139 this.applyLighting()
1140 this.queueSave()
1141 }
1142
1143 // ------------------------------------------------------------------- labels
1144
1145 setPlaces(places: readonly Place[]): void {
1146 if (!this.labelHost) return
1147 this.labelHost.replaceChildren()
1148 this.labels = places.map((place) => {
1149 const el = document.createElement('span')
1150 el.innerHTML = `${place.name.toUpperCase()}<small>${place.kind}</small>`
1151 el.style.pointerEvents = 'auto'
1152 el.style.cursor = 'pointer'
1153 el.onclick = () => this.cb.onPickPlace(place.id)
1154 this.labelHost!.append(el)
1155 return { place, el }
1156 })
1157 }
1158
1159 private placePoint(p: Place): SurfacePoint | null {
1160 const g = p.geometry
1161 if (g.type === 'point') return { theta: g.theta, z: g.z }
1162 if (g.type === 'band') return null
1163 const first = g.points[0]
1164 return first ? { theta: first[0], z: first[1] } : null
1165 }
1166
1167 /**
1168 * Captions for what is in view. Distant things are dropped once the haze has
1169 * eaten them, so the labels agree with what can actually be seen rather than
1170 * naming a mountain that is not there.
1171 */
1172 private updateLabels(): void {
1173 if (!this.labels.length) return
1174 const { width, height } = this.canvas.getBoundingClientRect()
1175 const eye = this.camera.position
1176 const v = new Vector3()
1177 const placed: { x: number; y: number }[] = []
1178 for (const { place, el } of this.labels) {
1179 const at = this.placePoint(place)
1180 if (!at) { el.style.display = 'none'; continue }
1181 const s = R
1182 v.set(s * Math.cos(at.theta), s * Math.sin(at.theta), at.z / KM)
1183 const dist = v.distanceTo(eye)
1184 v.project(this.camera)
1185 const x = ((v.x + 1) / 2) * width
1186 const y = ((1 - v.y) / 2) * height
1187 let visible = v.z < 1 && Math.abs(v.x) < 1 && Math.abs(v.y) < 1 && dist > 0.3
1188 // Roughly: green extinction at the mean density along the ray. Exact enough
1189 // to decide whether a caption is honest.
1190 if (visible && Math.exp(-K_GREEN * dist * 0.55) < 0.06) visible = false
1191 if (visible && placed.some((q) => Math.abs(q.x - x) < 150 && Math.abs(q.y - y) < 30)) {
1192 visible = false
1193 }
1194 el.style.display = visible ? 'block' : 'none'
1195 if (!visible) continue
1196 placed.push({ x, y })
1197 el.style.left = `${x}px`
1198 el.style.top = `${y}px`
1199 el.style.opacity = String(Math.max(0.35, Math.min(1, 1 - dist / 260)))
1200 }
1201 }
1202
1203 // --------------------------------------------------------------- interaction
1204
1205 private attach(): void {
1206 this.canvas.addEventListener('pointerdown', (e) => {
1207 this.dragging = true
1208 this.lastPointer = { x: e.clientX, y: e.clientY }
1209 this.canvas.setPointerCapture(e.pointerId)
1210 })
1211 this.canvas.addEventListener('pointermove', (e) => {
1212 if (!this.dragging) return
1213 const dx = e.clientX - this.lastPointer.x
1214 const dy = e.clientY - this.lastPointer.y
1215 this.lastPointer = { x: e.clientX, y: e.clientY }
1216 // Scale with the field of view, so zoomed in does not mean flung around.
1217 const rate = (this.fovDeg / 60) * 0.0032
1218 this.yaw -= dx * rate
1219 this.pitch = Math.max(-1.55, Math.min(1.55, this.pitch + dy * rate))
1220 this.place()
1221 this.cb.onLook()
1222 this.queueSave()
1223 })
1224 const end = (e: PointerEvent) => {
1225 this.dragging = false
1226 this.canvas.releasePointerCapture?.(e.pointerId)
1227 }
1228 this.canvas.addEventListener('pointerup', end)
1229 this.canvas.addEventListener('pointercancel', end)
1230 this.canvas.addEventListener('wheel', (e) => {
1231 e.preventDefault()
1232 this.fovDeg = Math.max(3, Math.min(90, this.fovDeg * (e.deltaY > 0 ? 1.08 : 1 / 1.08)))
1233 this.place()
1234 this.cb.onLook()
1235 this.queueSave()
1236 }, { passive: false })
1237 window.addEventListener('pagehide', () => this.save())
1238 }
1239
1240 setEyeHeight(m: number): void {
1241 this.eyeM = Math.max(EYE_MIN_M, Math.min(EYE_MAX_M, m))
1242 this.place()
1243 this.queueSave()
1244 }
1245
1246 // -------------------------------------------------------------------- state
1247
1248 private queueSave(): void {
1249 if (this.saveTimer) clearTimeout(this.saveTimer)
1250 this.saveTimer = setTimeout(() => this.save(), STATE_SAVE_MS)
1251 }
1252
1253 private save(): void {
1254 if (this.saveTimer) clearTimeout(this.saveTimer)
1255 this.saveTimer = null
1256 try {
1257 localStorage.setItem(STATE_KEY, JSON.stringify({
1258 theta: this.at.theta, z: this.at.z, eye: this.eyeM,
1259 yaw: this.yaw, pitch: this.pitch, fov: this.fovDeg,
1260 }))
1261 } catch {
1262 // Not persisting where you stood is survivable.
1263 }
1264 }
1265
1266 /** Every field is checked: a bad entry here would strand the view (D-030). */
1267 private restoreState(): void {
1268 try {
1269 const raw = localStorage.getItem(STATE_KEY)
1270 if (!raw) return
1271 const s = JSON.parse(raw) as Record<string, unknown>
1272 const num = (v: unknown, lo: number, hi: number): number | null =>
1273 typeof v === 'number' && Number.isFinite(v) && v >= lo && v <= hi ? v : null
1274 const theta = num(s['theta'], 0, 2 * Math.PI)
1275 const z = num(s['z'], 0, LENGTH_M)
1276 if (theta !== null && z !== null) this.at = { theta, z }
1277 this.eyeM = num(s['eye'], EYE_MIN_M, EYE_MAX_M) ?? this.eyeM
1278 this.yaw = num(s['yaw'], -100, 100) ?? this.yaw
1279 this.pitch = num(s['pitch'], -1.55, 1.55) ?? this.pitch
1280 this.fovDeg = num(s['fov'], 3, 90) ?? this.fovDeg
392bb8a7 2026-09-08 Pavel Safronov1281 if (isLighting(s['lighting'])) {
7cc177f6 2026-09-07 Pavel Safronov1282 this.lighting = s['lighting']
1283 }
1284 } catch {
1285 // Unreadable storage just means standing in the capital again.
1286 }
1287 }
1288
1289 // -------------------------------------------------------------------- readout
1290
1291 /** What the instruments say from here. Rendered by the app into the overlay. */
1fcef8e6 2026-09-08 Pavel Safronov1292 report(): { standing: string; lines: string[]; verb: string } {
7cc177f6 2026-09-07 Pavel Safronov1293 const deg = (r: number) => ((r * 180) / Math.PI)
1294 const heading = normalizeTheta(this.yaw)
1295 const compass = ['deepward', 'spinward', 'sunward', 'antispinward'][
1296 Math.round(deg(heading) / 90) % 4
1297 ]!
1298 const elevation = this.elevationM
1fcef8e6 2026-09-08 Pavel Safronov1299 const m = (v: number) => (v < KM ? `${v.toFixed(0)} m` : `${(v / KM).toFixed(1)} km`)
1300 /**
1301 * How far the axis is, which is what sets the beam's apparent width — and while
1302 * flying that is the distance from the flier to the axis, not from the ground.
1303 */
1304 const overhead = this.flying
41516065 2026-09-08 Pavel Safronov1305 ? RADIUS_M - this.flyGroundKm * KM - this.eyeM
1fcef8e6 2026-09-08 Pavel Safronov1306 : RADIUS_M - elevation - this.eyeM
7cc177f6 2026-09-07 Pavel Safronov1307 const beamAngle = 2 * Math.atan(BEAM_RADIUS_M / overhead)
1fcef8e6 2026-09-08 Pavel Safronov1308 // Height above the datum, which is what gravity actually falls off with.
1309 const aboveDatum = this.flying ? RADIUS_M - overhead : elevation + this.eyeM
7cc177f6 2026-09-07 Pavel Safronov1310 const eyeText = this.eyeM < KM
1311 ? `${this.eyeM.toFixed(1)} m` : `${(this.eyeM / KM).toFixed(1)} km`
1312 return {
1fcef8e6 2026-09-08 Pavel Safronov1313 verb: this.flying ? 'flying' : 'standing',
7cc177f6 2026-09-07 Pavel Safronov1314 standing: `θ ${deg(this.at.theta).toFixed(1)}° z ${(this.at.z / KM).toFixed(1)} km`,
1315 lines: [
1316 `ground ${elevation.toFixed(0)} m above the datum`,
1fcef8e6 2026-09-08 Pavel Safronov1317 ...(this.flying
1318 ? [`altitude ${m(this.altitudeM)} over the ground, ${this.flySpeedKmS.toFixed(2)} km/s`,
41516065 2026-09-08 Pavel Safronov1319 `course ${deg(normalizeTheta(this.flyCourse)).toFixed(0)}° ${
1320 ['deepward', 'spinward', 'sunward', 'antispinward'][
1321 Math.round(deg(normalizeTheta(this.flyCourse)) / 90) % 4]!}, on autopilot`,
1fcef8e6 2026-09-08 Pavel Safronov1322 `height ${m(aboveDatum)} · ${(gravityAtHeight(aboveDatum) / G0).toFixed(4)} g`]
1323 : [`eye ${eyeText} · ${(gravityAtHeight(aboveDatum) / G0).toFixed(4)} g`]),
7cc177f6 2026-09-07 Pavel Safronov1324 `looking ${deg(heading).toFixed(0)}° ${compass}, ${deg(this.pitch) >= 0 ? '+' : ''}${deg(this.pitch).toFixed(0)}°`,
1325 `field ${this.fovDeg.toFixed(0)}°`,
1326 `beam ${deg(beamAngle).toFixed(2)}° wide overhead`,
2bb3e0de 2026-09-08 Pavel Safronov1327 ...(this.clockDriven ? [`time ${this.clock}${this.frontNote()}`] : []),
e27aaf20 2026-09-08 Pavel Safronov1328 // The pinwheel replaced the rain as the spin's evidence here (D-049), so
1329 // the readout names the period you can actually time against the sky.
1330 `sky turns once in ${fmtSpin()}, about the axis`,
7cc177f6 2026-09-07 Pavel Safronov1331 ],
1332 }
1333 }
1334
1335 resize(): void {
1336 const { width, height } = this.canvas.getBoundingClientRect()
1337 if (!width || !height) return
1338 this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
1339 this.renderer.setSize(width, height, false)
1340 this.camera.aspect = width / height
1341 this.camera.updateProjectionMatrix()
1342 }
1343
1344 start(): void {
1345 if (this.running) return
1346 this.running = true
1347 this.lastFrame = performance.now()
1348 const tick = (now: number) => {
1349 if (!this.running) return
1350 const dt = Math.min(0.1, (now - this.lastFrame) / 1000)
1351 this.lastFrame = now
e27aaf20 2026-09-08 Pavel Safronov1352 /**
769726ea 2026-09-08 Pavel Safronov1353 * The pinwheel. Nothing inside Tower rotates — you, the ground and the air
1354 * all turn together — but the sky through the far cap does not, so it wheels
1355 * once every 7 min 29 s about the one motionless star on the axis. It is the
1356 * only unambiguous evidence of the spin anywhere in the view (D-045).
1357 */
2bb3e0de 2026-09-08 Pavel Safronov1358 if (this.cycling) {
1359 this.dayPhase = (this.dayPhase + (dt * this.cycleRate) / DAY_LENGTH_S) % 1
1360 this.applyLighting()
1361 this.cb.onLook()
1362 }
1fcef8e6 2026-09-08 Pavel Safronov1363 if (this.flying) this.stepFlight(dt)
769726ea 2026-09-08 Pavel Safronov1364 this.spin = (this.spin + OMEGA_RAD_S * dt) % (2 * Math.PI)
1365 for (const m of this.shared) m.uniforms['uSpin']!.value = this.spin
7cc177f6 2026-09-07 Pavel Safronov1366 this.renderer.render(this.scene, this.camera)
1367 this.updateLabels()
1368 requestAnimationFrame(tick)
1369 }
1370 requestAnimationFrame(tick)
1371 }
1372
1373 stop(): void {
1374 this.running = false
1375 }
1376}