fuzzyworld

tower / files / src / views / exterior.ts

exterior.ts

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

5f38efd0 2026-09-07 Pavel Safronov1/**
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 Safronov9import type { Texture } from 'three'
5f38efd0 2026-09-07 Pavel Safronov10import {
6bb061a6 2026-09-14 Pavel Safronov11 AmbientLight, BackSide, BufferGeometry, CircleGeometry, Color, CylinderGeometry,
12 DirectionalLight, DoubleSide, Float32BufferAttribute, FrontSide, Group, LineBasicMaterial,
e331ec2a 2026-09-28 Pavel Safronov13 LineSegments, Matrix3, Matrix4, Mesh, MeshBasicMaterial, MeshStandardMaterial, PerspectiveCamera,
6bb061a6 2026-09-14 Pavel Safronov14 Raycaster, RepeatWrapping, RingGeometry, SRGBColorSpace, Scene, ShaderMaterial,
2edbf472 2026-09-27 Pavel Safronov15 SphereGeometry, CanvasTexture, Sprite, SpriteMaterial, Vector2, Vector3, WebGLRenderer,
16 AdditiveBlending,
5f38efd0 2026-09-07 Pavel Safronov17} from 'three'
18import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
19import {
472390e2 2026-09-09 Pavel Safronov20 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 Safronov22} from '../canon/constants.js'
11a13dbf 2026-09-07 Pavel Safronov23import { type SurfacePoint, mapThetaOrigin, normalizeTheta } from '../canon/geometry.js'
376f2ce6 2026-09-16 Pavel Safronov24import { terrain } from '../terrain/service.js'
28896dab 2026-09-27 Pavel Safronov25import { InteriorModel, STAR_GLSL, WINDOW_RENDER_ORDER } from './interior-model.js'
59c12827 2026-09-28 Pavel Safronov26import { defaultCalendar } from '../world/schema.js'
27import { clock } from '../world/clock.js'
28import { skyAt } from '../world/sky.js'
3cc2b5bd 2026-10-04 Pavel Safronov29import type { ExteriorShot } from '../world/photos.js'
e331ec2a 2026-09-28 Pavel Safronov30import { skyMatrix, starTexture } from './stars.js'
ab6ae94f 2026-09-28 Pavel Safronov31import { ROCK_COVER, buildRockHull, rockCover } from './rock-hull.js'
6368edcf 2026-09-28 Pavel Safronov32import { type PipeHull, buildPipeHull } from './pipe-hull.js'
99a2821b 2026-09-29 Pavel Safronov33import { CraftView } from './craft.js'
34import { TourPlayer } from './tour-player.js'
35import { type ExtLeg, HULL_R, SUN_DRAWN_R, SUN_Z, planExterior } from '../tour/exterior-legs.js'
36import { legSeconds, tourRng } from '../tour/plan.js'
5f38efd0 2026-09-07 Pavel Safronov37
38const KM = 1000
39const R = RADIUS_M / KM
40const L = LENGTH_M / KM
41const BEAM_R = BEAM_RADIUS_M / KM
6bb061a6 2026-09-14 Pavel Safronov42/** Wall depth in km (D-044). The far cap sits at the outer face of it. */
de65ab10 2026-09-08 Pavel Safronov43const HULL_D = HULL_DEPTH_M / KM
5f38efd0 2026-09-07 Pavel Safronov44const COLLECTOR_R = COLLECTOR_DIAMETER_M / KM / 2
9c891d2e 2026-09-08 Pavel Safronov45const COLLECTOR_STANDOFF = COLLECTOR_STANDOFF_M / KM
472390e2 2026-09-09 Pavel Safronov46const COLLECTOR_RIM = COLLECTOR_RIM_M / KM
15d933c9 2026-09-07 Pavel Safronov47/** How close to an end, in km, counts as being on a cap rather than on the wall. */
48const CAP_TOLERANCE = 8
49const isCapZ = (zKm: number): boolean => zKm < CAP_TOLERANCE || zKm > L - CAP_TOLERANCE
50
51const CAMERA_KEY = 'tower.exteriorCamera'
52const CAMERA_SAVE_MS = 400
5f38efd0 2026-09-07 Pavel Safronov53
6bb061a6 2026-09-14 Pavel Safronov54/** The real light: along the axis, from beyond the collector. */
55const SUN_POSITION = new Vector3(0, 0, -8000)
56const SUN_COLOR = 0xfff3dd
57const SUN_INTENSITY = 2.2
58
59const WORLD_VERT = /* glsl */`
60#include <common>
61varying vec3 vWorld;
62void 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 */
75const MIRROR_FRAG = /* glsl */`
76#include <common>
77uniform vec3 uBase;
78uniform vec3 uF0;
79uniform vec3 uSun;
80uniform vec3 uToSun;
81uniform float uAlpha;
82varying vec3 vWorld;
83void 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. */
106const SKY_FRAG = /* glsl */`
107#include <common>
108uniform vec3 uBase;
109varying vec3 vWorld;
110${STAR_GLSL}
111void main() {
112 gl_FragColor = vec4(uBase + starField(vWorld - cameraPosition), 1.0);
113 #include <colorspace_fragment>
114}
115`
116
908833aa 2026-09-28 Pavel Safronov117/**
ab6ae94f 2026-09-28 Pavel Safronov118 * 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 Safronov120 */
ab6ae94f 2026-09-28 Pavel Safronov121export type HullMode = 'true' | 'rock' | 'pipes' | 'rockpipes' | 'xray'
908833aa 2026-09-28 Pavel Safronov122
5f38efd0 2026-09-07 Pavel Safronov123export interface ExteriorCallbacks {
124 onPickPoint: (p: SurfacePoint) => void
125}
126
127export class ExteriorView {
128 private renderer: WebGLRenderer
129 private scene = new Scene()
130 private camera: PerspectiveCamera
131 private controls: OrbitControls
6bb061a6 2026-09-14 Pavel Safronov132 /** Everything that co-rotates with the hull. */
5f38efd0 2026-09-07 Pavel Safronov133 private tower = new Group()
134 private hull: Mesh
11a13dbf 2026-09-07 Pavel Safronov135 private hullMat: MeshStandardMaterial
6bb061a6 2026-09-14 Pavel Safronov136 /** The inside of Tower, the same meshes and shaders the interior view stands in. */
137 private model: InteriorModel
59c12827 2026-09-28 Pavel Safronov138 /** The converging rays from the collector, dimmed with the beam at night. */
139 private rays: LineSegments | null = null
e331ec2a 2026-09-28 Pavel Safronov140 /** 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 Safronov142 private frames = 0
6bb061a6 2026-09-14 Pavel Safronov143 private worldToTower = new Matrix4()
144 private eyeInTower = new Vector3()
11a13dbf 2026-09-07 Pavel Safronov145 private terrainMap: Texture | null = null
aa201500 2026-09-14 Pavel Safronov146 /** True mode by default (D-061); the app sets the remembered reading on construction. */
908833aa 2026-09-28 Pavel Safronov147 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 Safronov150 /** 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 Safronov152 /**
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 Safronov158 /** The metal skin past each end of the world, which the rock replaces. */
159 private metalEnds: Mesh[] = []
5f38efd0 2026-09-07 Pavel Safronov160 private raycaster = new Raycaster()
161 private running = false
162 private lastFrame = 0
163 private moved = false
15d933c9 2026-09-07 Pavel Safronov164 private cameraSaveTimer: ReturnType<typeof setTimeout> | null = null
5f38efd0 2026-09-07 Pavel Safronov165
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 Safronov171 /** 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 Safronov173
8b6287ba 2026-09-07 Pavel Safronov174 /** Screen-space captions, so nothing in the scene is left unexplained. */
175 private labels: { at: Vector3; el: HTMLSpanElement }[] = []
176
99a2821b 2026-09-29 Pavel Safronov177 /** 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 Safronov185 constructor(
186 private canvas: HTMLCanvasElement,
187 private cb: ExteriorCallbacks,
188 private labelHost?: HTMLElement,
189 ) {
5f38efd0 2026-09-07 Pavel Safronov190 this.renderer = new WebGLRenderer({ canvas, antialias: true })
191 this.renderer.setClearColor(0x05070d)
a3f01bd7 2026-09-08 Pavel Safronov192 // 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 Safronov195 // 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 Safronov202 // 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 Safronov206 this.controls.maxDistance = 6000
207
11a13dbf 2026-09-07 Pavel Safronov208 this.hullMat = new MeshStandardMaterial()
5f38efd0 2026-09-07 Pavel Safronov209 this.hull = this.buildHull()
376f2ce6 2026-09-16 Pavel Safronov210 // 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 Safronov212 this.model = new InteriorModel({
376f2ce6 2026-09-16 Pavel Safronov213 seeThrough: true, lod: { grid: 33, maxLevel: 3, split: 2.4, budget: 400 },
6bb061a6 2026-09-14 Pavel Safronov214 })
215 this.tower.add(this.hull, ...this.model.meshes)
5f38efd0 2026-09-07 Pavel Safronov216 this.scene.add(this.tower)
de65ab10 2026-09-08 Pavel Safronov217 this.buildCollars()
6bb061a6 2026-09-14 Pavel Safronov218 this.buildAperture()
5f38efd0 2026-09-07 Pavel Safronov219 this.buildSunSystem()
220 this.buildStars()
221 this.buildLights()
15d933c9 2026-09-07 Pavel Safronov222 this.restoreCamera()
8b6287ba 2026-09-07 Pavel Safronov223 this.buildLabels()
99a2821b 2026-09-29 Pavel Safronov224 this.scene.add(this.crafts.group)
225 this.player = new TourPlayer<ExtLeg>(canvas.parentElement)
5f38efd0 2026-09-07 Pavel Safronov226 this.attach()
227 this.resize()
8b6287ba 2026-09-07 Pavel Safronov228 /**
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 Safronov237 // 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 Safronov239 }
240
241 // ------------------------------------------------------------------ geometry
242
a3f01bd7 2026-09-08 Pavel Safronov243 /**
6bb061a6 2026-09-14 Pavel Safronov244 * 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 Safronov247 *
6bb061a6 2026-09-14 Pavel Safronov248 * 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 Safronov250 */
5f38efd0 2026-09-07 Pavel Safronov251 private buildHull(): Mesh {
14d35b7d 2026-09-16 Pavel Safronov252 /**
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 Safronov260 g.rotateX(Math.PI / 2)
261 g.translate(0, 0, L / 2)
11a13dbf 2026-09-07 Pavel Safronov262 this.remapHullUVs(g)
263
264 this.hullMat = new MeshStandardMaterial({
a3f01bd7 2026-09-08 Pavel Safronov265 color: 0x8892a3, roughness: 0.62, metalness: 0.28, side: FrontSide,
11a13dbf 2026-09-07 Pavel Safronov266 })
267 const hull = new Mesh(g, this.hullMat)
268 this.loadTerrain()
5f38efd0 2026-09-07 Pavel Safronov269 return hull
270 }
271
11a13dbf 2026-09-07 Pavel Safronov272 /**
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 Safronov292 // 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 Safronov296 }
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 Safronov306 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 Safronov324 }
325
326 private applyTerrain(): void {
908833aa 2026-09-28 Pavel Safronov327 const on = this._hullMode === 'xray' && this.terrainMap
11a13dbf 2026-09-07 Pavel Safronov328 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 Safronov345 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 Safronov351 if (mode === 'pipes' && !this.pipes) {
352 this.pipes = buildPipeHull()
353 this.tower.add(this.pipes.group)
354 }
ab6ae94f 2026-09-28 Pavel Safronov355 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 Safronov363 const rock = mode === 'rock'
364 if (this.rock) this.rock.visible = rock
6368edcf 2026-09-28 Pavel Safronov365 if (this.pipes) this.pipes.group.visible = mode === 'pipes'
ab6ae94f 2026-09-28 Pavel Safronov366 if (this.mixed) this.mixed.rock.visible = this.mixed.pipes.group.visible = mode === 'rockpipes'
6368edcf 2026-09-28 Pavel Safronov367 // Piping keeps the cylinder and plates it; every other reading draws it as hullMat.
ab6ae94f 2026-09-28 Pavel Safronov368 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 Safronov370 // 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 Safronov374 this.applyTerrain()
375 }
376
de65ab10 2026-09-08 Pavel Safronov377 /**
378 * The kilometre of wall (D-044), which from outside is only visible as depth at
6bb061a6 2026-09-14 Pavel Safronov379 * 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 Safronov385 *
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 Safronov388 */
389 private buildCollars(): void {
14d35b7d 2026-09-16 Pavel Safronov390 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 Safronov393 for (const z0 of [-HULL_D, L]) {
14d35b7d 2026-09-16 Pavel Safronov394 // 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 Safronov401 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 Safronov404 }
14d35b7d 2026-09-16 Pavel Safronov405 /**
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 Safronov414 }
415
14d35b7d 2026-09-16 Pavel Safronov416
6bb061a6 2026-09-14 Pavel Safronov417 /**
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 Safronov423 const aperture = new Mesh(
424 new CircleGeometry(BEAM_R, 48),
6bb061a6 2026-09-14 Pavel Safronov425 new MeshBasicMaterial({ color: 0xfff0cf, side: DoubleSide }))
426 aperture.position.z = -0.05
5f38efd0 2026-09-07 Pavel Safronov427 this.tower.add(aperture)
428 }
429
430 private buildSunSystem(): void {
472390e2 2026-09-09 Pavel Safronov431 /**
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 Safronov457 * 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 Safronov467 */
6bb061a6 2026-09-14 Pavel Safronov468 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 Safronov480 }))
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 Safronov496
497 /**
8b6287ba 2026-09-07 Pavel Safronov498 * 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 Safronov509 */
8b6287ba 2026-09-07 Pavel Safronov510 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 Safronov527 const rays = this.rays = new LineSegments(beam, new LineBasicMaterial({
8b6287ba 2026-09-07 Pavel Safronov528 vertexColors: true, transparent: true,
529 blending: AdditiveBlending, depthWrite: false,
28896dab 2026-09-27 Pavel Safronov530 }))
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 Safronov534 }
535
6bb061a6 2026-09-14 Pavel Safronov536 /**
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 Safronov546 private buildStars(): void {
6bb061a6 2026-09-14 Pavel Safronov547 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 Safronov551 uStars: { value: starTexture() }, uSkyFrame: { value: new Matrix3() },
6bb061a6 2026-09-14 Pavel Safronov552 },
553 }))
e331ec2a 2026-09-28 Pavel Safronov554 this.skyFrame = (sky.material as ShaderMaterial).uniforms['uSkyFrame']!.value as Matrix3
6bb061a6 2026-09-14 Pavel Safronov555 // 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 Safronov560
2edbf472 2026-09-27 Pavel Safronov561 /**
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 Safronov568 new SphereGeometry(300, 24, 24),
2edbf472 2026-09-27 Pavel Safronov569 new MeshBasicMaterial({ color: 0xfff7e6, side: BackSide }))
99a2821b 2026-09-29 Pavel Safronov570 sun.position.z = SUN_Z
571 sun.scale.setScalar(SUN_DRAWN_R / 300)
2edbf472 2026-09-27 Pavel Safronov572 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 Safronov587 halo.position.z = SUN_Z
2edbf472 2026-09-27 Pavel Safronov588 this.scene.add(halo)
5f38efd0 2026-09-07 Pavel Safronov589 }
590
8b6287ba 2026-09-07 Pavel Safronov591 /**
472390e2 2026-09-09 Pavel Safronov592 * A dark disc and a glowing cone are not self-explanatory, and the collector is
8b6287ba 2026-09-07 Pavel Safronov593 * 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 Safronov613 const placed: { x: number; y: number }[] = []
8b6287ba 2026-09-07 Pavel Safronov614 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 Safronov617 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 Safronov625 el.style.display = visible ? 'block' : 'none'
626 if (!visible) continue
15d933c9 2026-09-07 Pavel Safronov627 placed.push({ x, y })
628 el.style.left = `${x}px`
629 el.style.top = `${y}px`
8b6287ba 2026-09-07 Pavel Safronov630 }
631 }
632
5f38efd0 2026-09-07 Pavel Safronov633 private buildLights(): void {
634 // The real light runs along the axis, from beyond the collector.
6bb061a6 2026-09-14 Pavel Safronov635 const sun = new DirectionalLight(SUN_COLOR, SUN_INTENSITY)
636 sun.position.copy(SUN_POSITION)
5f38efd0 2026-09-07 Pavel Safronov637 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 Safronov651 // 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 Safronov653 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 Safronov656 this.scene.add(new AmbientLight(0x3a4356, 1.6))
657 }
658
15d933c9 2026-09-07 Pavel Safronov659 // ------------------------------------------------------------------- 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 Safronov702 // -------------------------------------------------------------------- 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 Safronov729 // --------------------------------------------------------------- interaction
730
731 private attach(): void {
15d933c9 2026-09-07 Pavel Safronov732 // 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 Safronov740 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 Safronov752 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 Safronov785
786 /**
787 * A cap is a wall across the end, not a patch of ground, so the usual
c44860b9 2026-09-07 Pavel Safronov788 * from-the-side framing shows only an edge. This sits about 40° off the axis:
6bb061a6 2026-09-14 Pavel Safronov789 * 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 Safronov792 */
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 Safronov797 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 Safronov799 this.controls.update()
800 return
801 }
5f38efd0 2026-09-07 Pavel Safronov802 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 Safronov817 // 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 Safronov819 this.lastFrame = now
99a2821b 2026-09-29 Pavel Safronov820 // On the tour the hull turns at the leg's own rate — real time, unless it says otherwise.
3cc2b5bd 2026-10-04 Pavel Safronov821 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 Safronov822 this.phase = (this.phase + OMEGA_RAD_S * dt * spin) % (2 * Math.PI)
5f38efd0 2026-09-07 Pavel Safronov823 this.tower.rotation.z = this.phase
3cc2b5bd 2026-10-04 Pavel Safronov824 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 Safronov827 if (this.tour) this.stepTour(dt)
828 else this.controls.update()
11a13dbf 2026-09-07 Pavel Safronov829 /**
6bb061a6 2026-09-14 Pavel Safronov830 * 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 Safronov834 */
6bb061a6 2026-09-14 Pavel Safronov835 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 Safronov839 this.applySky()
5f38efd0 2026-09-07 Pavel Safronov840 this.renderer.render(this.scene, this.camera)
8b6287ba 2026-09-07 Pavel Safronov841 this.updateLabels()
5f38efd0 2026-09-07 Pavel Safronov842 requestAnimationFrame(tick)
843 }
844 requestAnimationFrame(tick)
845 }
846
847 stop(): void {
848 this.running = false
42b4554e 2026-09-29 Pavel Safronov849 // 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 Safronov852 }
59c12827 2026-09-28 Pavel Safronov853
99a2821b 2026-09-29 Pavel Safronov854 // --------------------------------------------------------------------- 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 Safronov970 /**
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 Safronov978 skyMatrix(sky.at, this.skyFrame)
59c12827 2026-09-28 Pavel Safronov979 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 Safronov983}