fuzzyworld

tower / cascadia / files / src / views / exterior.ts

Branch cascadia · default branch · all branches

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 {
11 AdditiveBlending, AmbientLight, BackSide, BufferGeometry, CircleGeometry, Color,
a3f01bd7 2026-09-08 Pavel Safronov12 FrontSide,
5f38efd0 2026-09-07 Pavel Safronov13 CylinderGeometry, DirectionalLight, DoubleSide, Float32BufferAttribute, Group, Mesh,
8b6287ba 2026-09-07 Pavel Safronov14 LineBasicMaterial, LineSegments, MeshBasicMaterial, MeshStandardMaterial,
15 PerspectiveCamera, Points, PointsMaterial,
11a13dbf 2026-09-07 Pavel Safronov16 Raycaster, RepeatWrapping, RingGeometry, SRGBColorSpace, Scene, SphereGeometry,
04d6779e 2026-09-07 Pavel Safronov17 TextureLoader, Vector2, Vector3, WebGLRenderer,
5f38efd0 2026-09-07 Pavel Safronov18} from 'three'
19import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
20import {
9c891d2e 2026-09-08 Pavel Safronov21 BEAM_RADIUS_M, COLLECTOR_DIAMETER_M, COLLECTOR_STANDOFF_M, HULL_DEPTH_M, LENGTH_M, 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'
5f38efd0 2026-09-07 Pavel Safronov24import type { Place } from '../world/schema.js'
769726ea 2026-09-08 Pavel Safronov25import { capGlazingTexture, capPatternTexture } from './cap-pattern.js'
5f38efd0 2026-09-07 Pavel Safronov26import { KIND_COLOR } from './palette.js'
1affced2 2026-09-08 Pavel Safronov27import { type Surface, rasterSet } from './raster.js'
5f38efd0 2026-09-07 Pavel Safronov28
29const KM = 1000
30const R = RADIUS_M / KM
31const L = LENGTH_M / KM
32const BEAM_R = BEAM_RADIUS_M / KM
de65ab10 2026-09-08 Pavel Safronov33/** Wall depth in km (D-044). The caps sit at the outer face of it. */
34const HULL_D = HULL_DEPTH_M / KM
5f38efd0 2026-09-07 Pavel Safronov35const COLLECTOR_R = COLLECTOR_DIAMETER_M / KM / 2
9c891d2e 2026-09-08 Pavel Safronov36const COLLECTOR_STANDOFF = COLLECTOR_STANDOFF_M / KM
15d933c9 2026-09-07 Pavel Safronov37/** How close to an end, in km, counts as being on a cap rather than on the wall. */
38const CAP_TOLERANCE = 8
39const isCapZ = (zKm: number): boolean => zKm < CAP_TOLERANCE || zKm > L - CAP_TOLERANCE
40
41const CAMERA_KEY = 'tower.exteriorCamera'
42const CAMERA_SAVE_MS = 400
5f38efd0 2026-09-07 Pavel Safronov43
44export interface ExteriorCallbacks {
45 onPickPoint: (p: SurfacePoint) => void
46 onPickPlace: (id: string) => void
47}
48
49export class ExteriorView {
50 private renderer: WebGLRenderer
51 private scene = new Scene()
52 private camera: PerspectiveCamera
53 private controls: OrbitControls
54 /** Everything that co-rotates with the hull, so markers stay put on the surface. */
55 private tower = new Group()
56 private markers = new Group()
57 private hull: Mesh
11a13dbf 2026-09-07 Pavel Safronov58 private hullMat: MeshStandardMaterial
a3f01bd7 2026-09-08 Pavel Safronov59 private innerMat!: MeshStandardMaterial
11a13dbf 2026-09-07 Pavel Safronov60 private terrainMap: Texture | null = null
1affced2 2026-09-08 Pavel Safronov61 private surface: Surface = 'canon'
11a13dbf 2026-09-07 Pavel Safronov62 private _showTerrain = true
5f38efd0 2026-09-07 Pavel Safronov63 private raycaster = new Raycaster()
64 private running = false
65 private lastFrame = 0
66 private moved = false
15d933c9 2026-09-07 Pavel Safronov67 private cameraSaveTimer: ReturnType<typeof setTimeout> | null = null
5f38efd0 2026-09-07 Pavel Safronov68
69 /** Spin phase in radians, shared with the rest of the app. */
70 phase = 0
71 /** Multiple of real time. Tower turns once per 7.5 min, which is slow to watch. */
72 timeScale = 60
73 spinning = true
74
8b6287ba 2026-09-07 Pavel Safronov75 /** Screen-space captions, so nothing in the scene is left unexplained. */
76 private labels: { at: Vector3; el: HTMLSpanElement }[] = []
77
78 constructor(
79 private canvas: HTMLCanvasElement,
80 private cb: ExteriorCallbacks,
81 private labelHost?: HTMLElement,
82 ) {
5f38efd0 2026-09-07 Pavel Safronov83 this.renderer = new WebGLRenderer({ canvas, antialias: true })
84 this.renderer.setClearColor(0x05070d)
a3f01bd7 2026-09-08 Pavel Safronov85 // Near plane at 2 km rather than 8: the wall is only a kilometre thick (D-044),
86 // so anywhere close enough to read it as depth is inside the old near plane.
87 this.camera = new PerspectiveCamera(42, 1, 2, 30_000)
5f38efd0 2026-09-07 Pavel Safronov88 // Framed to hold the whole system: the collector is 244 km across and 320 km
89 // ahead of the sun cap, so Tower alone is not the subject.
90 this.camera.position.set(260, -1180, 300)
91 this.controls = new OrbitControls(this.camera, canvas)
92 this.controls.target.set(0, 0, (L - COLLECTOR_STANDOFF) / 2)
93 this.controls.enableDamping = true
94 this.controls.dampingFactor = 0.08
a3f01bd7 2026-09-08 Pavel Safronov95 // 60 km, down from 120. A kilometre of wall is 1% of the cap's diameter, so it
96 // is a couple of pixels from anywhere the old limit allowed — the depth was
97 // being drawn and could not be got close enough to to see.
98 this.controls.minDistance = 60
5f38efd0 2026-09-07 Pavel Safronov99 this.controls.maxDistance = 6000
100
11a13dbf 2026-09-07 Pavel Safronov101 this.hullMat = new MeshStandardMaterial()
5f38efd0 2026-09-07 Pavel Safronov102 this.hull = this.buildHull()
103 this.tower.add(this.hull, this.markers)
104 this.scene.add(this.tower)
de65ab10 2026-09-08 Pavel Safronov105 this.buildCollars()
5f38efd0 2026-09-07 Pavel Safronov106 this.buildCaps()
107 this.buildSunSystem()
108 this.buildStars()
109 this.buildLights()
15d933c9 2026-09-07 Pavel Safronov110 this.restoreCamera()
8b6287ba 2026-09-07 Pavel Safronov111 this.buildLabels()
5f38efd0 2026-09-07 Pavel Safronov112 this.attach()
113 this.resize()
8b6287ba 2026-09-07 Pavel Safronov114 /**
115 * Watch the canvas box rather than only the window. Restoring this view on load
116 * un-hides the canvas and constructs the renderer in the same task, before the
117 * browser has laid the element out — so `resize()` sees a zero-sized box, bails,
118 * and the renderer keeps its default 300x150 buffer stretched across the canvas.
119 * Clicking the view later worked because layout had long since settled, which is
120 * why this only appeared once the view began persisting across reloads.
121 */
122 new ResizeObserver(() => this.resize()).observe(canvas)
5f38efd0 2026-09-07 Pavel Safronov123 }
124
125 // ------------------------------------------------------------------ geometry
126
a3f01bd7 2026-09-08 Pavel Safronov127 /**
128 * Two meshes, not one, because the two sides of Tower's wall show different
129 * things and a single DoubleSide material cannot say so.
130 *
131 * The outer skin is what Tower looks like: bare metal, or — in x-ray mode — the
132 * inner surface projected onto it, which is a diagram and not a view (D-047).
133 * The inner surface is the land itself, and since the far cap became a window
134 * (D-045) it is genuinely visible from outside: look in through the glass and
135 * the far wall of the world is right there, lit and 540 km deep.
136 */
5f38efd0 2026-09-07 Pavel Safronov137 private buildHull(): Mesh {
138 const g = new CylinderGeometry(R, R, L, 160, 1, true)
139 g.rotateX(Math.PI / 2)
140 g.translate(0, 0, L / 2)
11a13dbf 2026-09-07 Pavel Safronov141 this.remapHullUVs(g)
142
143 this.hullMat = new MeshStandardMaterial({
a3f01bd7 2026-09-08 Pavel Safronov144 color: 0x8892a3, roughness: 0.62, metalness: 0.28, side: FrontSide,
11a13dbf 2026-09-07 Pavel Safronov145 })
a3f01bd7 2026-09-08 Pavel Safronov146 // The land, seen from inside, through the window. Always the terrain — there
147 // is no mode in which the inside of Tower is bare metal.
148 this.innerMat = new MeshStandardMaterial({
149 color: 0x2a2f38, roughness: 0.95, metalness: 0.0, side: BackSide,
150 })
151 this.tower.add(new Mesh(g.clone(), this.innerMat))
152
11a13dbf 2026-09-07 Pavel Safronov153 const hull = new Mesh(g, this.hullMat)
154 this.loadTerrain()
5f38efd0 2026-09-07 Pavel Safronov155 return hull
156 }
157
11a13dbf 2026-09-07 Pavel Safronov158 /**
159 * Rewrite the cylinder's UVs so the terrain raster lands where the map says it
160 * should. Three's cylinder runs u around the circumference and v along the axis;
161 * the raster is the other way round, with x = z and y = theta.
162 *
163 * Deriving the angle from vertex positions would be simpler and wrong: the seam
164 * vertices are duplicated, so atan2 gives both the same angle and smears one
165 * column of triangles across the whole image. Working from the original u keeps
166 * it linear, and RepeatWrapping absorbs the offset.
167 *
168 * Three builds the ring as x = R sin t, z = R cos t, so after rotateX the angle
169 * in the XY plane is t - pi/2. Canon theta is measured from +X, and the map is
170 * cut at mapThetaOrigin — hence the constant.
171 */
172 private remapHullUVs(g: CylinderGeometry): void {
173 const uv = g.attributes['uv']!
174 const shift = 0.25 + mapThetaOrigin() / (2 * Math.PI)
175 for (let i = 0; i < uv.count; i++) {
176 const around = uv.getX(i)
177 const along = uv.getY(i)
178 uv.setXY(i, 1 - along, around - shift)
179 }
180 uv.needsUpdate = true
181 }
182
183 /**
184 * Paint the inner surface onto the outer hull. This is a projection, not a view:
185 * from outside, Tower is structure, and what the texture shows is what is
186 * *inside*. It is an x-ray, and useful precisely because the hull is opaque.
187 */
1affced2 2026-09-08 Pavel Safronov188 /** Swap the ground painted onto the hull. See raster.js for what a surface is. */
189 setSurface(surface: Surface): void {
190 if (surface === this.surface) return
191 this.surface = surface
192 this.terrainMap?.dispose()
193 this.terrainMap = null
194 this.applyTerrain()
195 this.loadTerrain()
196 }
197
11a13dbf 2026-09-07 Pavel Safronov198 private loadTerrain(): void {
1affced2 2026-09-08 Pavel Safronov199 const want = this.surface
11a13dbf 2026-09-07 Pavel Safronov200 new TextureLoader().load(
1affced2 2026-09-08 Pavel Safronov201 `${import.meta.env.BASE_URL}${rasterSet(this.surface).color}`,
11a13dbf 2026-09-07 Pavel Safronov202 (tex) => {
1affced2 2026-09-08 Pavel Safronov203 // The reader may have switched surfaces while this was in flight.
204 if (want !== this.surface) { tex.dispose(); return }
11a13dbf 2026-09-07 Pavel Safronov205 tex.colorSpace = SRGBColorSpace
206 tex.wrapS = RepeatWrapping
207 tex.wrapT = RepeatWrapping
208 // uv.y is computed, not sampled from the file's row order, so no flip.
209 tex.flipY = false
210 tex.anisotropy = this.renderer.capabilities.getMaxAnisotropy()
211 this.terrainMap = tex
212 this.applyTerrain()
213 },
214 undefined,
215 () => { /* no raster yet: the plain hull is a fine fallback */ },
216 )
217 }
218
219 private applyTerrain(): void {
220 const on = this._showTerrain && this.terrainMap
221 this.hullMat.map = on ? this.terrainMap : null
222 /**
223 * Self-illuminate the terrain. Sunlight runs along the axis, so most of the
224 * hull faces away from every light in the scene and the map would be legible
225 * only on the sliver facing the fill. Emissive keeps it readable all the way
226 * round while the directional lights still give the cylinder its form.
227 */
228 this.hullMat.emissiveMap = on ? this.terrainMap : null
229 this.hullMat.emissive.set(on ? 0xffffff : 0x000000)
230 this.hullMat.emissiveIntensity = on ? 0.62 : 0
231 // Bare metal wants a tint; a textured surface wants to be left alone.
232 this.hullMat.color.set(on ? 0xffffff : 0x8892a3)
233 this.hullMat.metalness = on ? 0.06 : 0.28
234 this.hullMat.roughness = on ? 0.9 : 0.62
235 this.hullMat.needsUpdate = true
a3f01bd7 2026-09-08 Pavel Safronov236
237 // The inside carries the land in both modes, and always emissive: it is lit by
238 // a beam this scene does not model, and unlit it would read as a black hole
239 // behind the window rather than as a world.
240 if (this.terrainMap) {
241 this.innerMat.map = this.terrainMap
242 this.innerMat.emissiveMap = this.terrainMap
243 this.innerMat.emissive.set(0xffffff)
244 this.innerMat.emissiveIntensity = 0.5
245 this.innerMat.color.set(0xffffff)
246 this.innerMat.needsUpdate = true
247 }
11a13dbf 2026-09-07 Pavel Safronov248 }
249
250 set showTerrain(v: boolean) {
251 this._showTerrain = v
252 this.applyTerrain()
253 }
254
de65ab10 2026-09-08 Pavel Safronov255 /**
256 * The kilometre of wall (D-044), which from outside is only visible as depth at
257 * the two ends: the inner surface stops at z = 0 and z = L, the caps sit a
258 * kilometre further out at the skin, and what stands between them is a collar
259 * you can see down. There is no terrain on a collar — it is wall, not world.
260 */
261 private buildCollars(): void {
262 const mat = new MeshStandardMaterial({
263 color: 0x6f7787, roughness: 0.55, metalness: 0.42, side: DoubleSide,
264 })
265 for (const z0 of [-HULL_D, L]) {
266 const g = new CylinderGeometry(R, R, HULL_D, 160, 1, true)
267 g.rotateX(Math.PI / 2)
268 g.translate(0, 0, z0 + HULL_D / 2)
269 this.tower.add(new Mesh(g, mat))
270 }
271 }
272
5f38efd0 2026-09-07 Pavel Safronov273 private buildCaps(): void {
c44860b9 2026-09-07 Pavel Safronov274 // The same pattern the interior view paints on their far side (D-041). A cap is
275 // structure, not landscape, and the checker is what says so from either side.
276 const pattern = capPatternTexture()
5f38efd0 2026-09-07 Pavel Safronov277 // The sun cap is an annulus: the beam enters through the hole in the middle.
278 const sun = new Mesh(
279 new RingGeometry(BEAM_R, R, 96),
c44860b9 2026-09-07 Pavel Safronov280 new MeshStandardMaterial({
281 color: 0xdfe5ee, map: pattern, roughness: 0.5, metalness: 0.3, side: DoubleSide,
282 // Same licence as the hull's terrain: sunlight runs along the axis, so a cap
283 // is edge-on to every real light and renders as a dark disc exactly when it
284 // is being looked at. Enough emissive to keep the pattern legible.
285 emissiveMap: pattern, emissive: new Color(0xffffff), emissiveIntensity: 0.42,
286 }))
de65ab10 2026-09-08 Pavel Safronov287 sun.position.z = -HULL_D
5f38efd0 2026-09-07 Pavel Safronov288 this.tower.add(sun)
289
290 const aperture = new Mesh(
291 new CircleGeometry(BEAM_R, 48),
292 new MeshBasicMaterial({ color: 0xfff0cf }))
de65ab10 2026-09-08 Pavel Safronov293 aperture.position.z = -HULL_D + 0.4
5f38efd0 2026-09-07 Pavel Safronov294 this.tower.add(aperture)
295
769726ea 2026-09-08 Pavel Safronov296 /**
a3f01bd7 2026-09-08 Pavel Safronov297 * The far cap is a window (D-045), and from outside it is a window in the
298 * ordinary sense: the mullions are structure and the panes are clear, so what
299 * is behind them is the inside of Tower. That is the whole of the "true" view's
300 * payoff — the only line of sight into the world that is not a diagram.
769726ea 2026-09-08 Pavel Safronov301 */
302 const glazing = capGlazingTexture()
5f38efd0 2026-09-07 Pavel Safronov303 const far = new Mesh(
304 new CircleGeometry(R, 96),
c44860b9 2026-09-07 Pavel Safronov305 new MeshStandardMaterial({
769726ea 2026-09-08 Pavel Safronov306 color: 0xd8dee8, map: glazing, transparent: true, side: DoubleSide,
307 roughness: 0.72, metalness: 0.25,
308 emissiveMap: glazing, emissive: new Color(0xffffff), emissiveIntensity: 0.5,
c44860b9 2026-09-07 Pavel Safronov309 }))
769726ea 2026-09-08 Pavel Safronov310 far.position.z = L + HULL_D + 0.02
5f38efd0 2026-09-07 Pavel Safronov311 this.tower.add(far)
312 }
313
314 private buildSunSystem(): void {
315 // The collector flies ahead of the sun cap: 244 km across, wider than Tower.
316 const collector = new Mesh(
317 new CircleGeometry(COLLECTOR_R, 128),
318 new MeshStandardMaterial({
319 color: 0xe6edf8, roughness: 0.2, metalness: 0.5, side: DoubleSide,
320 emissive: new Color(0x93a2be), emissiveIntensity: 1.15,
321 }))
322 collector.position.z = -COLLECTOR_STANDOFF
323 this.scene.add(collector)
324
325 /**
8b6287ba 2026-09-07 Pavel Safronov326 * The converging beam, drawn as rays from the mirror's rim to the aperture.
327 *
328 * A translucent cone is the obvious construction and it fringes: at the mouth
329 * the shell is edge-on, so its triangles project to slivers and the additive
330 * blend stacks them into bright dashes across the collector. Isolated by
331 * bisection — removing the cone cleared the disc, removing the stars did not,
332 * and shortening the cone moved the dashes with its mouth. Rays have no
333 * silhouette to alias, and read more like light besides.
334 *
335 * In vacuum none of this would be visible; there is nothing out here to
336 * scatter it. Same licence as the hillshade on the map.
5f38efd0 2026-09-07 Pavel Safronov337 */
8b6287ba 2026-09-07 Pavel Safronov338 const RAYS = 220
339 const verts = new Float32Array(RAYS * 6)
340 const cols = new Float32Array(RAYS * 8)
341 for (let i = 0; i < RAYS; i++) {
342 const a = (i / RAYS) * Math.PI * 2
343 // Spread the starts across the mirror rather than only round its rim, so the
344 // beam has a body instead of being a hollow funnel.
345 const r = COLLECTOR_R * Math.sqrt(0.15 + 0.85 * ((i * 0.6180339887) % 1))
346 verts.set([
347 r * Math.cos(a), r * Math.sin(a), -COLLECTOR_STANDOFF,
348 BEAM_R * Math.cos(a), BEAM_R * Math.sin(a), 0,
349 ], i * 6)
350 cols.set([1, 0.95, 0.8, 0.0, 1, 0.97, 0.86, 0.38], i * 8)
351 }
352 const beam = new BufferGeometry()
353 beam.setAttribute('position', new Float32BufferAttribute(verts, 3))
354 beam.setAttribute('color', new Float32BufferAttribute(cols, 4))
355 this.scene.add(new LineSegments(beam, new LineBasicMaterial({
356 vertexColors: true, transparent: true,
357 blending: AdditiveBlending, depthWrite: false,
5f38efd0 2026-09-07 Pavel Safronov358 })))
359 }
360
361 private buildStars(): void {
362 const n = 3500
363 const pos = new Float32Array(n * 3)
364 for (let i = 0; i < n; i++) {
365 const v = new Vector3().randomDirection().multiplyScalar(18_000)
366 pos.set([v.x, v.y, v.z], i * 3)
367 }
368 const g = new BufferGeometry()
369 g.setAttribute('position', new Float32BufferAttribute(pos, 3))
370 this.scene.add(new Points(g, new PointsMaterial({ color: 0xdfe8ff, size: 26, sizeAttenuation: true })))
371
372 const glow = new Mesh(
373 new SphereGeometry(300, 24, 24),
374 new MeshBasicMaterial({ color: 0xffe9bd, transparent: true, opacity: 0.5, side: BackSide }))
375 glow.position.z = -14_000
376 this.scene.add(glow)
377 }
378
8b6287ba 2026-09-07 Pavel Safronov379 /**
380 * A pale disc and a glowing cone are not self-explanatory, and the collector is
381 * the least intuitive object in Tower: it is bigger than the world it feeds.
382 */
383 private buildLabels(): void {
384 if (!this.labelHost) return
385 const add = (at: Vector3, title: string, sub: string) => {
386 const el = document.createElement('span')
387 el.innerHTML = `${title}<small>${sub}</small>`
388 this.labelHost!.append(el)
389 this.labels.push({ at, el })
390 }
391 add(new Vector3(0, COLLECTOR_R * 0.82, -COLLECTOR_STANDOFF), 'COLLECTOR', '244 km across')
392 add(new Vector3(0, 96, -COLLECTOR_STANDOFF * 0.5), 'BEAM', '54.3 TW into a 3.2 km hole')
393 add(new Vector3(0, R + 26, 0), 'SUN CAP', 'faces the sun')
394 add(new Vector3(0, R + 26, L), 'FAR CAP', '540 km away')
395 }
396
397 private updateLabels(): void {
398 if (!this.labels.length) return
399 const { width, height } = this.canvas.getBoundingClientRect()
400 const v = new Vector3()
15d933c9 2026-09-07 Pavel Safronov401 const placed: { x: number; y: number }[] = []
8b6287ba 2026-09-07 Pavel Safronov402 for (const { at, el } of this.labels) {
403 v.copy(at).project(this.camera)
404 // z > 1 means the point is behind the camera; project wraps it to the far side.
15d933c9 2026-09-07 Pavel Safronov405 let visible = v.z < 1 && Math.abs(v.x) < 1.6 && Math.abs(v.y) < 1.6
406 const x = ((v.x + 1) / 2) * width
407 const y = ((1 - v.y) / 2) * height
408 // Drop captions that would land on one already placed. Close in on a cap,
409 // all four project into the same corner and become an unreadable stack.
410 if (visible && placed.some((p) => Math.abs(p.x - x) < 170 && Math.abs(p.y - y) < 36)) {
411 visible = false
412 }
8b6287ba 2026-09-07 Pavel Safronov413 el.style.display = visible ? 'block' : 'none'
414 if (!visible) continue
15d933c9 2026-09-07 Pavel Safronov415 placed.push({ x, y })
416 el.style.left = `${x}px`
417 el.style.top = `${y}px`
8b6287ba 2026-09-07 Pavel Safronov418 }
419 }
420
5f38efd0 2026-09-07 Pavel Safronov421 private buildLights(): void {
422 // The real light runs along the axis, from beyond the collector.
423 const sun = new DirectionalLight(0xfff3dd, 2.2)
424 sun.position.set(0, 0, -8000)
425 this.scene.add(sun)
426
427 /**
428 * Fill light, and a reading aid rather than physics. Sunlight here is parallel
429 * to the axis, so Tower's flank is edge-on to it and correctly renders almost
430 * black — accurate and useless. These two lift the hull enough to read its
431 * shape, the same licence as the hillshade on the map.
432 */
433 const key = new DirectionalLight(0xc8d6f0, 1.5)
434 key.position.set(-1, -1.4, 0.35)
435 this.scene.add(key)
436 const rim = new DirectionalLight(0x8fa8d8, 0.7)
437 rim.position.set(1.2, 0.9, -0.4)
438 this.scene.add(rim)
15d933c9 2026-09-07 Pavel Safronov439 // The far cap's outer face points away from the sun and from both fills, so it
440 // renders as a black hole exactly when you focus on it.
441 const behind = new DirectionalLight(0x9fb2d4, 0.55)
442 behind.position.set(-0.5, -0.8, 1)
443 this.scene.add(behind)
5f38efd0 2026-09-07 Pavel Safronov444 this.scene.add(new AmbientLight(0x3a4356, 1.6))
445 }
446
447 // ------------------------------------------------------------------- markers
448
449 /** Put a dot on the hull for every place, at its canon (theta, z). */
450 setPlaces(places: readonly Place[], selectedId: string | null): void {
451 this.markers.clear()
452 for (const p of places) {
453 const g = p.geometry
454 const at: SurfacePoint = g.type === 'point'
455 ? { theta: g.theta, z: g.z }
04d6779e 2026-09-07 Pavel Safronov456 : g.type === 'band'
457 // A band rings the world, so any theta is as good as another.
458 ? { theta: 0, z: (g.zFrom + g.zTo) / 2 }
459 : { theta: g.points[0]![0], z: g.points[0]![1] }
5f38efd0 2026-09-07 Pavel Safronov460 const selected = p.id === selectedId
11a13dbf 2026-09-07 Pavel Safronov461 const r = selected ? 6.5 : 4.2
5f38efd0 2026-09-07 Pavel Safronov462 const dot = new Mesh(
11a13dbf 2026-09-07 Pavel Safronov463 new SphereGeometry(r, 14, 14),
5f38efd0 2026-09-07 Pavel Safronov464 new MeshBasicMaterial({ color: new Color(KIND_COLOR[p.kind] ?? '#ccc') }))
11a13dbf 2026-09-07 Pavel Safronov465 // A dark shell behind each dot. Small bright spheres read fine against a bare
466 // hull and vanish into the terrain once it is painted on; the outline is what
467 // keeps them legible over green forest and tan desert alike.
468 dot.add(new Mesh(
469 new SphereGeometry(r * 1.45, 14, 14),
470 new MeshBasicMaterial({ color: 0x05070d, side: BackSide })))
15d933c9 2026-09-07 Pavel Safronov471 const zKm = at.z / KM
472 if (isCapZ(zKm)) {
473 // A cap is a disc across the end, so its place is the axis — not a point on
474 // the rim, which would ride round the edge as Tower turns.
475 dot.position.set(0, 0, zKm < L / 2 ? -5 : L + 5)
476 } else {
477 dot.position.set(
478 (R + 1.6) * Math.cos(at.theta), (R + 1.6) * Math.sin(at.theta), zKm)
479 }
5f38efd0 2026-09-07 Pavel Safronov480 dot.userData['placeId'] = p.id
11a13dbf 2026-09-07 Pavel Safronov481 dot.renderOrder = 2
5f38efd0 2026-09-07 Pavel Safronov482 this.markers.add(dot)
483 }
484 }
485
15d933c9 2026-09-07 Pavel Safronov486 // ------------------------------------------------------------------- camera
487
488 /**
489 * Restore where the camera was left. Every field is checked before use: a
490 * corrupt or stale entry here would leave the view pointing at nothing with no
491 * obvious way back, and silently-bad persisted state has bitten this app once
492 * already (D-025).
493 */
494 private restoreCamera(): void {
495 try {
496 const raw = localStorage.getItem(CAMERA_KEY)
497 if (!raw) return
498 const s = JSON.parse(raw) as { p?: unknown; t?: unknown }
499 const ok = (v: unknown): v is number[] =>
500 Array.isArray(v) && v.length === 3 && v.every((n) => typeof n === 'number' && Number.isFinite(n))
501 if (!ok(s.p) || !ok(s.t)) return
502 const pos = new Vector3(s.p[0], s.p[1], s.p[2])
503 const target = new Vector3(s.t[0], s.t[1], s.t[2])
504 const d = pos.distanceTo(target)
505 if (d < this.controls.minDistance || d > this.controls.maxDistance) return
506 this.camera.position.copy(pos)
507 this.controls.target.copy(target)
508 this.controls.update()
509 } catch {
510 // Unreadable storage just means the default framing.
511 }
512 }
513
514 private saveCamera(): void {
515 if (this.cameraSaveTimer) clearTimeout(this.cameraSaveTimer)
516 this.cameraSaveTimer = null
517 try {
518 const p = this.camera.position
519 const t = this.controls.target
520 localStorage.setItem(CAMERA_KEY, JSON.stringify({
521 p: [p.x, p.y, p.z].map((n) => Math.round(n * 100) / 100),
522 t: [t.x, t.y, t.z].map((n) => Math.round(n * 100) / 100),
523 }))
524 } catch {
525 // Not persisting the camera is survivable.
526 }
527 }
528
5f38efd0 2026-09-07 Pavel Safronov529 // --------------------------------------------------------------- interaction
530
531 private attach(): void {
15d933c9 2026-09-07 Pavel Safronov532 // OrbitControls fires 'change' per frame while dragging, so debounce rather
533 // than writing to storage on every mouse move.
534 this.controls.addEventListener('change', () => {
535 if (this.cameraSaveTimer) clearTimeout(this.cameraSaveTimer)
536 this.cameraSaveTimer = setTimeout(() => this.saveCamera(), CAMERA_SAVE_MS)
537 })
538 window.addEventListener('pagehide', () => this.saveCamera())
539
5f38efd0 2026-09-07 Pavel Safronov540 this.canvas.addEventListener('pointerdown', () => { this.moved = false })
541 this.canvas.addEventListener('pointermove', () => { this.moved = true })
542 this.canvas.addEventListener('pointerup', (e) => {
543 if (this.moved) return
544 const r = this.canvas.getBoundingClientRect()
545 const ndc = new Vector2(
546 ((e.clientX - r.left) / r.width) * 2 - 1,
547 -((e.clientY - r.top) / r.height) * 2 + 1)
548 this.raycaster.setFromCamera(ndc, this.camera)
549
550 const onMarker = this.raycaster.intersectObjects(this.markers.children, false)[0]
551 if (onMarker) return this.cb.onPickPlace(onMarker.object.userData['placeId'] as string)
552
553 const onHull = this.raycaster.intersectObject(this.hull, false)[0]
554 if (!onHull) return
555 // Into the hull's own frame, so the spin is undone and theta is canon.
556 const local = this.tower.worldToLocal(onHull.point.clone())
557 this.cb.onPickPoint({
558 theta: normalizeTheta(Math.atan2(local.y, local.x)),
559 z: Math.max(0, Math.min(LENGTH_M, local.z * KM)),
560 })
561 })
562 }
563
564 resize(): void {
565 const { width, height } = this.canvas.getBoundingClientRect()
566 if (!width || !height) return
567 this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
568 this.renderer.setSize(width, height, false)
569 this.camera.aspect = width / height
570 this.camera.updateProjectionMatrix()
571 }
572
573 /** Frame a surface point: swing the camera round to look at it from outside. */
574 lookAt(p: SurfacePoint): void {
575 const worldTheta = p.theta + this.phase
576 const z = p.z / KM
15d933c9 2026-09-07 Pavel Safronov577
578 /**
579 * A cap is a wall across the end, not a patch of ground, so the usual
c44860b9 2026-09-07 Pavel Safronov580 * from-the-side framing shows only an edge. This sits about 40° off the axis:
581 * far enough round to see the cap's face — which now carries a pattern worth
582 * seeing (D-041) — without looking straight down the barrel and foreshortening
583 * the whole 540 km behind it.
15d933c9 2026-09-07 Pavel Safronov584 */
585 if (isCapZ(z)) {
586 const atSun = z < L / 2
587 const face = atSun ? 0 : L
588 const dir = atSun ? -1 : 1
c44860b9 2026-09-07 Pavel Safronov589 this.controls.target.set(0, 0, face - dir * 30)
590 this.camera.position.set(R * 1.6, -R * 3.6, face + dir * 240)
15d933c9 2026-09-07 Pavel Safronov591 this.controls.update()
592 return
593 }
5f38efd0 2026-09-07 Pavel Safronov594 this.controls.target.set(R * 0.4 * Math.cos(worldTheta), R * 0.4 * Math.sin(worldTheta), z)
595 const d = 260
596 this.camera.position.set(
597 (R + d) * Math.cos(worldTheta), (R + d) * Math.sin(worldTheta), z + 90)
598 this.controls.update()
599 }
600
601 // -------------------------------------------------------------------- loop
602
603 start(): void {
604 if (this.running) return
605 this.running = true
606 this.lastFrame = performance.now()
607 const tick = (now: number) => {
608 if (!this.running) return
609 const dt = Math.min(0.1, (now - this.lastFrame) / 1000)
610 this.lastFrame = now
611 if (this.spinning) this.phase = (this.phase + OMEGA_RAD_S * dt * this.timeScale) % (2 * Math.PI)
612 this.tower.rotation.z = this.phase
11a13dbf 2026-09-07 Pavel Safronov613 /**
614 * Keep markers a roughly constant size on screen. A 4 km dot is legible from
615 * 200 km away and sub-pixel from 1,200 km, which is where the default view
616 * sits — so at world scale the places would simply not be there.
617 */
618 const scale = Math.max(0.8, Math.min(2.5, this.camera.position.distanceTo(this.controls.target) / 700))
619 for (const m of this.markers.children) m.scale.setScalar(scale)
5f38efd0 2026-09-07 Pavel Safronov620 this.controls.update()
621 this.renderer.render(this.scene, this.camera)
8b6287ba 2026-09-07 Pavel Safronov622 this.updateLabels()
5f38efd0 2026-09-07 Pavel Safronov623 requestAnimationFrame(tick)
624 }
625 requestAnimationFrame(tick)
626 }
627
628 stop(): void {
629 this.running = false
630 }
631}