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