fuzzyworld

tower / interior-flight / files / src / views / exterior.ts

Branch interior-flight · 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'
27
28const KM = 1000
29const R = RADIUS_M / KM
30const L = LENGTH_M / KM
31const BEAM_R = BEAM_RADIUS_M / KM
de65ab10 2026-09-08 Pavel Safronov32/** Wall depth in km (D-044). The caps sit at the outer face of it. */
33const HULL_D = HULL_DEPTH_M / KM
5f38efd0 2026-09-07 Pavel Safronov34const COLLECTOR_R = COLLECTOR_DIAMETER_M / KM / 2
9c891d2e 2026-09-08 Pavel Safronov35const COLLECTOR_STANDOFF = COLLECTOR_STANDOFF_M / KM
15d933c9 2026-09-07 Pavel Safronov36/** How close to an end, in km, counts as being on a cap rather than on the wall. */
37const CAP_TOLERANCE = 8
38const isCapZ = (zKm: number): boolean => zKm < CAP_TOLERANCE || zKm > L - CAP_TOLERANCE
39
40const CAMERA_KEY = 'tower.exteriorCamera'
41const CAMERA_SAVE_MS = 400
5f38efd0 2026-09-07 Pavel Safronov42
43export interface ExteriorCallbacks {
44 onPickPoint: (p: SurfacePoint) => void
45 onPickPlace: (id: string) => void
46}
47
48export class ExteriorView {
49 private renderer: WebGLRenderer
50 private scene = new Scene()
51 private camera: PerspectiveCamera
52 private controls: OrbitControls
53 /** Everything that co-rotates with the hull, so markers stay put on the surface. */
54 private tower = new Group()
55 private markers = new Group()
56 private hull: Mesh
11a13dbf 2026-09-07 Pavel Safronov57 private hullMat: MeshStandardMaterial
a3f01bd7 2026-09-08 Pavel Safronov58 private innerMat!: MeshStandardMaterial
11a13dbf 2026-09-07 Pavel Safronov59 private terrainMap: Texture | null = null
60 private _showTerrain = true
5f38efd0 2026-09-07 Pavel Safronov61 private raycaster = new Raycaster()
62 private running = false
63 private lastFrame = 0
64 private moved = false
15d933c9 2026-09-07 Pavel Safronov65 private cameraSaveTimer: ReturnType<typeof setTimeout> | null = null
5f38efd0 2026-09-07 Pavel Safronov66
67 /** Spin phase in radians, shared with the rest of the app. */
68 phase = 0
69 /** Multiple of real time. Tower turns once per 7.5 min, which is slow to watch. */
70 timeScale = 60
71 spinning = true
72
8b6287ba 2026-09-07 Pavel Safronov73 /** Screen-space captions, so nothing in the scene is left unexplained. */
74 private labels: { at: Vector3; el: HTMLSpanElement }[] = []
75
76 constructor(
77 private canvas: HTMLCanvasElement,
78 private cb: ExteriorCallbacks,
79 private labelHost?: HTMLElement,
80 ) {
5f38efd0 2026-09-07 Pavel Safronov81 this.renderer = new WebGLRenderer({ canvas, antialias: true })
82 this.renderer.setClearColor(0x05070d)
a3f01bd7 2026-09-08 Pavel Safronov83 // Near plane at 2 km rather than 8: the wall is only a kilometre thick (D-044),
84 // so anywhere close enough to read it as depth is inside the old near plane.
85 this.camera = new PerspectiveCamera(42, 1, 2, 30_000)
5f38efd0 2026-09-07 Pavel Safronov86 // Framed to hold the whole system: the collector is 244 km across and 320 km
87 // ahead of the sun cap, so Tower alone is not the subject.
88 this.camera.position.set(260, -1180, 300)
89 this.controls = new OrbitControls(this.camera, canvas)
90 this.controls.target.set(0, 0, (L - COLLECTOR_STANDOFF) / 2)
91 this.controls.enableDamping = true
92 this.controls.dampingFactor = 0.08
a3f01bd7 2026-09-08 Pavel Safronov93 // 60 km, down from 120. A kilometre of wall is 1% of the cap's diameter, so it
94 // is a couple of pixels from anywhere the old limit allowed — the depth was
95 // being drawn and could not be got close enough to to see.
96 this.controls.minDistance = 60
5f38efd0 2026-09-07 Pavel Safronov97 this.controls.maxDistance = 6000
98
11a13dbf 2026-09-07 Pavel Safronov99 this.hullMat = new MeshStandardMaterial()
5f38efd0 2026-09-07 Pavel Safronov100 this.hull = this.buildHull()
101 this.tower.add(this.hull, this.markers)
102 this.scene.add(this.tower)
de65ab10 2026-09-08 Pavel Safronov103 this.buildCollars()
5f38efd0 2026-09-07 Pavel Safronov104 this.buildCaps()
105 this.buildSunSystem()
106 this.buildStars()
107 this.buildLights()
15d933c9 2026-09-07 Pavel Safronov108 this.restoreCamera()
8b6287ba 2026-09-07 Pavel Safronov109 this.buildLabels()
5f38efd0 2026-09-07 Pavel Safronov110 this.attach()
111 this.resize()
8b6287ba 2026-09-07 Pavel Safronov112 /**
113 * Watch the canvas box rather than only the window. Restoring this view on load
114 * un-hides the canvas and constructs the renderer in the same task, before the
115 * browser has laid the element out — so `resize()` sees a zero-sized box, bails,
116 * and the renderer keeps its default 300x150 buffer stretched across the canvas.
117 * Clicking the view later worked because layout had long since settled, which is
118 * why this only appeared once the view began persisting across reloads.
119 */
120 new ResizeObserver(() => this.resize()).observe(canvas)
5f38efd0 2026-09-07 Pavel Safronov121 }
122
123 // ------------------------------------------------------------------ geometry
124
a3f01bd7 2026-09-08 Pavel Safronov125 /**
126 * Two meshes, not one, because the two sides of Tower's wall show different
127 * things and a single DoubleSide material cannot say so.
128 *
129 * The outer skin is what Tower looks like: bare metal, or — in x-ray mode — the
130 * inner surface projected onto it, which is a diagram and not a view (D-047).
131 * The inner surface is the land itself, and since the far cap became a window
132 * (D-045) it is genuinely visible from outside: look in through the glass and
133 * the far wall of the world is right there, lit and 540 km deep.
134 */
5f38efd0 2026-09-07 Pavel Safronov135 private buildHull(): Mesh {
136 const g = new CylinderGeometry(R, R, L, 160, 1, true)
137 g.rotateX(Math.PI / 2)
138 g.translate(0, 0, L / 2)
11a13dbf 2026-09-07 Pavel Safronov139 this.remapHullUVs(g)
140
141 this.hullMat = new MeshStandardMaterial({
a3f01bd7 2026-09-08 Pavel Safronov142 color: 0x8892a3, roughness: 0.62, metalness: 0.28, side: FrontSide,
11a13dbf 2026-09-07 Pavel Safronov143 })
a3f01bd7 2026-09-08 Pavel Safronov144 // The land, seen from inside, through the window. Always the terrain — there
145 // is no mode in which the inside of Tower is bare metal.
146 this.innerMat = new MeshStandardMaterial({
147 color: 0x2a2f38, roughness: 0.95, metalness: 0.0, side: BackSide,
148 })
149 this.tower.add(new Mesh(g.clone(), this.innerMat))
150
11a13dbf 2026-09-07 Pavel Safronov151 const hull = new Mesh(g, this.hullMat)
152 this.loadTerrain()
5f38efd0 2026-09-07 Pavel Safronov153 return hull
154 }
155
11a13dbf 2026-09-07 Pavel Safronov156 /**
157 * Rewrite the cylinder's UVs so the terrain raster lands where the map says it
158 * should. Three's cylinder runs u around the circumference and v along the axis;
159 * the raster is the other way round, with x = z and y = theta.
160 *
161 * Deriving the angle from vertex positions would be simpler and wrong: the seam
162 * vertices are duplicated, so atan2 gives both the same angle and smears one
163 * column of triangles across the whole image. Working from the original u keeps
164 * it linear, and RepeatWrapping absorbs the offset.
165 *
166 * Three builds the ring as x = R sin t, z = R cos t, so after rotateX the angle
167 * in the XY plane is t - pi/2. Canon theta is measured from +X, and the map is
168 * cut at mapThetaOrigin — hence the constant.
169 */
170 private remapHullUVs(g: CylinderGeometry): void {
171 const uv = g.attributes['uv']!
172 const shift = 0.25 + mapThetaOrigin() / (2 * Math.PI)
173 for (let i = 0; i < uv.count; i++) {
174 const around = uv.getX(i)
175 const along = uv.getY(i)
176 uv.setXY(i, 1 - along, around - shift)
177 }
178 uv.needsUpdate = true
179 }
180
181 /**
182 * Paint the inner surface onto the outer hull. This is a projection, not a view:
183 * from outside, Tower is structure, and what the texture shows is what is
184 * *inside*. It is an x-ray, and useful precisely because the hull is opaque.
185 */
186 private loadTerrain(): void {
187 new TextureLoader().load(
188 `${import.meta.env.BASE_URL}terrain-color.png`,
189 (tex) => {
190 tex.colorSpace = SRGBColorSpace
191 tex.wrapS = RepeatWrapping
192 tex.wrapT = RepeatWrapping
193 // uv.y is computed, not sampled from the file's row order, so no flip.
194 tex.flipY = false
195 tex.anisotropy = this.renderer.capabilities.getMaxAnisotropy()
196 this.terrainMap = tex
197 this.applyTerrain()
198 },
199 undefined,
200 () => { /* no raster yet: the plain hull is a fine fallback */ },
201 )
202 }
203
204 private applyTerrain(): void {
205 const on = this._showTerrain && this.terrainMap
206 this.hullMat.map = on ? this.terrainMap : null
207 /**
208 * Self-illuminate the terrain. Sunlight runs along the axis, so most of the
209 * hull faces away from every light in the scene and the map would be legible
210 * only on the sliver facing the fill. Emissive keeps it readable all the way
211 * round while the directional lights still give the cylinder its form.
212 */
213 this.hullMat.emissiveMap = on ? this.terrainMap : null
214 this.hullMat.emissive.set(on ? 0xffffff : 0x000000)
215 this.hullMat.emissiveIntensity = on ? 0.62 : 0
216 // Bare metal wants a tint; a textured surface wants to be left alone.
217 this.hullMat.color.set(on ? 0xffffff : 0x8892a3)
218 this.hullMat.metalness = on ? 0.06 : 0.28
219 this.hullMat.roughness = on ? 0.9 : 0.62
220 this.hullMat.needsUpdate = true
a3f01bd7 2026-09-08 Pavel Safronov221
222 // The inside carries the land in both modes, and always emissive: it is lit by
223 // a beam this scene does not model, and unlit it would read as a black hole
224 // behind the window rather than as a world.
225 if (this.terrainMap) {
226 this.innerMat.map = this.terrainMap
227 this.innerMat.emissiveMap = this.terrainMap
228 this.innerMat.emissive.set(0xffffff)
229 this.innerMat.emissiveIntensity = 0.5
230 this.innerMat.color.set(0xffffff)
231 this.innerMat.needsUpdate = true
232 }
11a13dbf 2026-09-07 Pavel Safronov233 }
234
235 set showTerrain(v: boolean) {
236 this._showTerrain = v
237 this.applyTerrain()
238 }
239
de65ab10 2026-09-08 Pavel Safronov240 /**
241 * The kilometre of wall (D-044), which from outside is only visible as depth at
242 * the two ends: the inner surface stops at z = 0 and z = L, the caps sit a
243 * kilometre further out at the skin, and what stands between them is a collar
244 * you can see down. There is no terrain on a collar — it is wall, not world.
245 */
246 private buildCollars(): void {
247 const mat = new MeshStandardMaterial({
248 color: 0x6f7787, roughness: 0.55, metalness: 0.42, side: DoubleSide,
249 })
250 for (const z0 of [-HULL_D, L]) {
251 const g = new CylinderGeometry(R, R, HULL_D, 160, 1, true)
252 g.rotateX(Math.PI / 2)
253 g.translate(0, 0, z0 + HULL_D / 2)
254 this.tower.add(new Mesh(g, mat))
255 }
256 }
257
5f38efd0 2026-09-07 Pavel Safronov258 private buildCaps(): void {
c44860b9 2026-09-07 Pavel Safronov259 // The same pattern the interior view paints on their far side (D-041). A cap is
260 // structure, not landscape, and the checker is what says so from either side.
261 const pattern = capPatternTexture()
5f38efd0 2026-09-07 Pavel Safronov262 // The sun cap is an annulus: the beam enters through the hole in the middle.
263 const sun = new Mesh(
264 new RingGeometry(BEAM_R, R, 96),
c44860b9 2026-09-07 Pavel Safronov265 new MeshStandardMaterial({
266 color: 0xdfe5ee, map: pattern, roughness: 0.5, metalness: 0.3, side: DoubleSide,
267 // Same licence as the hull's terrain: sunlight runs along the axis, so a cap
268 // is edge-on to every real light and renders as a dark disc exactly when it
269 // is being looked at. Enough emissive to keep the pattern legible.
270 emissiveMap: pattern, emissive: new Color(0xffffff), emissiveIntensity: 0.42,
271 }))
de65ab10 2026-09-08 Pavel Safronov272 sun.position.z = -HULL_D
5f38efd0 2026-09-07 Pavel Safronov273 this.tower.add(sun)
274
275 const aperture = new Mesh(
276 new CircleGeometry(BEAM_R, 48),
277 new MeshBasicMaterial({ color: 0xfff0cf }))
de65ab10 2026-09-08 Pavel Safronov278 aperture.position.z = -HULL_D + 0.4
5f38efd0 2026-09-07 Pavel Safronov279 this.tower.add(aperture)
280
769726ea 2026-09-08 Pavel Safronov281 /**
a3f01bd7 2026-09-08 Pavel Safronov282 * The far cap is a window (D-045), and from outside it is a window in the
283 * ordinary sense: the mullions are structure and the panes are clear, so what
284 * is behind them is the inside of Tower. That is the whole of the "true" view's
285 * payoff — the only line of sight into the world that is not a diagram.
769726ea 2026-09-08 Pavel Safronov286 */
287 const glazing = capGlazingTexture()
5f38efd0 2026-09-07 Pavel Safronov288 const far = new Mesh(
289 new CircleGeometry(R, 96),
c44860b9 2026-09-07 Pavel Safronov290 new MeshStandardMaterial({
769726ea 2026-09-08 Pavel Safronov291 color: 0xd8dee8, map: glazing, transparent: true, side: DoubleSide,
292 roughness: 0.72, metalness: 0.25,
293 emissiveMap: glazing, emissive: new Color(0xffffff), emissiveIntensity: 0.5,
c44860b9 2026-09-07 Pavel Safronov294 }))
769726ea 2026-09-08 Pavel Safronov295 far.position.z = L + HULL_D + 0.02
5f38efd0 2026-09-07 Pavel Safronov296 this.tower.add(far)
297 }
298
299 private buildSunSystem(): void {
300 // The collector flies ahead of the sun cap: 244 km across, wider than Tower.
301 const collector = new Mesh(
302 new CircleGeometry(COLLECTOR_R, 128),
303 new MeshStandardMaterial({
304 color: 0xe6edf8, roughness: 0.2, metalness: 0.5, side: DoubleSide,
305 emissive: new Color(0x93a2be), emissiveIntensity: 1.15,
306 }))
307 collector.position.z = -COLLECTOR_STANDOFF
308 this.scene.add(collector)
309
310 /**
8b6287ba 2026-09-07 Pavel Safronov311 * The converging beam, drawn as rays from the mirror's rim to the aperture.
312 *
313 * A translucent cone is the obvious construction and it fringes: at the mouth
314 * the shell is edge-on, so its triangles project to slivers and the additive
315 * blend stacks them into bright dashes across the collector. Isolated by
316 * bisection — removing the cone cleared the disc, removing the stars did not,
317 * and shortening the cone moved the dashes with its mouth. Rays have no
318 * silhouette to alias, and read more like light besides.
319 *
320 * In vacuum none of this would be visible; there is nothing out here to
321 * scatter it. Same licence as the hillshade on the map.
5f38efd0 2026-09-07 Pavel Safronov322 */
8b6287ba 2026-09-07 Pavel Safronov323 const RAYS = 220
324 const verts = new Float32Array(RAYS * 6)
325 const cols = new Float32Array(RAYS * 8)
326 for (let i = 0; i < RAYS; i++) {
327 const a = (i / RAYS) * Math.PI * 2
328 // Spread the starts across the mirror rather than only round its rim, so the
329 // beam has a body instead of being a hollow funnel.
330 const r = COLLECTOR_R * Math.sqrt(0.15 + 0.85 * ((i * 0.6180339887) % 1))
331 verts.set([
332 r * Math.cos(a), r * Math.sin(a), -COLLECTOR_STANDOFF,
333 BEAM_R * Math.cos(a), BEAM_R * Math.sin(a), 0,
334 ], i * 6)
335 cols.set([1, 0.95, 0.8, 0.0, 1, 0.97, 0.86, 0.38], i * 8)
336 }
337 const beam = new BufferGeometry()
338 beam.setAttribute('position', new Float32BufferAttribute(verts, 3))
339 beam.setAttribute('color', new Float32BufferAttribute(cols, 4))
340 this.scene.add(new LineSegments(beam, new LineBasicMaterial({
341 vertexColors: true, transparent: true,
342 blending: AdditiveBlending, depthWrite: false,
5f38efd0 2026-09-07 Pavel Safronov343 })))
344 }
345
346 private buildStars(): void {
347 const n = 3500
348 const pos = new Float32Array(n * 3)
349 for (let i = 0; i < n; i++) {
350 const v = new Vector3().randomDirection().multiplyScalar(18_000)
351 pos.set([v.x, v.y, v.z], i * 3)
352 }
353 const g = new BufferGeometry()
354 g.setAttribute('position', new Float32BufferAttribute(pos, 3))
355 this.scene.add(new Points(g, new PointsMaterial({ color: 0xdfe8ff, size: 26, sizeAttenuation: true })))
356
357 const glow = new Mesh(
358 new SphereGeometry(300, 24, 24),
359 new MeshBasicMaterial({ color: 0xffe9bd, transparent: true, opacity: 0.5, side: BackSide }))
360 glow.position.z = -14_000
361 this.scene.add(glow)
362 }
363
8b6287ba 2026-09-07 Pavel Safronov364 /**
365 * A pale disc and a glowing cone are not self-explanatory, and the collector is
366 * the least intuitive object in Tower: it is bigger than the world it feeds.
367 */
368 private buildLabels(): void {
369 if (!this.labelHost) return
370 const add = (at: Vector3, title: string, sub: string) => {
371 const el = document.createElement('span')
372 el.innerHTML = `${title}<small>${sub}</small>`
373 this.labelHost!.append(el)
374 this.labels.push({ at, el })
375 }
376 add(new Vector3(0, COLLECTOR_R * 0.82, -COLLECTOR_STANDOFF), 'COLLECTOR', '244 km across')
377 add(new Vector3(0, 96, -COLLECTOR_STANDOFF * 0.5), 'BEAM', '54.3 TW into a 3.2 km hole')
378 add(new Vector3(0, R + 26, 0), 'SUN CAP', 'faces the sun')
379 add(new Vector3(0, R + 26, L), 'FAR CAP', '540 km away')
380 }
381
382 private updateLabels(): void {
383 if (!this.labels.length) return
384 const { width, height } = this.canvas.getBoundingClientRect()
385 const v = new Vector3()
15d933c9 2026-09-07 Pavel Safronov386 const placed: { x: number; y: number }[] = []
8b6287ba 2026-09-07 Pavel Safronov387 for (const { at, el } of this.labels) {
388 v.copy(at).project(this.camera)
389 // z > 1 means the point is behind the camera; project wraps it to the far side.
15d933c9 2026-09-07 Pavel Safronov390 let visible = v.z < 1 && Math.abs(v.x) < 1.6 && Math.abs(v.y) < 1.6
391 const x = ((v.x + 1) / 2) * width
392 const y = ((1 - v.y) / 2) * height
393 // Drop captions that would land on one already placed. Close in on a cap,
394 // all four project into the same corner and become an unreadable stack.
395 if (visible && placed.some((p) => Math.abs(p.x - x) < 170 && Math.abs(p.y - y) < 36)) {
396 visible = false
397 }
8b6287ba 2026-09-07 Pavel Safronov398 el.style.display = visible ? 'block' : 'none'
399 if (!visible) continue
15d933c9 2026-09-07 Pavel Safronov400 placed.push({ x, y })
401 el.style.left = `${x}px`
402 el.style.top = `${y}px`
8b6287ba 2026-09-07 Pavel Safronov403 }
404 }
405
5f38efd0 2026-09-07 Pavel Safronov406 private buildLights(): void {
407 // The real light runs along the axis, from beyond the collector.
408 const sun = new DirectionalLight(0xfff3dd, 2.2)
409 sun.position.set(0, 0, -8000)
410 this.scene.add(sun)
411
412 /**
413 * Fill light, and a reading aid rather than physics. Sunlight here is parallel
414 * to the axis, so Tower's flank is edge-on to it and correctly renders almost
415 * black — accurate and useless. These two lift the hull enough to read its
416 * shape, the same licence as the hillshade on the map.
417 */
418 const key = new DirectionalLight(0xc8d6f0, 1.5)
419 key.position.set(-1, -1.4, 0.35)
420 this.scene.add(key)
421 const rim = new DirectionalLight(0x8fa8d8, 0.7)
422 rim.position.set(1.2, 0.9, -0.4)
423 this.scene.add(rim)
15d933c9 2026-09-07 Pavel Safronov424 // The far cap's outer face points away from the sun and from both fills, so it
425 // renders as a black hole exactly when you focus on it.
426 const behind = new DirectionalLight(0x9fb2d4, 0.55)
427 behind.position.set(-0.5, -0.8, 1)
428 this.scene.add(behind)
5f38efd0 2026-09-07 Pavel Safronov429 this.scene.add(new AmbientLight(0x3a4356, 1.6))
430 }
431
432 // ------------------------------------------------------------------- markers
433
434 /** Put a dot on the hull for every place, at its canon (theta, z). */
435 setPlaces(places: readonly Place[], selectedId: string | null): void {
436 this.markers.clear()
437 for (const p of places) {
438 const g = p.geometry
439 const at: SurfacePoint = g.type === 'point'
440 ? { theta: g.theta, z: g.z }
04d6779e 2026-09-07 Pavel Safronov441 : g.type === 'band'
442 // A band rings the world, so any theta is as good as another.
443 ? { theta: 0, z: (g.zFrom + g.zTo) / 2 }
444 : { theta: g.points[0]![0], z: g.points[0]![1] }
5f38efd0 2026-09-07 Pavel Safronov445 const selected = p.id === selectedId
11a13dbf 2026-09-07 Pavel Safronov446 const r = selected ? 6.5 : 4.2
5f38efd0 2026-09-07 Pavel Safronov447 const dot = new Mesh(
11a13dbf 2026-09-07 Pavel Safronov448 new SphereGeometry(r, 14, 14),
5f38efd0 2026-09-07 Pavel Safronov449 new MeshBasicMaterial({ color: new Color(KIND_COLOR[p.kind] ?? '#ccc') }))
11a13dbf 2026-09-07 Pavel Safronov450 // A dark shell behind each dot. Small bright spheres read fine against a bare
451 // hull and vanish into the terrain once it is painted on; the outline is what
452 // keeps them legible over green forest and tan desert alike.
453 dot.add(new Mesh(
454 new SphereGeometry(r * 1.45, 14, 14),
455 new MeshBasicMaterial({ color: 0x05070d, side: BackSide })))
15d933c9 2026-09-07 Pavel Safronov456 const zKm = at.z / KM
457 if (isCapZ(zKm)) {
458 // A cap is a disc across the end, so its place is the axis — not a point on
459 // the rim, which would ride round the edge as Tower turns.
460 dot.position.set(0, 0, zKm < L / 2 ? -5 : L + 5)
461 } else {
462 dot.position.set(
463 (R + 1.6) * Math.cos(at.theta), (R + 1.6) * Math.sin(at.theta), zKm)
464 }
5f38efd0 2026-09-07 Pavel Safronov465 dot.userData['placeId'] = p.id
11a13dbf 2026-09-07 Pavel Safronov466 dot.renderOrder = 2
5f38efd0 2026-09-07 Pavel Safronov467 this.markers.add(dot)
468 }
469 }
470
15d933c9 2026-09-07 Pavel Safronov471 // ------------------------------------------------------------------- camera
472
473 /**
474 * Restore where the camera was left. Every field is checked before use: a
475 * corrupt or stale entry here would leave the view pointing at nothing with no
476 * obvious way back, and silently-bad persisted state has bitten this app once
477 * already (D-025).
478 */
479 private restoreCamera(): void {
480 try {
481 const raw = localStorage.getItem(CAMERA_KEY)
482 if (!raw) return
483 const s = JSON.parse(raw) as { p?: unknown; t?: unknown }
484 const ok = (v: unknown): v is number[] =>
485 Array.isArray(v) && v.length === 3 && v.every((n) => typeof n === 'number' && Number.isFinite(n))
486 if (!ok(s.p) || !ok(s.t)) return
487 const pos = new Vector3(s.p[0], s.p[1], s.p[2])
488 const target = new Vector3(s.t[0], s.t[1], s.t[2])
489 const d = pos.distanceTo(target)
490 if (d < this.controls.minDistance || d > this.controls.maxDistance) return
491 this.camera.position.copy(pos)
492 this.controls.target.copy(target)
493 this.controls.update()
494 } catch {
495 // Unreadable storage just means the default framing.
496 }
497 }
498
499 private saveCamera(): void {
500 if (this.cameraSaveTimer) clearTimeout(this.cameraSaveTimer)
501 this.cameraSaveTimer = null
502 try {
503 const p = this.camera.position
504 const t = this.controls.target
505 localStorage.setItem(CAMERA_KEY, JSON.stringify({
506 p: [p.x, p.y, p.z].map((n) => Math.round(n * 100) / 100),
507 t: [t.x, t.y, t.z].map((n) => Math.round(n * 100) / 100),
508 }))
509 } catch {
510 // Not persisting the camera is survivable.
511 }
512 }
513
5f38efd0 2026-09-07 Pavel Safronov514 // --------------------------------------------------------------- interaction
515
516 private attach(): void {
15d933c9 2026-09-07 Pavel Safronov517 // OrbitControls fires 'change' per frame while dragging, so debounce rather
518 // than writing to storage on every mouse move.
519 this.controls.addEventListener('change', () => {
520 if (this.cameraSaveTimer) clearTimeout(this.cameraSaveTimer)
521 this.cameraSaveTimer = setTimeout(() => this.saveCamera(), CAMERA_SAVE_MS)
522 })
523 window.addEventListener('pagehide', () => this.saveCamera())
524
5f38efd0 2026-09-07 Pavel Safronov525 this.canvas.addEventListener('pointerdown', () => { this.moved = false })
526 this.canvas.addEventListener('pointermove', () => { this.moved = true })
527 this.canvas.addEventListener('pointerup', (e) => {
528 if (this.moved) return
529 const r = this.canvas.getBoundingClientRect()
530 const ndc = new Vector2(
531 ((e.clientX - r.left) / r.width) * 2 - 1,
532 -((e.clientY - r.top) / r.height) * 2 + 1)
533 this.raycaster.setFromCamera(ndc, this.camera)
534
535 const onMarker = this.raycaster.intersectObjects(this.markers.children, false)[0]
536 if (onMarker) return this.cb.onPickPlace(onMarker.object.userData['placeId'] as string)
537
538 const onHull = this.raycaster.intersectObject(this.hull, false)[0]
539 if (!onHull) return
540 // Into the hull's own frame, so the spin is undone and theta is canon.
541 const local = this.tower.worldToLocal(onHull.point.clone())
542 this.cb.onPickPoint({
543 theta: normalizeTheta(Math.atan2(local.y, local.x)),
544 z: Math.max(0, Math.min(LENGTH_M, local.z * KM)),
545 })
546 })
547 }
548
549 resize(): void {
550 const { width, height } = this.canvas.getBoundingClientRect()
551 if (!width || !height) return
552 this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
553 this.renderer.setSize(width, height, false)
554 this.camera.aspect = width / height
555 this.camera.updateProjectionMatrix()
556 }
557
558 /** Frame a surface point: swing the camera round to look at it from outside. */
559 lookAt(p: SurfacePoint): void {
560 const worldTheta = p.theta + this.phase
561 const z = p.z / KM
15d933c9 2026-09-07 Pavel Safronov562
563 /**
564 * A cap is a wall across the end, not a patch of ground, so the usual
c44860b9 2026-09-07 Pavel Safronov565 * from-the-side framing shows only an edge. This sits about 40° off the axis:
566 * far enough round to see the cap's face — which now carries a pattern worth
567 * seeing (D-041) — without looking straight down the barrel and foreshortening
568 * the whole 540 km behind it.
15d933c9 2026-09-07 Pavel Safronov569 */
570 if (isCapZ(z)) {
571 const atSun = z < L / 2
572 const face = atSun ? 0 : L
573 const dir = atSun ? -1 : 1
c44860b9 2026-09-07 Pavel Safronov574 this.controls.target.set(0, 0, face - dir * 30)
575 this.camera.position.set(R * 1.6, -R * 3.6, face + dir * 240)
15d933c9 2026-09-07 Pavel Safronov576 this.controls.update()
577 return
578 }
5f38efd0 2026-09-07 Pavel Safronov579 this.controls.target.set(R * 0.4 * Math.cos(worldTheta), R * 0.4 * Math.sin(worldTheta), z)
580 const d = 260
581 this.camera.position.set(
582 (R + d) * Math.cos(worldTheta), (R + d) * Math.sin(worldTheta), z + 90)
583 this.controls.update()
584 }
585
586 // -------------------------------------------------------------------- loop
587
588 start(): void {
589 if (this.running) return
590 this.running = true
591 this.lastFrame = performance.now()
592 const tick = (now: number) => {
593 if (!this.running) return
594 const dt = Math.min(0.1, (now - this.lastFrame) / 1000)
595 this.lastFrame = now
596 if (this.spinning) this.phase = (this.phase + OMEGA_RAD_S * dt * this.timeScale) % (2 * Math.PI)
597 this.tower.rotation.z = this.phase
11a13dbf 2026-09-07 Pavel Safronov598 /**
599 * Keep markers a roughly constant size on screen. A 4 km dot is legible from
600 * 200 km away and sub-pixel from 1,200 km, which is where the default view
601 * sits — so at world scale the places would simply not be there.
602 */
603 const scale = Math.max(0.8, Math.min(2.5, this.camera.position.distanceTo(this.controls.target) / 700))
604 for (const m of this.markers.children) m.scale.setScalar(scale)
5f38efd0 2026-09-07 Pavel Safronov605 this.controls.update()
606 this.renderer.render(this.scene, this.camera)
8b6287ba 2026-09-07 Pavel Safronov607 this.updateLabels()
5f38efd0 2026-09-07 Pavel Safronov608 requestAnimationFrame(tick)
609 }
610 requestAnimationFrame(tick)
611 }
612
613 stop(): void {
614 this.running = false
615 }
616}