tower / files / src / views / exterior.ts
exterior.ts
5f38efd0 2026-09-07 Pavel Safronov | 1 | /** |
| 2 | * The exterior view: Tower seen from outside. See design.md §1.3, §1.4, §1.6. | |
| 3 | * | |
| 4 | * Scene units are kilometres, so the numbers in the code are the numbers in canon. | |
| 5 | * Tower's axis lies along world Z with the sun cap at z = 0, which makes a canon | |
| 6 | * surface point (theta, z) sit at (R cos theta, R sin theta, z) in the hull's own | |
| 7 | * frame — the hull then spins about Z and the frame carries the point with it. | |
| 8 | */ | |
11a13dbf 2026-09-07 Pavel Safronov | 9 | import type { Texture } from 'three' |
5f38efd0 2026-09-07 Pavel Safronov | 10 | import { |
6bb061a6 2026-09-14 Pavel Safronov | 11 | AmbientLight, BackSide, BufferGeometry, CircleGeometry, Color, CylinderGeometry, |
| 12 | DirectionalLight, DoubleSide, Float32BufferAttribute, FrontSide, Group, LineBasicMaterial, | |
e331ec2a 2026-09-28 Pavel Safronov | 13 | LineSegments, Matrix3, Matrix4, Mesh, MeshBasicMaterial, MeshStandardMaterial, PerspectiveCamera, |
6bb061a6 2026-09-14 Pavel Safronov | 14 | Raycaster, RepeatWrapping, RingGeometry, SRGBColorSpace, Scene, ShaderMaterial, |
2edbf472 2026-09-27 Pavel Safronov | 15 | SphereGeometry, CanvasTexture, Sprite, SpriteMaterial, Vector2, Vector3, WebGLRenderer, |
| 16 | AdditiveBlending, | |
5f38efd0 2026-09-07 Pavel Safronov | 17 | } from 'three' |
| 18 | import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js' | |
| 19 | import { | |
472390e2 2026-09-09 Pavel Safronov | 20 | BEAM_RADIUS_M, COLLECTOR_DIAMETER_M, COLLECTOR_RIM_M, COLLECTOR_STANDOFF_M, HULL_DEPTH_M, LENGTH_M, |
| 21 | OMEGA_RAD_S, RADIUS_M, | |
5f38efd0 2026-09-07 Pavel Safronov | 22 | } from '../canon/constants.js' |
11a13dbf 2026-09-07 Pavel Safronov | 23 | import { type SurfacePoint, mapThetaOrigin, normalizeTheta } from '../canon/geometry.js' |
376f2ce6 2026-09-16 Pavel Safronov | 24 | import { terrain } from '../terrain/service.js' |
28896dab 2026-09-27 Pavel Safronov | 25 | import { InteriorModel, STAR_GLSL, WINDOW_RENDER_ORDER } from './interior-model.js' |
59c12827 2026-09-28 Pavel Safronov | 26 | import { defaultCalendar } from '../world/schema.js' |
| 27 | import { clock } from '../world/clock.js' | |
| 28 | import { skyAt } from '../world/sky.js' | |
3cc2b5bd 2026-10-04 Pavel Safronov | 29 | import type { ExteriorShot } from '../world/photos.js' |
e331ec2a 2026-09-28 Pavel Safronov | 30 | import { skyMatrix, starTexture } from './stars.js' |
ab6ae94f 2026-09-28 Pavel Safronov | 31 | import { ROCK_COVER, buildRockHull, rockCover } from './rock-hull.js' |
6368edcf 2026-09-28 Pavel Safronov | 32 | import { type PipeHull, buildPipeHull } from './pipe-hull.js' |
99a2821b 2026-09-29 Pavel Safronov | 33 | import { CraftView } from './craft.js' |
| 34 | import { TourPlayer } from './tour-player.js' | |
| 35 | import { type ExtLeg, HULL_R, SUN_DRAWN_R, SUN_Z, planExterior } from '../tour/exterior-legs.js' | |
| 36 | import { legSeconds, tourRng } from '../tour/plan.js' | |
5f38efd0 2026-09-07 Pavel Safronov | 37 | |
| 38 | const KM = 1000 | |
| 39 | const R = RADIUS_M / KM | |
| 40 | const L = LENGTH_M / KM | |
| 41 | const BEAM_R = BEAM_RADIUS_M / KM | |
6bb061a6 2026-09-14 Pavel Safronov | 42 | /** Wall depth in km (D-044). The far cap sits at the outer face of it. */ |
de65ab10 2026-09-08 Pavel Safronov | 43 | const HULL_D = HULL_DEPTH_M / KM |
5f38efd0 2026-09-07 Pavel Safronov | 44 | const COLLECTOR_R = COLLECTOR_DIAMETER_M / KM / 2 |
9c891d2e 2026-09-08 Pavel Safronov | 45 | const COLLECTOR_STANDOFF = COLLECTOR_STANDOFF_M / KM |
472390e2 2026-09-09 Pavel Safronov | 46 | const COLLECTOR_RIM = COLLECTOR_RIM_M / KM |
15d933c9 2026-09-07 Pavel Safronov | 47 | /** How close to an end, in km, counts as being on a cap rather than on the wall. */ |
| 48 | const CAP_TOLERANCE = 8 | |
| 49 | const isCapZ = (zKm: number): boolean => zKm < CAP_TOLERANCE || zKm > L - CAP_TOLERANCE | |
| 50 | ||
| 51 | const CAMERA_KEY = 'tower.exteriorCamera' | |
| 52 | const CAMERA_SAVE_MS = 400 | |
5f38efd0 2026-09-07 Pavel Safronov | 53 | |
6bb061a6 2026-09-14 Pavel Safronov | 54 | /** The real light: along the axis, from beyond the collector. */ |
| 55 | const SUN_POSITION = new Vector3(0, 0, -8000) | |
| 56 | const SUN_COLOR = 0xfff3dd | |
| 57 | const SUN_INTENSITY = 2.2 | |
| 58 | ||
| 59 | const WORLD_VERT = /* glsl */` | |
| 60 | #include <common> | |
| 61 | varying vec3 vWorld; | |
| 62 | void main() { | |
| 63 | vWorld = (modelMatrix * vec4(position, 1.0)).xyz; | |
| 64 | gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); | |
| 65 | } | |
| 66 | ` | |
| 67 | ||
| 68 | /** | |
| 69 | * The collector's mirror face, lit by the sun and by nothing else. | |
| 70 | * | |
| 71 | * The specular term is Three's own for a metal under one directional light — GGX | |
| 72 | * distribution, correlated Smith visibility, Schlick Fresnel — so the glint is the | |
| 73 | * one MeshStandardMaterial drew. What is missing is the other lights (D-060). | |
| 74 | */ | |
| 75 | const MIRROR_FRAG = /* glsl */` | |
| 76 | #include <common> | |
| 77 | uniform vec3 uBase; | |
| 78 | uniform vec3 uF0; | |
| 79 | uniform vec3 uSun; | |
| 80 | uniform vec3 uToSun; | |
| 81 | uniform float uAlpha; | |
| 82 | varying vec3 vWorld; | |
| 83 | void main() { | |
| 84 | // Only ever seen from the sun side, so the normal is the sunward axis. | |
| 85 | vec3 n = vec3(0.0, 0.0, -1.0); | |
| 86 | vec3 v = normalize(cameraPosition - vWorld); | |
| 87 | vec3 l = normalize(uToSun); | |
| 88 | vec3 h = normalize(l + v); | |
| 89 | float nl = max(dot(n, l), 0.0); | |
| 90 | float nv = max(dot(n, v), 1e-4); | |
| 91 | float nh = max(dot(n, h), 0.0); | |
| 92 | float vh = max(dot(v, h), 0.0); | |
| 93 | float a2 = uAlpha * uAlpha; | |
| 94 | float dd = nh * nh * (a2 - 1.0) + 1.0; | |
| 95 | float D = a2 / (PI * dd * dd); | |
| 96 | float gv = nl * sqrt(a2 + (1.0 - a2) * nv * nv); | |
| 97 | float gl = nv * sqrt(a2 + (1.0 - a2) * nl * nl); | |
| 98 | float G = 0.5 / max(gv + gl, 1e-6); | |
| 99 | vec3 F = uF0 + (1.0 - uF0) * pow(1.0 - vh, 5.0); | |
| 100 | gl_FragColor = vec4(uBase + uSun * nl * F * G * D, 1.0); | |
| 101 | #include <colorspace_fragment> | |
| 102 | } | |
| 103 | ` | |
| 104 | ||
| 105 | /** The sky: the interior's star field, by direction from the camera. */ | |
| 106 | const SKY_FRAG = /* glsl */` | |
| 107 | #include <common> | |
| 108 | uniform vec3 uBase; | |
| 109 | varying vec3 vWorld; | |
| 110 | ${STAR_GLSL} | |
| 111 | void main() { | |
| 112 | gl_FragColor = vec4(uBase + starField(vWorld - cameraPosition), 1.0); | |
| 113 | #include <colorspace_fragment> | |
| 114 | } | |
| 115 | ` | |
| 116 | ||
908833aa 2026-09-28 Pavel Safronov | 117 | /** |
ab6ae94f 2026-09-28 Pavel Safronov | 118 | * How the hull is drawn (D-047, D-076, D-077, D-081): bare metal, the rock or piping |
| 119 | * experiments or both together, or the x-ray of the inside wrapped onto the smooth skin. | |
908833aa 2026-09-28 Pavel Safronov | 120 | */ |
ab6ae94f 2026-09-28 Pavel Safronov | 121 | export type HullMode = 'true' | 'rock' | 'pipes' | 'rockpipes' | 'xray' |
908833aa 2026-09-28 Pavel Safronov | 122 | |
5f38efd0 2026-09-07 Pavel Safronov | 123 | export interface ExteriorCallbacks { |
| 124 | onPickPoint: (p: SurfacePoint) => void | |
| 125 | } | |
| 126 | ||
| 127 | export class ExteriorView { | |
| 128 | private renderer: WebGLRenderer | |
| 129 | private scene = new Scene() | |
| 130 | private camera: PerspectiveCamera | |
| 131 | private controls: OrbitControls | |
6bb061a6 2026-09-14 Pavel Safronov | 132 | /** Everything that co-rotates with the hull. */ |
5f38efd0 2026-09-07 Pavel Safronov | 133 | private tower = new Group() |
| 134 | private hull: Mesh | |
11a13dbf 2026-09-07 Pavel Safronov | 135 | private hullMat: MeshStandardMaterial |
6bb061a6 2026-09-14 Pavel Safronov | 136 | /** The inside of Tower, the same meshes and shaders the interior view stands in. */ |
| 137 | private model: InteriorModel | |
59c12827 2026-09-28 Pavel Safronov | 138 | /** The converging rays from the collector, dimmed with the beam at night. */ |
| 139 | private rays: LineSegments | null = null | |
e331ec2a 2026-09-28 Pavel Safronov | 140 | /** The sky sphere's catalogue frame, set from the clock's instant each frame (D-075). */ |
| 141 | private skyFrame = new Matrix3() | |
59c12827 2026-09-28 Pavel Safronov | 142 | private frames = 0 |
6bb061a6 2026-09-14 Pavel Safronov | 143 | private worldToTower = new Matrix4() |
| 144 | private eyeInTower = new Vector3() | |
11a13dbf 2026-09-07 Pavel Safronov | 145 | private terrainMap: Texture | null = null |
aa201500 2026-09-14 Pavel Safronov | 146 | /** True mode by default (D-061); the app sets the remembered reading on construction. */ |
908833aa 2026-09-28 Pavel Safronov | 147 | private _hullMode: HullMode = 'true' |
| 148 | /** The rock shell (D-076), built the first time it is asked for. */ | |
| 149 | private rock: Mesh | null = null | |
6368edcf 2026-09-28 Pavel Safronov | 150 | /** The piping (D-077), built the first time it is asked for, like the rock. */ |
| 151 | private pipes: PipeHull | null = null | |
ab6ae94f 2026-09-28 Pavel Safronov | 152 | /** |
| 153 | * Both at once (D-081): rock heaped on the plant as shielding, cut away where the plant | |
| 154 | * is bare. Its own pair, since the plant crowds into the bare ground and the rock is dug | |
| 155 | * clear of the thrusters, so neither half is the same as the dress on its own. | |
| 156 | */ | |
| 157 | private mixed: { rock: Mesh; pipes: PipeHull } | null = null | |
908833aa 2026-09-28 Pavel Safronov | 158 | /** The metal skin past each end of the world, which the rock replaces. */ |
| 159 | private metalEnds: Mesh[] = [] | |
5f38efd0 2026-09-07 Pavel Safronov | 160 | private raycaster = new Raycaster() |
| 161 | private running = false | |
| 162 | private lastFrame = 0 | |
| 163 | private moved = false | |
15d933c9 2026-09-07 Pavel Safronov | 164 | private cameraSaveTimer: ReturnType<typeof setTimeout> | null = null |
5f38efd0 2026-09-07 Pavel Safronov | 165 | |
| 166 | /** Spin phase in radians, shared with the rest of the app. */ | |
| 167 | phase = 0 | |
| 168 | /** Multiple of real time. Tower turns once per 7.5 min, which is slow to watch. */ | |
| 169 | timeScale = 60 | |
| 170 | spinning = true | |
3cc2b5bd 2026-10-04 Pavel Safronov | 171 | /** Held at a saved shot (`poseShot`): the hull does not turn and the plant does not vent. */ |
| 172 | private still = false | |
5f38efd0 2026-09-07 Pavel Safronov | 173 | |
8b6287ba 2026-09-07 Pavel Safronov | 174 | /** Screen-space captions, so nothing in the scene is left unexplained. */ |
| 175 | private labels: { at: Vector3; el: HTMLSpanElement }[] = [] | |
| 176 | ||
99a2821b 2026-09-29 Pavel Safronov | 177 | /** The scenic tour outside (tour/exterior-legs.ts), while one is playing. */ |
| 178 | private tour: TourPlayer<ExtLeg> | null = null | |
| 179 | private player: TourPlayer<ExtLeg> | |
| 180 | /** Where the camera was, and the clock, when the tour took them: given back when it stops. */ | |
| 181 | private tourSaved: { pos: Vector3; target: Vector3; hours: number; running: boolean } | null = null | |
| 182 | /** The tour's spacecraft. */ | |
| 183 | private crafts = new CraftView((c) => new MeshStandardMaterial({ color: c, roughness: 0.55, metalness: 0.35 })) | |
| 184 | ||
8b6287ba 2026-09-07 Pavel Safronov | 185 | constructor( |
| 186 | private canvas: HTMLCanvasElement, | |
| 187 | private cb: ExteriorCallbacks, | |
| 188 | private labelHost?: HTMLElement, | |
| 189 | ) { | |
5f38efd0 2026-09-07 Pavel Safronov | 190 | this.renderer = new WebGLRenderer({ canvas, antialias: true }) |
| 191 | this.renderer.setClearColor(0x05070d) | |
a3f01bd7 2026-09-08 Pavel Safronov | 192 | // Near plane at 2 km rather than 8: the wall is only a kilometre thick (D-044), |
| 193 | // so anywhere close enough to read it as depth is inside the old near plane. | |
| 194 | this.camera = new PerspectiveCamera(42, 1, 2, 30_000) | |
5f38efd0 2026-09-07 Pavel Safronov | 195 | // Framed to hold the whole system: the collector is 244 km across and 320 km |
| 196 | // ahead of the sun cap, so Tower alone is not the subject. | |
| 197 | this.camera.position.set(260, -1180, 300) | |
| 198 | this.controls = new OrbitControls(this.camera, canvas) | |
| 199 | this.controls.target.set(0, 0, (L - COLLECTOR_STANDOFF) / 2) | |
| 200 | this.controls.enableDamping = true | |
| 201 | this.controls.dampingFactor = 0.08 | |
a3f01bd7 2026-09-08 Pavel Safronov | 202 | // 60 km, down from 120. A kilometre of wall is 1% of the cap's diameter, so it |
| 203 | // is a couple of pixels from anywhere the old limit allowed — the depth was | |
| 204 | // being drawn and could not be got close enough to to see. | |
| 205 | this.controls.minDistance = 60 | |
5f38efd0 2026-09-07 Pavel Safronov | 206 | this.controls.maxDistance = 6000 |
| 207 | ||
11a13dbf 2026-09-07 Pavel Safronov | 208 | this.hullMat = new MeshStandardMaterial() |
5f38efd0 2026-09-07 Pavel Safronov | 209 | this.hull = this.buildHull() |
376f2ce6 2026-09-16 Pavel Safronov | 210 | // Seen through a window from outside, the ground never needs more than a few |
| 211 | // levels: nothing finer survives 100 km of glass and haze. | |
6bb061a6 2026-09-14 Pavel Safronov | 212 | this.model = new InteriorModel({ |
376f2ce6 2026-09-16 Pavel Safronov | 213 | seeThrough: true, lod: { grid: 33, maxLevel: 3, split: 2.4, budget: 400 }, |
6bb061a6 2026-09-14 Pavel Safronov | 214 | }) |
| 215 | this.tower.add(this.hull, ...this.model.meshes) | |
5f38efd0 2026-09-07 Pavel Safronov | 216 | this.scene.add(this.tower) |
de65ab10 2026-09-08 Pavel Safronov | 217 | this.buildCollars() |
6bb061a6 2026-09-14 Pavel Safronov | 218 | this.buildAperture() |
5f38efd0 2026-09-07 Pavel Safronov | 219 | this.buildSunSystem() |
| 220 | this.buildStars() | |
| 221 | this.buildLights() | |
15d933c9 2026-09-07 Pavel Safronov | 222 | this.restoreCamera() |
8b6287ba 2026-09-07 Pavel Safronov | 223 | this.buildLabels() |
99a2821b 2026-09-29 Pavel Safronov | 224 | this.scene.add(this.crafts.group) |
| 225 | this.player = new TourPlayer<ExtLeg>(canvas.parentElement) | |
5f38efd0 2026-09-07 Pavel Safronov | 226 | this.attach() |
| 227 | this.resize() | |
8b6287ba 2026-09-07 Pavel Safronov | 228 | /** |
| 229 | * Watch the canvas box rather than only the window. Restoring this view on load | |
| 230 | * un-hides the canvas and constructs the renderer in the same task, before the | |
| 231 | * browser has laid the element out — so `resize()` sees a zero-sized box, bails, | |
| 232 | * and the renderer keeps its default 300x150 buffer stretched across the canvas. | |
| 233 | * Clicking the view later worked because layout had long since settled, which is | |
| 234 | * why this only appeared once the view began persisting across reloads. | |
| 235 | */ | |
| 236 | new ResizeObserver(() => this.resize()).observe(canvas) | |
99a2821b 2026-09-29 Pavel Safronov | 237 | // The screenshot tour's handle on the tour outside, beside the interior's `tower`. |
| 238 | ;(window as unknown as Record<string, unknown>)['towerOutside'] = this | |
5f38efd0 2026-09-07 Pavel Safronov | 239 | } |
| 240 | ||
| 241 | // ------------------------------------------------------------------ geometry | |
| 242 | ||
a3f01bd7 2026-09-08 Pavel Safronov | 243 | /** |
6bb061a6 2026-09-14 Pavel Safronov | 244 | * The outer skin. The inside of the wall is not drawn here any more: it is the |
| 245 | * interior view's own ground (D-060), displaced and hazed, and it is seen only | |
| 246 | * where there is a line of sight to it — in through the windows at either end. | |
a3f01bd7 2026-09-08 Pavel Safronov | 247 | * |
6bb061a6 2026-09-14 Pavel Safronov | 248 | * The skin is what Tower looks like: bare metal, or — in x-ray mode — the inner |
| 249 | * surface projected onto it, which is a diagram and not a view (D-047). | |
a3f01bd7 2026-09-08 Pavel Safronov | 250 | */ |
5f38efd0 2026-09-07 Pavel Safronov | 251 | private buildHull(): Mesh { |
14d35b7d 2026-09-16 Pavel Safronov | 252 | /** |
| 253 | * The skin is the wall's outer face, a kilometre out from the floor (D-044), not | |
| 254 | * the floor itself. While the ground was a raster that never went below the datum | |
| 255 | * by more than a lake, the difference could be ignored; generated ground has sea | |
| 256 | * floors 330 m down and tile skirts below that, and at radius R they came through | |
| 257 | * the hull as a lattice of lines (D-064). | |
| 258 | */ | |
| 259 | const g = new CylinderGeometry(R + HULL_D, R + HULL_D, L, 160, 1, true) | |
5f38efd0 2026-09-07 Pavel Safronov | 260 | g.rotateX(Math.PI / 2) |
| 261 | g.translate(0, 0, L / 2) | |
11a13dbf 2026-09-07 Pavel Safronov | 262 | this.remapHullUVs(g) |
| 263 | ||
| 264 | this.hullMat = new MeshStandardMaterial({ | |
a3f01bd7 2026-09-08 Pavel Safronov | 265 | color: 0x8892a3, roughness: 0.62, metalness: 0.28, side: FrontSide, |
11a13dbf 2026-09-07 Pavel Safronov | 266 | }) |
| 267 | const hull = new Mesh(g, this.hullMat) | |
| 268 | this.loadTerrain() | |
5f38efd0 2026-09-07 Pavel Safronov | 269 | return hull |
| 270 | } | |
| 271 | ||
11a13dbf 2026-09-07 Pavel Safronov | 272 | /** |
| 273 | * Rewrite the cylinder's UVs so the terrain raster lands where the map says it | |
| 274 | * should. Three's cylinder runs u around the circumference and v along the axis; | |
| 275 | * the raster is the other way round, with x = z and y = theta. | |
| 276 | * | |
| 277 | * Deriving the angle from vertex positions would be simpler and wrong: the seam | |
| 278 | * vertices are duplicated, so atan2 gives both the same angle and smears one | |
| 279 | * column of triangles across the whole image. Working from the original u keeps | |
| 280 | * it linear, and RepeatWrapping absorbs the offset. | |
| 281 | * | |
| 282 | * Three builds the ring as x = R sin t, z = R cos t, so after rotateX the angle | |
| 283 | * in the XY plane is t - pi/2. Canon theta is measured from +X, and the map is | |
| 284 | * cut at mapThetaOrigin — hence the constant. | |
| 285 | */ | |
| 286 | private remapHullUVs(g: CylinderGeometry): void { | |
| 287 | const uv = g.attributes['uv']! | |
| 288 | const shift = 0.25 + mapThetaOrigin() / (2 * Math.PI) | |
| 289 | for (let i = 0; i < uv.count; i++) { | |
| 290 | const around = uv.getX(i) | |
| 291 | const along = uv.getY(i) | |
376f2ce6 2026-09-16 Pavel Safronov | 292 | // Three's v is 1 at the cylinder's +y end, which rotateX carries to z = L, the far |
| 293 | // cap — so v already runs sun cap to far cap, as the image's x does. It used to be | |
| 294 | // flipped here, which put the Girdle's snow at the far cap end of the x-ray. | |
| 295 | uv.setXY(i, along, around - shift) | |
11a13dbf 2026-09-07 Pavel Safronov | 296 | } |
| 297 | uv.needsUpdate = true | |
| 298 | } | |
| 299 | ||
| 300 | /** | |
| 301 | * Paint the inner surface onto the outer hull. This is a projection, not a view: | |
| 302 | * from outside, Tower is structure, and what the texture shows is what is | |
| 303 | * *inside*. It is an x-ray, and useful precisely because the hull is opaque. | |
| 304 | */ | |
| 305 | private loadTerrain(): void { | |
376f2ce6 2026-09-16 Pavel Safronov | 306 | const adopt = (): void => { |
| 307 | const canvas = terrain().mapCanvas | |
| 308 | if (!canvas) return | |
| 309 | this.terrainMap?.dispose() | |
| 310 | const tex = new CanvasTexture(canvas) | |
| 311 | tex.colorSpace = SRGBColorSpace | |
| 312 | tex.wrapS = RepeatWrapping | |
| 313 | tex.wrapT = RepeatWrapping | |
| 314 | // uv.y is computed, not sampled from the image's row order, so no flip. | |
| 315 | tex.flipY = false | |
| 316 | tex.anisotropy = this.renderer.capabilities.getMaxAnisotropy() | |
| 317 | this.terrainMap = tex | |
| 318 | this.applyTerrain() | |
| 319 | } | |
| 320 | // The picture is the map's own, rendered live from the world (world-generation.md | |
| 321 | // §9), so it follows every edit. Until the first one is drawn the hull stays bare. | |
| 322 | terrain().onMap(adopt) | |
| 323 | adopt() | |
11a13dbf 2026-09-07 Pavel Safronov | 324 | } |
| 325 | ||
| 326 | private applyTerrain(): void { | |
908833aa 2026-09-28 Pavel Safronov | 327 | const on = this._hullMode === 'xray' && this.terrainMap |
11a13dbf 2026-09-07 Pavel Safronov | 328 | this.hullMat.map = on ? this.terrainMap : null |
| 329 | /** | |
| 330 | * Self-illuminate the terrain. Sunlight runs along the axis, so most of the | |
| 331 | * hull faces away from every light in the scene and the map would be legible | |
| 332 | * only on the sliver facing the fill. Emissive keeps it readable all the way | |
| 333 | * round while the directional lights still give the cylinder its form. | |
| 334 | */ | |
| 335 | this.hullMat.emissiveMap = on ? this.terrainMap : null | |
| 336 | this.hullMat.emissive.set(on ? 0xffffff : 0x000000) | |
| 337 | this.hullMat.emissiveIntensity = on ? 0.62 : 0 | |
| 338 | // Bare metal wants a tint; a textured surface wants to be left alone. | |
| 339 | this.hullMat.color.set(on ? 0xffffff : 0x8892a3) | |
| 340 | this.hullMat.metalness = on ? 0.06 : 0.28 | |
| 341 | this.hullMat.roughness = on ? 0.9 : 0.62 | |
| 342 | this.hullMat.needsUpdate = true | |
| 343 | } | |
| 344 | ||
908833aa 2026-09-28 Pavel Safronov | 345 | set hullMode(mode: HullMode) { |
| 346 | this._hullMode = mode | |
| 347 | if (mode === 'rock' && !this.rock) { | |
| 348 | this.rock = buildRockHull() | |
| 349 | this.tower.add(this.rock) | |
| 350 | } | |
6368edcf 2026-09-28 Pavel Safronov | 351 | if (mode === 'pipes' && !this.pipes) { |
| 352 | this.pipes = buildPipeHull() | |
| 353 | this.tower.add(this.pipes.group) | |
| 354 | } | |
ab6ae94f 2026-09-28 Pavel Safronov | 355 | if (mode === 'rockpipes' && !this.mixed) { |
| 356 | const cover = rockCover() | |
| 357 | const pipes = buildPipeHull((theta, z) => cover(theta, z) < ROCK_COVER - 0.06) | |
| 358 | // Dug clear round each pod: a sideways flame runs close along the hull. | |
| 359 | const rock = buildRockHull(rockCover(pipes.pods.map((p) => ({ ...p, r: 14 })))) | |
| 360 | this.mixed = { rock, pipes } | |
| 361 | this.tower.add(rock, pipes.group) | |
| 362 | } | |
908833aa 2026-09-28 Pavel Safronov | 363 | const rock = mode === 'rock' |
| 364 | if (this.rock) this.rock.visible = rock | |
6368edcf 2026-09-28 Pavel Safronov | 365 | if (this.pipes) this.pipes.group.visible = mode === 'pipes' |
ab6ae94f 2026-09-28 Pavel Safronov | 366 | if (this.mixed) this.mixed.rock.visible = this.mixed.pipes.group.visible = mode === 'rockpipes' |
6368edcf 2026-09-28 Pavel Safronov | 367 | // Piping keeps the cylinder and plates it; every other reading draws it as hullMat. |
ab6ae94f 2026-09-28 Pavel Safronov | 368 | const plant = mode === 'pipes' ? this.pipes : mode === 'rockpipes' ? this.mixed?.pipes : null |
| 369 | this.hull.material = plant ? plant.plating : this.hullMat | |
908833aa 2026-09-28 Pavel Safronov | 370 | // Hidden, the smooth skin still takes the clicks: raycasting ignores visibility, and a |
| 371 | // point on the cylinder is the canon point whatever is heaped on top of it. | |
| 372 | this.hull.visible = !rock | |
| 373 | for (const m of this.metalEnds) m.visible = !rock | |
11a13dbf 2026-09-07 Pavel Safronov | 374 | this.applyTerrain() |
| 375 | } | |
| 376 | ||
de65ab10 2026-09-08 Pavel Safronov | 377 | /** |
| 378 | * The kilometre of wall (D-044), which from outside is only visible as depth at | |
6bb061a6 2026-09-14 Pavel Safronov | 379 | * the two ends. There is no terrain on a collar — it is wall, not world. |
| 380 | * | |
| 381 | * The far collar is outer skin only: its inside is the interior model's bore, | |
| 382 | * hazed like everything else in there, and drawing both would put two surfaces at | |
| 383 | * one depth. The sun collar has no bore behind it — the sun cap window sits at the | |
| 384 | * inner face, z = 0 — so it keeps both faces, as the lip you look past to see in. | |
14d35b7d 2026-09-16 Pavel Safronov | 385 | * |
| 386 | * Since D-064 the wall is a kilometre thick around the whole circumference too, so | |
| 387 | * each end also gets its end face: a ring from the window's edge out to the skin. | |
de65ab10 2026-09-08 Pavel Safronov | 388 | */ |
| 389 | private buildCollars(): void { | |
14d35b7d 2026-09-16 Pavel Safronov | 390 | const metal = (side: typeof FrontSide | typeof DoubleSide) => new MeshStandardMaterial({ |
| 391 | color: 0x6f7787, roughness: 0.55, metalness: 0.42, side, | |
| 392 | }) | |
de65ab10 2026-09-08 Pavel Safronov | 393 | for (const z0 of [-HULL_D, L]) { |
14d35b7d 2026-09-16 Pavel Safronov | 394 | // The outer skin carried on past each end of the world, to the caps. |
| 395 | const outer = new CylinderGeometry(R + HULL_D, R + HULL_D, HULL_D, 160, 1, true) | |
| 396 | outer.rotateX(Math.PI / 2) | |
| 397 | outer.translate(0, 0, z0 + HULL_D / 2) | |
| 398 | // The end face of the wall: the kilometre between the window's edge and the skin. | |
| 399 | const face = new RingGeometry(R, R + HULL_D, 160) | |
| 400 | face.translate(0, 0, z0 < 0 ? z0 : L + HULL_D) | |
908833aa 2026-09-28 Pavel Safronov | 401 | const ends = [new Mesh(outer, metal(FrontSide)), new Mesh(face, metal(DoubleSide))] |
| 402 | this.metalEnds.push(...ends) | |
| 403 | this.tower.add(...ends) | |
de65ab10 2026-09-08 Pavel Safronov | 404 | } |
14d35b7d 2026-09-16 Pavel Safronov | 405 | /** |
| 406 | * The sun end's inner face has no bore behind it — the sun cap window sits at the | |
| 407 | * floor's end, z = 0 — so it is drawn here, as the lip you look past to see in. The | |
| 408 | * far end's inner face is the interior model's bore, hazed like everything in there. | |
| 409 | */ | |
| 410 | const lip = new CylinderGeometry(R, R, HULL_D, 160, 1, true) | |
| 411 | lip.rotateX(Math.PI / 2) | |
| 412 | lip.translate(0, 0, -HULL_D / 2) | |
| 413 | this.tower.add(new Mesh(lip, metal(DoubleSide))) | |
de65ab10 2026-09-08 Pavel Safronov | 414 | } |
| 415 | ||
14d35b7d 2026-09-16 Pavel Safronov | 416 | |
6bb061a6 2026-09-14 Pavel Safronov | 417 | /** |
| 418 | * The beam's way in: the hole in the middle of the sun cap's annulus, where the | |
| 419 | * converging rays arrive. The caps themselves are the interior model's windows | |
| 420 | * (D-060), so this is the one piece of the sun cap the exterior still draws. | |
| 421 | */ | |
| 422 | private buildAperture(): void { | |
5f38efd0 2026-09-07 Pavel Safronov | 423 | const aperture = new Mesh( |
| 424 | new CircleGeometry(BEAM_R, 48), | |
6bb061a6 2026-09-14 Pavel Safronov | 425 | new MeshBasicMaterial({ color: 0xfff0cf, side: DoubleSide })) |
| 426 | aperture.position.z = -0.05 | |
5f38efd0 2026-09-07 Pavel Safronov | 427 | this.tower.add(aperture) |
| 428 | } | |
| 429 | ||
| 430 | private buildSunSystem(): void { | |
472390e2 2026-09-09 Pavel Safronov | 431 | /** |
| 432 | * The collector flies ahead of the sun cap: 244 km across, wider than Tower. | |
| 433 | * | |
| 434 | * It has two sides and they do not look alike, so it is drawn as two meshes | |
| 435 | * rather than one double-sided disc. The face pointed at the sun is a mirror; | |
| 436 | * the side pointed at Tower — the one this camera almost always sees, since | |
| 437 | * Tower is between it and the viewer — is the back of that mirror, radiating | |
| 438 | * its waste heat at 245 K. That is infrared, so in visible light it is black | |
| 439 | * (D-050), which is exactly how the interior view draws it: a hole with a lit | |
| 440 | * rim. Drawing it pale from out here contradicted the window it is seen | |
| 441 | * through. Culling does the choosing: only one of the two faces survives for | |
| 442 | * any given camera, so the pair never fight over the same depth. | |
| 443 | */ | |
| 444 | const disc = new CircleGeometry(COLLECTOR_R, 128) | |
| 445 | // CircleGeometry faces +z, and +z from the collector is toward Tower. | |
| 446 | const back = new Mesh(disc, new MeshStandardMaterial({ | |
| 447 | color: 0x0b0e14, roughness: 0.95, metalness: 0.0, side: FrontSide, | |
| 448 | // Not black to the last digit: against black space a wholly unlit disc is | |
| 449 | // not a dark object, it is a missing one. This is the least glow that still | |
| 450 | // reads as a surface. | |
| 451 | emissive: new Color(0x171d27), emissiveIntensity: 1.0, | |
| 452 | })) | |
| 453 | back.position.z = -COLLECTOR_STANDOFF | |
| 454 | this.scene.add(back) | |
| 455 | /** | |
| 456 | * The mirror face. A flat mirror in vacuum shows what is in front of it, and | |
6bb061a6 2026-09-14 Pavel Safronov | 457 | * what is in front of this one is empty sky — so it is dark, with the sun's own |
| 458 | * reflection as a glint where the angle happens to line up. | |
| 459 | * | |
| 460 | * The glint is computed here rather than left to the scene's lights (D-060). As | |
| 461 | * a standard metal material the mirror reflected *every* light, and the scene | |
| 462 | * also carries fill lights that exist only to make the hull readable (§2.4) — so | |
| 463 | * a blue-white sweep of light that is not there crossed the disc beside the real | |
| 464 | * glint. A standard material cannot be told to ignore some lights, so this is a | |
| 465 | * shader that knows exactly one: the lobe Three drew for the sun at the same | |
| 466 | * roughness, and nothing for anything else. | |
472390e2 2026-09-09 Pavel Safronov | 467 | */ |
6bb061a6 2026-09-14 Pavel Safronov | 468 | const front = new Mesh(disc, new ShaderMaterial({ |
| 469 | vertexShader: WORLD_VERT, fragmentShader: MIRROR_FRAG, side: BackSide, | |
| 470 | uniforms: { | |
| 471 | uBase: { value: new Color(0x161b24) }, | |
| 472 | // Near-neutral silver. The old tint was a little blue, which is the colour | |
| 473 | // this change exists to take off the disc. | |
| 474 | uF0: { value: new Color(0xe8ebf0) }, | |
| 475 | uSun: { value: new Color(SUN_COLOR).multiplyScalar(SUN_INTENSITY) }, | |
| 476 | uToSun: { value: SUN_POSITION.clone().normalize() }, | |
| 477 | // Three squares roughness into alpha; 0.12 is what the metal material had. | |
| 478 | uAlpha: { value: 0.12 * 0.12 }, | |
| 479 | }, | |
472390e2 2026-09-09 Pavel Safronov | 480 | })) |
| 481 | front.position.z = -COLLECTOR_STANDOFF | |
| 482 | this.scene.add(front) | |
| 483 | /** | |
| 484 | * The sunlit rim (D-054): 4 km of structure standing proud of the mirror | |
| 485 | * plane at the edge, struck side-on by a sun nothing else here can see. It is | |
| 486 | * the only lit part of the collector from Tower's side, and it is what makes | |
| 487 | * the black disc read as a disc rather than as a gap in the stars. Drawn | |
| 488 | * outboard of the mirror instead of overlapping its edge, so the two surfaces | |
| 489 | * never share a depth. | |
| 490 | */ | |
| 491 | const rim = new Mesh( | |
| 492 | new RingGeometry(COLLECTOR_R, COLLECTOR_R + COLLECTOR_RIM, 128), | |
| 493 | new MeshBasicMaterial({ color: 0xffe6bb, side: DoubleSide })) | |
| 494 | rim.position.z = -COLLECTOR_STANDOFF | |
| 495 | this.scene.add(rim) | |
5f38efd0 2026-09-07 Pavel Safronov | 496 | |
| 497 | /** | |
8b6287ba 2026-09-07 Pavel Safronov | 498 | * The converging beam, drawn as rays from the mirror's rim to the aperture. |
| 499 | * | |
| 500 | * A translucent cone is the obvious construction and it fringes: at the mouth | |
| 501 | * the shell is edge-on, so its triangles project to slivers and the additive | |
| 502 | * blend stacks them into bright dashes across the collector. Isolated by | |
| 503 | * bisection — removing the cone cleared the disc, removing the stars did not, | |
| 504 | * and shortening the cone moved the dashes with its mouth. Rays have no | |
| 505 | * silhouette to alias, and read more like light besides. | |
| 506 | * | |
| 507 | * In vacuum none of this would be visible; there is nothing out here to | |
| 508 | * scatter it. Same licence as the hillshade on the map. | |
5f38efd0 2026-09-07 Pavel Safronov | 509 | */ |
8b6287ba 2026-09-07 Pavel Safronov | 510 | const RAYS = 220 |
| 511 | const verts = new Float32Array(RAYS * 6) | |
| 512 | const cols = new Float32Array(RAYS * 8) | |
| 513 | for (let i = 0; i < RAYS; i++) { | |
| 514 | const a = (i / RAYS) * Math.PI * 2 | |
| 515 | // Spread the starts across the mirror rather than only round its rim, so the | |
| 516 | // beam has a body instead of being a hollow funnel. | |
| 517 | const r = COLLECTOR_R * Math.sqrt(0.15 + 0.85 * ((i * 0.6180339887) % 1)) | |
| 518 | verts.set([ | |
| 519 | r * Math.cos(a), r * Math.sin(a), -COLLECTOR_STANDOFF, | |
| 520 | BEAM_R * Math.cos(a), BEAM_R * Math.sin(a), 0, | |
| 521 | ], i * 6) | |
| 522 | cols.set([1, 0.95, 0.8, 0.0, 1, 0.97, 0.86, 0.38], i * 8) | |
| 523 | } | |
| 524 | const beam = new BufferGeometry() | |
| 525 | beam.setAttribute('position', new Float32BufferAttribute(verts, 3)) | |
| 526 | beam.setAttribute('color', new Float32BufferAttribute(cols, 4)) | |
59c12827 2026-09-28 Pavel Safronov | 527 | const rays = this.rays = new LineSegments(beam, new LineBasicMaterial({ |
8b6287ba 2026-09-07 Pavel Safronov | 528 | vertexColors: true, transparent: true, |
| 529 | blending: AdditiveBlending, depthWrite: false, | |
28896dab 2026-09-27 Pavel Safronov | 530 | })) |
| 531 | // The same order as the windows, so the rays still sort against the glass they end at. | |
| 532 | rays.renderOrder = WINDOW_RENDER_ORDER | |
| 533 | this.scene.add(rays) | |
5f38efd0 2026-09-07 Pavel Safronov | 534 | } |
| 535 | ||
6bb061a6 2026-09-14 Pavel Safronov | 536 | /** |
| 537 | * The sky, as the interior sees it through its windows: the same star field | |
| 538 | * (D-056), sampled by direction from the camera, so the stars sit at infinity and | |
| 539 | * do not slide as the camera orbits (D-060). | |
| 540 | * | |
| 541 | * Out here nothing turns the sky — the camera is not riding the hull — so the | |
| 542 | * pinwheel's phase is held at zero, and there is no daylight for the eye to adapt | |
| 543 | * to, so the gain is full. The field it replaces was 3,500 points 26 km wide at | |
| 544 | * 18,000 km: about a pixel each, and on a retina display not there at all. | |
| 545 | */ | |
5f38efd0 2026-09-07 Pavel Safronov | 546 | private buildStars(): void { |
6bb061a6 2026-09-14 Pavel Safronov | 547 | const sky = new Mesh(new SphereGeometry(20_000, 48, 24), new ShaderMaterial({ |
| 548 | vertexShader: WORLD_VERT, fragmentShader: SKY_FRAG, side: BackSide, depthWrite: false, | |
| 549 | uniforms: { | |
| 550 | uBase: { value: new Color(0x05070d) }, uSpin: { value: 0 }, uStarGain: { value: 1 }, | |
e331ec2a 2026-09-28 Pavel Safronov | 551 | uStars: { value: starTexture() }, uSkyFrame: { value: new Matrix3() }, |
6bb061a6 2026-09-14 Pavel Safronov | 552 | }, |
| 553 | })) | |
e331ec2a 2026-09-28 Pavel Safronov | 554 | this.skyFrame = (sky.material as ShaderMaterial).uniforms['uSkyFrame']!.value as Matrix3 |
6bb061a6 2026-09-14 Pavel Safronov | 555 | // First, behind everything. And it surrounds the camera, which culling does not |
| 556 | // expect of a bounding sphere. | |
| 557 | sky.renderOrder = -1 | |
| 558 | sky.frustumCulled = false | |
| 559 | this.scene.add(sky) | |
5f38efd0 2026-09-07 Pavel Safronov | 560 | |
2edbf472 2026-09-27 Pavel Safronov | 561 | /** |
| 562 | * The sun: an opaque, near-white disc, so the stars behind it are hidden as they | |
| 563 | * would be. At half opacity it read as a beige plate with stars showing through it. | |
| 564 | * A soft halo round it, fading to nothing, makes it read as a light rather than a | |
| 565 | * paper disc. | |
| 566 | */ | |
| 567 | const sun = new Mesh( | |
5f38efd0 2026-09-07 Pavel Safronov | 568 | new SphereGeometry(300, 24, 24), |
2edbf472 2026-09-27 Pavel Safronov | 569 | new MeshBasicMaterial({ color: 0xfff7e6, side: BackSide })) |
99a2821b 2026-09-29 Pavel Safronov | 570 | sun.position.z = SUN_Z |
| 571 | sun.scale.setScalar(SUN_DRAWN_R / 300) | |
2edbf472 2026-09-27 Pavel Safronov | 572 | this.scene.add(sun) |
| 573 | const c = document.createElement('canvas') | |
| 574 | c.width = c.height = 128 | |
| 575 | const ctx = c.getContext('2d')! | |
| 576 | const g = ctx.createRadialGradient(64, 64, 0, 64, 64, 64) | |
| 577 | g.addColorStop(0, 'rgba(255, 240, 210, 0.9)') | |
| 578 | g.addColorStop(0.25, 'rgba(255, 226, 170, 0.35)') | |
| 579 | g.addColorStop(0.6, 'rgba(255, 210, 140, 0.08)') | |
| 580 | g.addColorStop(1, 'rgba(255, 200, 120, 0)') | |
| 581 | ctx.fillStyle = g | |
| 582 | ctx.fillRect(0, 0, 128, 128) | |
| 583 | const halo = new Sprite(new SpriteMaterial({ | |
| 584 | map: new CanvasTexture(c), blending: AdditiveBlending, depthWrite: false, | |
| 585 | })) | |
| 586 | halo.scale.setScalar(2400) | |
99a2821b 2026-09-29 Pavel Safronov | 587 | halo.position.z = SUN_Z |
2edbf472 2026-09-27 Pavel Safronov | 588 | this.scene.add(halo) |
5f38efd0 2026-09-07 Pavel Safronov | 589 | } |
| 590 | ||
8b6287ba 2026-09-07 Pavel Safronov | 591 | /** |
472390e2 2026-09-09 Pavel Safronov | 592 | * A dark disc and a glowing cone are not self-explanatory, and the collector is |
8b6287ba 2026-09-07 Pavel Safronov | 593 | * the least intuitive object in Tower: it is bigger than the world it feeds. |
| 594 | */ | |
| 595 | private buildLabels(): void { | |
| 596 | if (!this.labelHost) return | |
| 597 | const add = (at: Vector3, title: string, sub: string) => { | |
| 598 | const el = document.createElement('span') | |
| 599 | el.innerHTML = `${title}<small>${sub}</small>` | |
| 600 | this.labelHost!.append(el) | |
| 601 | this.labels.push({ at, el }) | |
| 602 | } | |
| 603 | add(new Vector3(0, COLLECTOR_R * 0.82, -COLLECTOR_STANDOFF), 'COLLECTOR', '244 km across') | |
| 604 | add(new Vector3(0, 96, -COLLECTOR_STANDOFF * 0.5), 'BEAM', '54.3 TW into a 3.2 km hole') | |
| 605 | add(new Vector3(0, R + 26, 0), 'SUN CAP', 'faces the sun') | |
| 606 | add(new Vector3(0, R + 26, L), 'FAR CAP', '540 km away') | |
| 607 | } | |
| 608 | ||
| 609 | private updateLabels(): void { | |
| 610 | if (!this.labels.length) return | |
| 611 | const { width, height } = this.canvas.getBoundingClientRect() | |
| 612 | const v = new Vector3() | |
15d933c9 2026-09-07 Pavel Safronov | 613 | const placed: { x: number; y: number }[] = [] |
8b6287ba 2026-09-07 Pavel Safronov | 614 | for (const { at, el } of this.labels) { |
| 615 | v.copy(at).project(this.camera) | |
| 616 | // z > 1 means the point is behind the camera; project wraps it to the far side. | |
15d933c9 2026-09-07 Pavel Safronov | 617 | let visible = v.z < 1 && Math.abs(v.x) < 1.6 && Math.abs(v.y) < 1.6 |
| 618 | const x = ((v.x + 1) / 2) * width | |
| 619 | const y = ((1 - v.y) / 2) * height | |
| 620 | // Drop captions that would land on one already placed. Close in on a cap, | |
| 621 | // all four project into the same corner and become an unreadable stack. | |
| 622 | if (visible && placed.some((p) => Math.abs(p.x - x) < 170 && Math.abs(p.y - y) < 36)) { | |
| 623 | visible = false | |
| 624 | } | |
8b6287ba 2026-09-07 Pavel Safronov | 625 | el.style.display = visible ? 'block' : 'none' |
| 626 | if (!visible) continue | |
15d933c9 2026-09-07 Pavel Safronov | 627 | placed.push({ x, y }) |
| 628 | el.style.left = `${x}px` | |
| 629 | el.style.top = `${y}px` | |
8b6287ba 2026-09-07 Pavel Safronov | 630 | } |
| 631 | } | |
| 632 | ||
5f38efd0 2026-09-07 Pavel Safronov | 633 | private buildLights(): void { |
| 634 | // The real light runs along the axis, from beyond the collector. | |
6bb061a6 2026-09-14 Pavel Safronov | 635 | const sun = new DirectionalLight(SUN_COLOR, SUN_INTENSITY) |
| 636 | sun.position.copy(SUN_POSITION) | |
5f38efd0 2026-09-07 Pavel Safronov | 637 | this.scene.add(sun) |
| 638 | ||
| 639 | /** | |
| 640 | * Fill light, and a reading aid rather than physics. Sunlight here is parallel | |
| 641 | * to the axis, so Tower's flank is edge-on to it and correctly renders almost | |
| 642 | * black — accurate and useless. These two lift the hull enough to read its | |
| 643 | * shape, the same licence as the hillshade on the map. | |
| 644 | */ | |
| 645 | const key = new DirectionalLight(0xc8d6f0, 1.5) | |
| 646 | key.position.set(-1, -1.4, 0.35) | |
| 647 | this.scene.add(key) | |
| 648 | const rim = new DirectionalLight(0x8fa8d8, 0.7) | |
| 649 | rim.position.set(1.2, 0.9, -0.4) | |
| 650 | this.scene.add(rim) | |
6bb061a6 2026-09-14 Pavel Safronov | 651 | // The far collar's outer face points away from the sun and from both fills, so |
| 652 | // it renders as a black band exactly when you focus on that end. | |
15d933c9 2026-09-07 Pavel Safronov | 653 | const behind = new DirectionalLight(0x9fb2d4, 0.55) |
| 654 | behind.position.set(-0.5, -0.8, 1) | |
| 655 | this.scene.add(behind) | |
5f38efd0 2026-09-07 Pavel Safronov | 656 | this.scene.add(new AmbientLight(0x3a4356, 1.6)) |
| 657 | } | |
| 658 | ||
15d933c9 2026-09-07 Pavel Safronov | 659 | // ------------------------------------------------------------------- camera |
| 660 | ||
| 661 | /** | |
| 662 | * Restore where the camera was left. Every field is checked before use: a | |
| 663 | * corrupt or stale entry here would leave the view pointing at nothing with no | |
| 664 | * obvious way back, and silently-bad persisted state has bitten this app once | |
| 665 | * already (D-025). | |
| 666 | */ | |
| 667 | private restoreCamera(): void { | |
| 668 | try { | |
| 669 | const raw = localStorage.getItem(CAMERA_KEY) | |
| 670 | if (!raw) return | |
| 671 | const s = JSON.parse(raw) as { p?: unknown; t?: unknown } | |
| 672 | const ok = (v: unknown): v is number[] => | |
| 673 | Array.isArray(v) && v.length === 3 && v.every((n) => typeof n === 'number' && Number.isFinite(n)) | |
| 674 | if (!ok(s.p) || !ok(s.t)) return | |
| 675 | const pos = new Vector3(s.p[0], s.p[1], s.p[2]) | |
| 676 | const target = new Vector3(s.t[0], s.t[1], s.t[2]) | |
| 677 | const d = pos.distanceTo(target) | |
| 678 | if (d < this.controls.minDistance || d > this.controls.maxDistance) return | |
| 679 | this.camera.position.copy(pos) | |
| 680 | this.controls.target.copy(target) | |
| 681 | this.controls.update() | |
| 682 | } catch { | |
| 683 | // Unreadable storage just means the default framing. | |
| 684 | } | |
| 685 | } | |
| 686 | ||
| 687 | private saveCamera(): void { | |
| 688 | if (this.cameraSaveTimer) clearTimeout(this.cameraSaveTimer) | |
| 689 | this.cameraSaveTimer = null | |
| 690 | try { | |
| 691 | const p = this.camera.position | |
| 692 | const t = this.controls.target | |
| 693 | localStorage.setItem(CAMERA_KEY, JSON.stringify({ | |
| 694 | p: [p.x, p.y, p.z].map((n) => Math.round(n * 100) / 100), | |
| 695 | t: [t.x, t.y, t.z].map((n) => Math.round(n * 100) / 100), | |
| 696 | })) | |
| 697 | } catch { | |
| 698 | // Not persisting the camera is survivable. | |
| 699 | } | |
| 700 | } | |
| 701 | ||
3cc2b5bd 2026-10-04 Pavel Safronov | 702 | // -------------------------------------------------------------------- photos |
| 703 | ||
| 704 | /** The numbers a photograph of this view is made of (world/photos.ts, D-099). */ | |
| 705 | shot(): ExteriorShot { | |
| 706 | return { | |
| 707 | position: this.camera.position.toArray(), target: this.controls.target.toArray(), | |
| 708 | phase: this.phase, fovDeg: this.camera.fov, | |
| 709 | } | |
| 710 | } | |
| 711 | ||
| 712 | /** | |
| 713 | * Put the camera at a saved shot, the hull turned as the shot has it. Held, which is the | |
| 714 | * photo tool's way in, the view stays still there, so the same entry is the same picture | |
| 715 | * on every run. Not held, it is the author going back to a shot: the hull turns on from | |
| 716 | * there if the spin is on. | |
| 717 | */ | |
| 718 | poseShot(s: ExteriorShot, hold: boolean): void { | |
| 719 | if (this.tour) this.stopTour() | |
| 720 | this.camera.position.fromArray(s.position) | |
| 721 | this.controls.target.fromArray(s.target) | |
| 722 | this.controls.update() | |
| 723 | this.camera.fov = s.fovDeg | |
| 724 | this.camera.updateProjectionMatrix() | |
| 725 | this.phase = normalizeTheta(s.phase) | |
| 726 | this.still = hold | |
| 727 | } | |
| 728 | ||
5f38efd0 2026-09-07 Pavel Safronov | 729 | // --------------------------------------------------------------- interaction |
| 730 | ||
| 731 | private attach(): void { | |
15d933c9 2026-09-07 Pavel Safronov | 732 | // OrbitControls fires 'change' per frame while dragging, so debounce rather |
| 733 | // than writing to storage on every mouse move. | |
| 734 | this.controls.addEventListener('change', () => { | |
| 735 | if (this.cameraSaveTimer) clearTimeout(this.cameraSaveTimer) | |
| 736 | this.cameraSaveTimer = setTimeout(() => this.saveCamera(), CAMERA_SAVE_MS) | |
| 737 | }) | |
| 738 | window.addEventListener('pagehide', () => this.saveCamera()) | |
| 739 | ||
99a2821b 2026-09-29 Pavel Safronov | 740 | this.canvas.addEventListener('pointerdown', () => { |
| 741 | this.moved = false | |
| 742 | // Taking hold of the view takes it back from the tour. | |
| 743 | if (this.tour && !this.tour.frozen) this.stopTour() | |
| 744 | }) | |
| 745 | this.canvas.addEventListener('wheel', () => { if (this.tour && !this.tour.frozen) this.stopTour() }, { passive: true }) | |
| 746 | window.addEventListener('keydown', (e) => { | |
| 747 | if (!this.running || !this.tour || this.tour.frozen || e.metaKey || e.ctrlKey || e.altKey) return | |
| 748 | if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement) return | |
| 749 | if (e.code === 'Escape') { this.stopTour(); e.preventDefault() } | |
| 750 | if (e.code === 'KeyN') { this.tour.next(); e.preventDefault() } | |
| 751 | }) | |
5f38efd0 2026-09-07 Pavel Safronov | 752 | this.canvas.addEventListener('pointermove', () => { this.moved = true }) |
| 753 | this.canvas.addEventListener('pointerup', (e) => { | |
| 754 | if (this.moved) return | |
| 755 | const r = this.canvas.getBoundingClientRect() | |
| 756 | const ndc = new Vector2( | |
| 757 | ((e.clientX - r.left) / r.width) * 2 - 1, | |
| 758 | -((e.clientY - r.top) / r.height) * 2 + 1) | |
| 759 | this.raycaster.setFromCamera(ndc, this.camera) | |
| 760 | ||
| 761 | const onHull = this.raycaster.intersectObject(this.hull, false)[0] | |
| 762 | if (!onHull) return | |
| 763 | // Into the hull's own frame, so the spin is undone and theta is canon. | |
| 764 | const local = this.tower.worldToLocal(onHull.point.clone()) | |
| 765 | this.cb.onPickPoint({ | |
| 766 | theta: normalizeTheta(Math.atan2(local.y, local.x)), | |
| 767 | z: Math.max(0, Math.min(LENGTH_M, local.z * KM)), | |
| 768 | }) | |
| 769 | }) | |
| 770 | } | |
| 771 | ||
| 772 | resize(): void { | |
| 773 | const { width, height } = this.canvas.getBoundingClientRect() | |
| 774 | if (!width || !height) return | |
| 775 | this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) | |
| 776 | this.renderer.setSize(width, height, false) | |
| 777 | this.camera.aspect = width / height | |
| 778 | this.camera.updateProjectionMatrix() | |
| 779 | } | |
| 780 | ||
| 781 | /** Frame a surface point: swing the camera round to look at it from outside. */ | |
| 782 | lookAt(p: SurfacePoint): void { | |
| 783 | const worldTheta = p.theta + this.phase | |
| 784 | const z = p.z / KM | |
15d933c9 2026-09-07 Pavel Safronov | 785 | |
| 786 | /** | |
| 787 | * A cap is a wall across the end, not a patch of ground, so the usual | |
c44860b9 2026-09-07 Pavel Safronov | 788 | * from-the-side framing shows only an edge. This sits about 40° off the axis: |
6bb061a6 2026-09-14 Pavel Safronov | 789 | * far enough round to see the cap's face — and in through its window (D-060) — |
| 790 | * without looking straight down the barrel and foreshortening the whole 540 km | |
| 791 | * behind it. | |
15d933c9 2026-09-07 Pavel Safronov | 792 | */ |
| 793 | if (isCapZ(z)) { | |
| 794 | const atSun = z < L / 2 | |
| 795 | const face = atSun ? 0 : L | |
| 796 | const dir = atSun ? -1 : 1 | |
c44860b9 2026-09-07 Pavel Safronov | 797 | this.controls.target.set(0, 0, face - dir * 30) |
| 798 | this.camera.position.set(R * 1.6, -R * 3.6, face + dir * 240) | |
15d933c9 2026-09-07 Pavel Safronov | 799 | this.controls.update() |
| 800 | return | |
| 801 | } | |
5f38efd0 2026-09-07 Pavel Safronov | 802 | this.controls.target.set(R * 0.4 * Math.cos(worldTheta), R * 0.4 * Math.sin(worldTheta), z) |
| 803 | const d = 260 | |
| 804 | this.camera.position.set( | |
| 805 | (R + d) * Math.cos(worldTheta), (R + d) * Math.sin(worldTheta), z + 90) | |
| 806 | this.controls.update() | |
| 807 | } | |
| 808 | ||
| 809 | // -------------------------------------------------------------------- loop | |
| 810 | ||
| 811 | start(): void { | |
| 812 | if (this.running) return | |
| 813 | this.running = true | |
| 814 | this.lastFrame = performance.now() | |
| 815 | const tick = (now: number) => { | |
| 816 | if (!this.running) return | |
abc0aad0 2026-10-01 Pavel Safronov | 817 | // Nothing moves while the world is being made (clock.ts `held`, D-096). |
| 818 | const dt = clock.held ? 0 : Math.min(0.1, (now - this.lastFrame) / 1000) | |
5f38efd0 2026-09-07 Pavel Safronov | 819 | this.lastFrame = now |
99a2821b 2026-09-29 Pavel Safronov | 820 | // On the tour the hull turns at the leg's own rate — real time, unless it says otherwise. |
3cc2b5bd 2026-10-04 Pavel Safronov | 821 | const spin = this.still ? 0 : this.tour ? (this.tour.frozen ? 0 : this.tour.current?.rate ?? 1) : this.spinning ? this.timeScale : 0 |
99a2821b 2026-09-29 Pavel Safronov | 822 | this.phase = (this.phase + OMEGA_RAD_S * dt * spin) % (2 * Math.PI) |
5f38efd0 2026-09-07 Pavel Safronov | 823 | this.tower.rotation.z = this.phase |
3cc2b5bd 2026-10-04 Pavel Safronov | 824 | const plantS = this.still ? 0 : now / 1000 |
| 825 | if (this.pipes?.group.visible) this.pipes.update(plantS) | |
| 826 | if (this.mixed?.pipes.group.visible) this.mixed.pipes.update(plantS) | |
99a2821b 2026-09-29 Pavel Safronov | 827 | if (this.tour) this.stepTour(dt) |
| 828 | else this.controls.update() | |
11a13dbf 2026-09-07 Pavel Safronov | 829 | /** |
6bb061a6 2026-09-14 Pavel Safronov | 830 | * The interior model's shaders integrate haze in the tower's own frame, and |
| 831 | * the tower is turning. So they are handed the world-to-tower transform and | |
| 832 | * the eye already carried through it, fresh each frame after both the spin | |
| 833 | * and the camera have moved. | |
11a13dbf 2026-09-07 Pavel Safronov | 834 | */ |
6bb061a6 2026-09-14 Pavel Safronov | 835 | this.tower.updateMatrixWorld() |
| 836 | this.worldToTower.copy(this.tower.matrixWorld).invert() | |
| 837 | this.model.setFrame(this.worldToTower) | |
| 838 | this.model.setCamera(this.eyeInTower.copy(this.camera.position).applyMatrix4(this.worldToTower)) | |
59c12827 2026-09-28 Pavel Safronov | 839 | this.applySky() |
5f38efd0 2026-09-07 Pavel Safronov | 840 | this.renderer.render(this.scene, this.camera) |
8b6287ba 2026-09-07 Pavel Safronov | 841 | this.updateLabels() |
5f38efd0 2026-09-07 Pavel Safronov | 842 | requestAnimationFrame(tick) |
| 843 | } | |
| 844 | requestAnimationFrame(tick) | |
| 845 | } | |
| 846 | ||
| 847 | stop(): void { | |
| 848 | this.running = false | |
42b4554e 2026-09-29 Pavel Safronov | 849 | // Leaving the view ends its tour: otherwise its caption stays up over the next view's, |
| 850 | // and a tour started there draws a second one on top of it. | |
| 851 | if (this.tour) this.stopTour() | |
5f38efd0 2026-09-07 Pavel Safronov | 852 | } |
59c12827 2026-09-28 Pavel Safronov | 853 | |
99a2821b 2026-09-29 Pavel Safronov | 854 | // --------------------------------------------------------------------- tour |
| 855 | ||
| 856 | private planTour(seed: number): ExtLeg[] { | |
| 857 | const world = terrain().world | |
| 858 | if (!world) return [] | |
| 859 | const rng = tourRng(seed) | |
| 860 | return planExterior({ world }, rng, () => legSeconds(rng())) | |
| 861 | } | |
| 862 | ||
| 863 | get touring(): boolean { | |
| 864 | return this.tour !== null && !this.tour.frozen | |
| 865 | } | |
| 866 | ||
| 867 | get sceneIds(): string[] { | |
| 868 | return this.planTour(1).map((l) => l.id) | |
| 869 | } | |
| 870 | ||
| 871 | /** Start the tour outside: spacecraft, dealt at random, every twelve to eighteen seconds. */ | |
| 872 | startTour(fromId?: string): boolean { | |
| 873 | const seed = Math.floor(Math.random() * 2 ** 32) | |
| 874 | const legs = this.planTour(seed) | |
| 875 | if (!legs.length) return false | |
| 876 | this.takeCamera() | |
| 877 | this.player.start(legs, tourRng(seed ^ 0x5eed), fromId) | |
| 878 | this.tour = this.player | |
| 879 | return true | |
| 880 | } | |
| 881 | ||
| 882 | stopTour(): void { | |
| 883 | const tour = this.tour | |
| 884 | if (!tour) return | |
| 885 | this.tour = null | |
| 886 | tour.stop() | |
| 887 | this.crafts.clear() | |
| 888 | const saved = this.tourSaved | |
| 889 | this.tourSaved = null | |
| 890 | this.camera.near = 2 | |
| 891 | this.camera.fov = 42 | |
| 892 | this.camera.up.set(0, 1, 0) | |
| 893 | if (saved) { | |
| 894 | this.camera.position.copy(saved.pos) | |
| 895 | this.controls.target.copy(saved.target) | |
| 896 | clock.set(saved.hours) | |
| 897 | clock.setRunning(saved.running) | |
| 898 | } | |
| 899 | this.camera.updateProjectionMatrix() | |
| 900 | this.controls.enabled = true | |
| 901 | this.controls.update() | |
| 902 | } | |
| 903 | ||
| 904 | /** Hold one leg at one moment, for the screenshot tour. */ | |
| 905 | showScene(id: string, t: number): string | null { | |
| 906 | const legs = this.planTour(1) | |
| 907 | const leg = legs.find((l) => l.id === id) | |
| 908 | if (!leg) return null | |
| 909 | this.takeCamera() | |
| 910 | this.player.hold(legs, leg, t) | |
| 911 | this.tour = this.player | |
| 912 | this.applyLeg(leg, this.player.t) | |
| 913 | return leg.title | |
| 914 | } | |
| 915 | ||
| 916 | private takeCamera(): void { | |
| 917 | this.tourSaved ??= { | |
| 918 | pos: this.camera.position.clone(), target: this.controls.target.clone(), | |
| 919 | hours: clock.hours, running: clock.running, | |
| 920 | } | |
| 921 | clock.setRunning(false) | |
| 922 | this.controls.enabled = false | |
| 923 | } | |
| 924 | ||
| 925 | private stepTour(dt: number): void { | |
| 926 | const tour = this.tour! | |
| 927 | const show = tour.step(dt, true) | |
| 928 | const leg = tour.current | |
| 929 | if (!leg) { this.stopTour(); return } | |
| 930 | if (show) this.applyLeg(leg, Math.min(tour.t, leg.durationS)) | |
| 931 | } | |
| 932 | ||
| 933 | /** | |
| 934 | * Put the camera and the leg's craft where the leg has them at `t`. A leg in the tower's | |
| 935 | * frame turns with the hull, so its points are carried through the hull's transform. | |
| 936 | */ | |
| 937 | private applyLeg(leg: ExtLeg, t: number): void { | |
| 938 | clock.set(leg.hours + (t * leg.rate) / 3600) | |
| 939 | this.tower.updateMatrixWorld() | |
| 940 | const m = leg.frame === 'tower' ? this.tower.matrixWorld : new Matrix4() | |
| 941 | const rot = new Matrix3().setFromMatrix4(m) | |
| 942 | const p = leg.pose(t) | |
| 943 | const eye = new Vector3(p.eye.x, p.eye.y, p.eye.z).applyMatrix4(m) | |
| 944 | const look = new Vector3(p.look.x, p.look.y, p.look.z).applyMatrix4(m) | |
| 945 | this.camera.position.copy(eye) | |
| 946 | this.camera.up.set(p.up.x, p.up.y, p.up.z).applyMatrix3(rot).normalize() | |
| 947 | this.camera.lookAt(look) | |
| 948 | this.camera.fov = p.fovDeg | |
| 949 | const crafts = leg.craft(t).map((c) => { | |
| 950 | const pos = new Vector3(c.pos.x, c.pos.y, c.pos.z).applyMatrix4(m).multiplyScalar(KM) | |
| 951 | const fwd = new Vector3(c.fwd.x, c.fwd.y, c.fwd.z).applyMatrix3(rot) | |
| 952 | const up = new Vector3(c.up.x, c.up.y, c.up.z).applyMatrix3(rot) | |
| 953 | return { kind: 'spacecraft' as const, pos, fwd, up } | |
| 954 | }) | |
| 955 | this.crafts.show(crafts) | |
| 956 | /** | |
| 957 | * The near plane follows whatever is closest — the hull, or the craft being chased — so a | |
| 958 | * ship 400 m off is not clipped, and the depth buffer is not spent on nothing when the | |
| 959 | * camera is a thousand kilometres out. | |
| 960 | */ | |
| 961 | const local = eye.clone().applyMatrix4(this.tower.matrixWorld.clone().invert()) | |
| 962 | const rad = Math.hypot(local.x, local.y) | |
| 963 | let nearest = local.z >= 0 && local.z <= L ? Math.abs(rad - HULL_R) | |
| 964 | : Math.hypot(Math.max(0, rad - HULL_R), local.z < 0 ? -local.z : local.z - L) | |
| 965 | for (const c of crafts) nearest = Math.min(nearest, c.pos.clone().divideScalar(KM).distanceTo(eye) - 0.05) | |
| 966 | this.camera.near = Math.min(2, Math.max(0.01, nearest * 0.3)) | |
| 967 | this.camera.updateProjectionMatrix() | |
| 968 | } | |
| 969 | ||
59c12827 2026-09-28 Pavel Safronov | 970 | /** |
| 971 | * The same sky the interior lights itself by (sky.ts, D-074): the sun where the clock | |
| 972 | * has it, seen in through the windows, and the rod lit from the sun cap to it. The rays | |
| 973 | * from the collector carry the beam, so they dim when Tower banks it for the night. | |
| 974 | */ | |
| 975 | private applySky(): void { | |
| 976 | const sky = skyAt(terrain().world?.calendar ?? defaultCalendar(), clock.hours, clock.dial) | |
| 977 | this.model.setSky(sky) | |
e331ec2a 2026-09-28 Pavel Safronov | 978 | skyMatrix(sky.at, this.skyFrame) |
59c12827 2026-09-28 Pavel Safronov | 979 | if (this.frames++ % 10 === 0) this.model.setWeather(clock.hours) |
| 980 | const beam = sky.dial === 'dark' ? 0 : Math.max(sky.power, 0.04) | |
| 981 | ;(this.rays!.material as LineBasicMaterial).color.setScalar(beam) | |
| 982 | } | |
5f38efd0 2026-09-07 Pavel Safronov | 983 | } |