tower / interior-flight / files / src / views / interior.ts
Branch interior-flight · 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, |
17ccee99 2026-09-08 Pavel Safronov | 28 | 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 Safronov | 30 | BRIGHTNESS_TWILIGHT_W_M2, G0, HULL_DEPTH_M, LENGTH_M, OMEGA_RAD_S, RADIUS_M, |
de65ab10 2026-09-08 Pavel Safronov | 31 | RAYLEIGH_FLOOR_PER_M, |
7cc177f6 2026-09-07 Pavel Safronov | 32 | SPECIFIC_GAS_CONSTANT, TEMPERATURE_K, gravityAtHeight, |
| 33 | } from '../canon/constants.js' | |
| 34 | import { type SurfacePoint, mapThetaOrigin, normalizeTheta } from '../canon/geometry.js' | |
| 35 | import type { Place } from '../world/schema.js' | |
| 36 | import { EYE_MAX_M, EYE_MIN_M } from '../ui/scales.js' | |
9c891d2e 2026-09-08 Pavel Safronov | 37 | import { |
| 38 | MIRROR_UV_RADIUS, capGlazingTexture, sunCapGlazingTexture, | |
| 39 | } from './cap-pattern.js' | |
7cc177f6 2026-09-07 Pavel Safronov | 40 | import { |
| 41 | COLOR_RASTER, HEIGHT_RASTER, RASTER_HEIGHT, RASTER_HEIGHT_SPAN_M, | |
| 42 | RASTER_MIN_HEIGHT_M, RASTER_WIDTH, | |
| 43 | } from './raster.js' | |
| 44 | ||
| 45 | const KM = 1000 | |
| 46 | const R = RADIUS_M / KM | |
| 47 | const L = LENGTH_M / KM | |
| 48 | const BEAM_R = BEAM_RADIUS_M / KM | |
da6bcb0e 2026-09-08 Pavel Safronov | 49 | /** Wall depth in km. The far cap's window is set this far into its own frame. */ |
de65ab10 2026-09-08 Pavel Safronov | 50 | const HULL_D = HULL_DEPTH_M / KM |
9c891d2e 2026-09-08 Pavel Safronov | 51 | /** The collector, in km: 122 km of radius flying 320 km ahead of the sun cap. */ |
| 52 | const COLLECTOR_R = COLLECTOR_DIAMETER_M / KM / 2 | |
| 53 | const COLLECTOR_Z = -COLLECTOR_STANDOFF_M / KM | |
49b0d64d 2026-09-08 Pavel Safronov | 54 | /** Width of the sunlit edge structure, in km (D-054). */ |
| 55 | const COLLECTOR_RIM = COLLECTOR_RIM_M / KM | |
17ccee99 2026-09-08 Pavel Safronov | 56 | /** Clock geometry on the collector face, in km (D-055). */ |
| 57 | const CLOCK_MARK = COLLECTOR_CLOCK_MARK_M / KM | |
| 58 | const CLOCK_HAND_R = COLLECTOR_R * COLLECTOR_CLOCK_HAND_FRACTION | |
7cc177f6 2026-09-07 Pavel Safronov | 59 | |
| 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 | */ | |
| 68 | const K_GREEN = RAYLEIGH_FLOOR_PER_M * KM | |
| 69 | const 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. */ | |
| 73 | const 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 | */ | |
| 84 | const HAZE_DAY = 0x9aa3ad | |
| 85 | const HAZE_DUSK = 0x8d8792 | |
| 86 | ||
| 87 | ||
| 88 | /** Shell tessellation. 0.7 km triangles: distant relief is real, your feet are not. */ | |
| 89 | const SEG_AROUND = 512 | |
| 90 | const SEG_ALONG = 768 | |
| 91 | ||
392bb8a7 2026-09-08 Pavel Safronov | 92 | /** |
| 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 Safronov | 102 | /** The pinwheel's period, the one interval in Tower you can time against the sky. */ |
| 103 | const 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 Safronov | 108 | export const LIGHTING = { |
392bb8a7 2026-09-08 Pavel Safronov | 109 | dark: 0, |
| 110 | night: BRIGHTNESS_NIGHT_W_M2, | |
7cc177f6 2026-09-07 Pavel Safronov | 111 | twilight: BRIGHTNESS_TWILIGHT_W_M2, |
| 112 | temperate: BRIGHTNESS_TEMPERATE_W_M2, | |
| 113 | full: BRIGHTNESS_FULL_W_M2, | |
| 114 | } as const | |
| 115 | export type Lighting = keyof typeof LIGHTING | |
392bb8a7 2026-09-08 Pavel Safronov | 116 | export const isLighting = (v: unknown): v is Lighting => |
| 117 | typeof v === 'string' && v in LIGHTING | |
7cc177f6 2026-09-07 Pavel Safronov | 118 | |
| 119 | const STATE_KEY = 'tower.interior' | |
| 120 | const STATE_SAVE_MS = 400 | |
| 121 | ||
| 122 | export 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 | */ | |
| 138 | const HAZE_GLSL = /* glsl */` | |
| 139 | uniform vec3 uCam; | |
| 140 | uniform vec3 uK; | |
| 141 | uniform vec3 uHaze; | |
| 142 | uniform float uExposure; | |
| 143 | const float A = ${DENSITY_A.toExponential(8)}; | |
| 144 | const float RR = ${(R * R).toFixed(4)}; | |
| 145 | ||
| 146 | vec3 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 | ||
| 160 | const GROUND_VERT = /* glsl */` | |
| 161 | #include <common> | |
| 162 | #include <logdepthbuf_pars_vertex> | |
| 163 | uniform sampler2D uHeight; | |
| 164 | uniform vec2 uTexel; | |
| 165 | uniform float uMppKm; | |
| 166 | varying vec2 vUv; | |
| 167 | varying vec3 vWorld; | |
| 168 | varying vec3 vNormal2; | |
| 169 | ||
| 170 | /** The raster is stretched across a declared working range, not 0..anything. */ | |
| 171 | float 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 | ||
| 176 | void 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 | ||
| 199 | const GROUND_FRAG = /* glsl */` | |
| 200 | #include <common> | |
| 201 | #include <logdepthbuf_pars_fragment> | |
| 202 | uniform sampler2D uColor; | |
2bb3e0de 2026-09-08 Pavel Safronov | 203 | uniform float uFrontZ; |
| 204 | uniform float uFrontWidth; | |
| 205 | uniform float uBelowFront; | |
| 206 | uniform float uAboveFront; | |
7cc177f6 2026-09-07 Pavel Safronov | 207 | varying vec2 vUv; |
| 208 | varying vec3 vWorld; | |
| 209 | varying vec3 vNormal2; | |
| 210 | ${HAZE_GLSL} | |
| 211 | ||
| 212 | /** Cheap value noise, to keep the ground from being flat colour underfoot. */ | |
| 213 | float hash(vec2 p) { | |
| 214 | return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); | |
| 215 | } | |
| 216 | float 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 | ||
| 223 | void 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 Safronov | 249 | /** |
| 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 Safronov | 260 | // The rest of the world is lit land, and lit land is what fills the shadows. |
2bb3e0de 2026-09-08 Pavel Safronov | 261 | vec3 lit = base * (0.34 + 0.80 * lambert) * uExposure * daylight; |
7cc177f6 2026-09-07 Pavel Safronov | 262 | |
| 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 | ||
| 269 | const FLAT_VERT = /* glsl */` | |
| 270 | #include <common> | |
| 271 | #include <logdepthbuf_pars_vertex> | |
| 272 | varying vec3 vWorld; | |
| 273 | void 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 Safronov | 280 | /** |
| 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 | */ | |
| 299 | const STAR_GLSL = /* glsl */` | |
| 300 | uniform float uSpin; | |
| 301 | uniform float uStarGain; | |
| 302 | ||
| 303 | float 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 | } | |
| 308 | vec2 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. | |
| 316 | const float STAR_SCALE = 46.0; | |
| 317 | ||
| 318 | vec3 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 Safronov | 352 | /** The bore between the world's end and the glazing. Plain structure, hazed. */ |
| 353 | const REVEAL_FRAG = /* glsl */` | |
7cc177f6 2026-09-07 Pavel Safronov | 354 | #include <common> |
| 355 | #include <logdepthbuf_pars_fragment> | |
| 356 | uniform vec3 uTint; | |
| 357 | varying vec3 vWorld; | |
| 358 | ${HAZE_GLSL} | |
| 359 | void main() { | |
| 360 | #include <logdepthbuf_fragment> | |
| 361 | vec3 T = towerTransmittance(uCam, vWorld); | |
9c891d2e 2026-09-08 Pavel Safronov | 362 | gl_FragColor = vec4(uTint * uExposure * T + uHaze * uExposure * (1.0 - T), 1.0); |
7cc177f6 2026-09-07 Pavel Safronov | 363 | #include <colorspace_fragment> |
| 364 | } | |
| 365 | ` | |
| 366 | ||
9c891d2e 2026-09-08 Pavel Safronov | 367 | /** |
| 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 | */ | |
| 382 | const SUN_CAP_FRAG = /* glsl */` | |
da6bcb0e 2026-09-08 Pavel Safronov | 383 | #include <common> |
| 384 | #include <logdepthbuf_pars_fragment> | |
| 385 | uniform vec3 uTint; | |
9c891d2e 2026-09-08 Pavel Safronov | 386 | uniform sampler2D uGlazing; |
17ccee99 2026-09-08 Pavel Safronov | 387 | uniform float uClockAngle; |
da6bcb0e 2026-09-08 Pavel Safronov | 388 | varying vec3 vWorld; |
| 389 | ${HAZE_GLSL} | |
9c891d2e 2026-09-08 Pavel Safronov | 390 | ${STAR_GLSL} |
17ccee99 2026-09-08 Pavel Safronov | 391 | |
| 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 | */ | |
| 400 | float 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 Safronov | 408 | void main() { |
| 409 | #include <logdepthbuf_fragment> | |
9c891d2e 2026-09-08 Pavel Safronov | 410 | 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 Safronov | 416 | /** |
| 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 Safronov | 423 | float t = (${COLLECTOR_Z.toFixed(1)} - vWorld.z) / min(dir.z, -1e-4); |
49b0d64d 2026-09-08 Pavel Safronov | 424 | 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 Safronov | 429 | /** |
| 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 Safronov | 444 | 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 Safronov | 454 | |
| 455 | vec3 face = mix(sky, mullion, frame.a); | |
da6bcb0e 2026-09-08 Pavel Safronov | 456 | vec3 T = towerTransmittance(uCam, vWorld); |
9c891d2e 2026-09-08 Pavel Safronov | 457 | gl_FragColor = vec4(face * T + uHaze * uExposure * (1.0 - T), 1.0); |
da6bcb0e 2026-09-08 Pavel Safronov | 458 | #include <colorspace_fragment> |
| 459 | } | |
| 460 | ` | |
| 461 | ||
769726ea 2026-09-08 Pavel Safronov | 462 | /** |
| 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 | */ | |
| 472 | const GLAZED_CAP_FRAG = /* glsl */` | |
| 473 | #include <common> | |
| 474 | #include <logdepthbuf_pars_fragment> | |
| 475 | uniform vec3 uTint; | |
| 476 | uniform sampler2D uGlazing; | |
| 477 | varying vec3 vWorld; | |
| 478 | ${HAZE_GLSL} | |
| 479 | ${STAR_GLSL} | |
| 480 | void 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 Safronov | 493 | /** |
| 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 Safronov | 508 | 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 Safronov | 515 | const BEAM_VERT = /* glsl */` |
| 516 | #include <common> | |
| 517 | #include <logdepthbuf_pars_vertex> | |
| 518 | varying vec3 vWorld; | |
| 519 | varying vec3 vNormal2; | |
| 520 | void 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 | */ | |
| 533 | const BEAM_FRAG = /* glsl */` | |
| 534 | #include <common> | |
| 535 | #include <logdepthbuf_pars_fragment> | |
| 536 | uniform vec3 uBeam; | |
| 537 | varying vec3 vWorld; | |
| 538 | varying vec3 vNormal2; | |
| 539 | ${HAZE_GLSL} | |
| 540 | void 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 | ||
| 554 | export 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 Safronov | 579 | /** Phase of the star pinwheel, radians. The only thing in the view that turns. */ |
| 580 | private spin = 0 | |
2bb3e0de 2026-09-08 Pavel Safronov | 581 | /** Time of day, 0..1. 0 is midnight, 0.5 is noon. */ |
| 582 | dayPhase = 0.5 | |
1fcef8e6 2026-09-08 Pavel Safronov | 583 | /** |
41516065 2026-09-08 Pavel Safronov | 584 | * 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 Safronov | 588 | */ |
| 589 | flying = false | |
| 590 | flySpeedKmS = 0.6 | |
41516065 2026-09-08 Pavel Safronov | 591 | /** |
| 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 Safronov | 599 | /** 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 Safronov | 607 | |
| 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 Safronov | 666 | // 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 Safronov | 670 | // Only the ground declares these; carried here so one loop can set them. |
17ccee99 2026-09-08 Pavel Safronov | 671 | uClockAngle: { value: 0 }, |
2bb3e0de 2026-09-08 Pavel Safronov | 672 | uFrontZ: { value: 0 }, |
| 673 | uFrontWidth: { value: 24 }, | |
| 674 | uBelowFront: { value: 1 }, | |
| 675 | uAboveFront: { value: 1 }, | |
7cc177f6 2026-09-07 Pavel Safronov | 676 | } |
| 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 Safronov | 802 | private glazedCapMaterial(): ShaderMaterial { |
7cc177f6 2026-09-07 Pavel Safronov | 803 | const m = new ShaderMaterial({ |
9c891d2e 2026-09-08 Pavel Safronov | 804 | vertexShader: FLAT_VERT, fragmentShader: GLAZED_CAP_FRAG, side: DoubleSide, |
7cc177f6 2026-09-07 Pavel Safronov | 805 | uniforms: { |
9c891d2e 2026-09-08 Pavel Safronov | 806 | uTint: { value: new Color(0xbcc2cc) }, |
| 807 | uGlazing: { value: capGlazingTexture() }, | |
7cc177f6 2026-09-07 Pavel Safronov | 808 | ...this.sharedUniforms(), |
| 809 | }, | |
| 810 | }) | |
| 811 | this.shared.push(m) | |
| 812 | return m | |
| 813 | } | |
| 814 | ||
9c891d2e 2026-09-08 Pavel Safronov | 815 | private sunCapMaterial(): ShaderMaterial { |
769726ea 2026-09-08 Pavel Safronov | 816 | const m = new ShaderMaterial({ |
9c891d2e 2026-09-08 Pavel Safronov | 817 | vertexShader: FLAT_VERT, fragmentShader: SUN_CAP_FRAG, side: DoubleSide, |
769726ea 2026-09-08 Pavel Safronov | 818 | uniforms: { |
9c891d2e 2026-09-08 Pavel Safronov | 819 | uTint: { value: new Color(0xffffff) }, |
| 820 | uGlazing: { value: sunCapGlazingTexture() }, | |
769726ea 2026-09-08 Pavel Safronov | 821 | ...this.sharedUniforms(), |
| 822 | }, | |
| 823 | }) | |
| 824 | this.shared.push(m) | |
| 825 | return m | |
| 826 | } | |
| 827 | ||
de65ab10 2026-09-08 Pavel Safronov | 828 | 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 Safronov | 837 | /** |
| 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 Safronov | 841 | * |
| 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 Safronov | 846 | */ |
| 847 | private buildCaps(): void { | |
da6bcb0e 2026-09-08 Pavel Safronov | 848 | // The tint is a multiplier over the pattern, not the colour of the cap. |
9c891d2e 2026-09-08 Pavel Safronov | 849 | const sun = new Mesh(new RingGeometry(BEAM_R, R, 128), this.sunCapMaterial()) |
7cc177f6 2026-09-07 Pavel Safronov | 850 | this.scene.add(sun) |
da6bcb0e 2026-09-08 Pavel Safronov | 851 | |
769726ea 2026-09-08 Pavel Safronov | 852 | const far = new Mesh(new CircleGeometry(R, 128), this.glazedCapMaterial()) |
de65ab10 2026-09-08 Pavel Safronov | 853 | far.position.z = L + HULL_D |
7cc177f6 2026-09-07 Pavel Safronov | 854 | this.scene.add(far) |
de65ab10 2026-09-08 Pavel Safronov | 855 | |
da6bcb0e 2026-09-08 Pavel Safronov | 856 | // 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 Safronov | 858 | 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 Safronov | 862 | } |
| 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 Safronov | 883 | /** |
| 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 Safronov | 892 | 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 Safronov | 900 | /** |
41516065 2026-09-08 Pavel Safronov | 901 | * 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 Safronov | 903 | * |
41516065 2026-09-08 Pavel Safronov | 904 | * 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 Safronov | 908 | */ |
| 909 | private stepFlight(dt: number): void { | |
41516065 2026-09-08 Pavel Safronov | 910 | 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 Safronov | 916 | } |
41516065 2026-09-08 Pavel Safronov | 917 | 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 Safronov | 928 | this.place() |
| 929 | this.cb.onLook() | |
| 930 | } | |
| 931 | ||
| 932 | setFlying(on: boolean): void { | |
| 933 | if (on && !this.flying) { | |
41516065 2026-09-08 Pavel Safronov | 934 | 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 Safronov | 938 | } |
| 939 | this.flying = on | |
| 940 | this.place() | |
| 941 | this.cb.onLook() | |
| 942 | this.queueSave() | |
| 943 | } | |
| 944 | ||
41516065 2026-09-08 Pavel Safronov | 945 | /** What the autopilot holds: the slider's height, measured over the terrain. */ |
| 946 | get altitudeM(): number { return this.eyeM } | |
1fcef8e6 2026-09-08 Pavel Safronov | 947 | |
7cc177f6 2026-09-07 Pavel Safronov | 948 | /** 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 Safronov | 974 | // 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 Safronov | 977 | 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 Safronov | 992 | /** |
| 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 Safronov | 1098 | 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 Safronov | 1104 | // 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 Safronov | 1109 | 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 Safronov | 1118 | const e = (watts / BRIGHTNESS_TEMPERATE_W_M2) ** 0.35 |
769726ea 2026-09-08 Pavel Safronov | 1119 | /** |
| 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 Safronov | 1126 | 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 Safronov | 1129 | m.uniforms['uStarGain']!.value = starGain |
392bb8a7 2026-09-08 Pavel Safronov | 1130 | ;(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 Safronov | 1135 | } |
| 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 Safronov | 1281 | if (isLighting(s['lighting'])) { |
7cc177f6 2026-09-07 Pavel Safronov | 1282 | 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 Safronov | 1292 | report(): { standing: string; lines: string[]; verb: string } { |
7cc177f6 2026-09-07 Pavel Safronov | 1293 | 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 Safronov | 1299 | 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 Safronov | 1305 | ? RADIUS_M - this.flyGroundKm * KM - this.eyeM |
1fcef8e6 2026-09-08 Pavel Safronov | 1306 | : RADIUS_M - elevation - this.eyeM |
7cc177f6 2026-09-07 Pavel Safronov | 1307 | const beamAngle = 2 * Math.atan(BEAM_RADIUS_M / overhead) |
1fcef8e6 2026-09-08 Pavel Safronov | 1308 | // 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 Safronov | 1310 | const eyeText = this.eyeM < KM |
| 1311 | ? `${this.eyeM.toFixed(1)} m` : `${(this.eyeM / KM).toFixed(1)} km` | |
| 1312 | return { | |
1fcef8e6 2026-09-08 Pavel Safronov | 1313 | verb: this.flying ? 'flying' : 'standing', |
7cc177f6 2026-09-07 Pavel Safronov | 1314 | 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 Safronov | 1317 | ...(this.flying |
| 1318 | ? [`altitude ${m(this.altitudeM)} over the ground, ${this.flySpeedKmS.toFixed(2)} km/s`, | |
41516065 2026-09-08 Pavel Safronov | 1319 | `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 Safronov | 1322 | `height ${m(aboveDatum)} · ${(gravityAtHeight(aboveDatum) / G0).toFixed(4)} g`] |
| 1323 | : [`eye ${eyeText} · ${(gravityAtHeight(aboveDatum) / G0).toFixed(4)} g`]), | |
7cc177f6 2026-09-07 Pavel Safronov | 1324 | `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 Safronov | 1327 | ...(this.clockDriven ? [`time ${this.clock}${this.frontNote()}`] : []), |
e27aaf20 2026-09-08 Pavel Safronov | 1328 | // 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 Safronov | 1331 | ], |
| 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 Safronov | 1352 | /** |
769726ea 2026-09-08 Pavel Safronov | 1353 | * 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 Safronov | 1358 | 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 Safronov | 1363 | if (this.flying) this.stepFlight(dt) |
769726ea 2026-09-08 Pavel Safronov | 1364 | 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 Safronov | 1366 | 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 | } |