tower / commit
feat(exterior): persist the camera, and make the caps focusable places
content/geography.json | 24 +++++++++++ decisions.md | 34 ++++++++++++++++ design.md | 6 +++ src/views/exterior.ts | 106 ++++++++++++++++++++++++++++++++++++++++++++++--- static/world.json | 22 ++++++++++ 5 files changed, 187 insertions(+), 5 deletions(-) diff --git a/content/geography.json b/content/geography.json index 4dcf263..681c2b4 100644 --- a/content/geography.json +++ b/content/geography.json @@ -131,6 +131,30 @@ }, "notes": "The same at the far cap, and the larger of the two. A 314 km shoreline with a 50 km cliff behind it, and the only windows in the world at its foot (§1.4)." }, + { + "id": "sun-cap", + "name": "The Sun Cap", + "provisionalName": true, + "kind": "landmark", + "geometry": { + "type": "point", + "theta": 0.0, + "z": 0 + }, + "notes": "The sunward end of the world, and one of only two views out (§1.4). A cap is a wall, not a floor: gravity there points radially outward, tangent to its surface, so nothing rests on it. Fifty kilometres of vertical cliff with its windows at the base, and the beam entering through a 3.2 km aperture at its centre." + }, + { + "id": "far-cap", + "name": "The Far Cap", + "provisionalName": true, + "kind": "landmark", + "geometry": { + "type": "point", + "theta": 0.0, + "z": 540000 + }, + "notes": "The deepward end, 540 km away, standing over The Deepward Sea. Its windows look along the spin axis — which is the celestial pole — so the stars do not sweep past but turn about a fixed centre, one revolution every 7 min 29 s. The only moving sky anyone in Tower ever sees." + }, { "id": "snowcrown", "name": "Snowcrown", diff --git a/decisions.md b/decisions.md index 57d3429..f478b73 100644 --- a/decisions.md +++ b/decisions.md @@ -892,3 +892,37 @@ restoring it on boot hit the window. Fixed with a `ResizeObserver` on the canvas rather than another guess about timing, which also covers the sidebar and window resizing for free. + +--- + +## D-030 — The camera persists, and the caps are places + +**Both at the author's request.** + +**Camera.** Position and target are stored per browser and restored on load, so a +reload returns to the vantage you left rather than the default framing. Every field +is validated before use — three finite numbers each, and a distance inside the +controls' own limits — because a corrupt entry here would leave the view pointing at +nothing with no obvious way back, and silently-bad persisted state has already cost +this project once (D-025). Saves are debounced: OrbitControls fires `change` every +frame of a drag. + +**Caps.** The sun cap and far cap are now features in `content/geography.json`, so +they appear in the place list and can be focused like anywhere else. Two things had +to be special-cased, both for the same underlying reason — *a cap is not a patch of +ground*: + +- **Its marker belongs on the axis.** Placed on the hull at theta = 0 like every + other marker, it sat on the cap's rim and rode round the edge as Tower turned. + A cap's location is its centre. +- **Its framing is different.** The usual from-the-side shot shows an end cap edge-on + as a line. Standing off along the axis instead is worse — it looks down the barrel + and foreshortens all 540 km behind an unlit disc. What works is the default view's + oblique angle slid onto the cap, and a light from deepward so the far cap's outer + face is not a black hole exactly when you focus on it. + +**A note on the debugging.** Verifying camera persistence took several wrong turns +because the test kept invalidating itself: `location.reload()` fires `pagehide`, +which saved the live camera over the value the test had just planted. The +persistence was working the whole time. Worth remembering that a `pagehide` save +makes "set state, reload, check state" an invalid experiment. diff --git a/design.md b/design.md index f969f2b..ad07aee 100644 --- a/design.md +++ b/design.md @@ -577,6 +577,12 @@ way round, so the UVs are rewritten from the original attribute rather than deri from vertex positions — the seam vertices are duplicated, and deriving an angle from them smears one column of triangles across the whole image. +**The camera position persists** across reloads, alongside the active view, so +returning to the page returns to the vantage you left. Both end caps are places in +their own right, so the view can frame them: a cap's marker sits on the **axis**, +because a cap is a disc across the end and a point on its rim would ride round the +edge as Tower turns. + Markers are scaled per frame to hold a roughly constant size on screen. A 4 km dot is legible from 200 km and sub-pixel from the 1,200 km of the default view, which is to say the places would simply not be there. diff --git a/src/views/exterior.ts b/src/views/exterior.ts index c67ac78..9497aa6 100644 --- a/src/views/exterior.ts +++ b/src/views/exterior.ts @@ -30,6 +30,12 @@ const BEAM_R = BEAM_RADIUS_M / KM const COLLECTOR_R = COLLECTOR_DIAMETER_M / KM / 2 /** How far ahead of the sun cap the collector flies. Not fixed by canon. */ const COLLECTOR_STANDOFF = 320 +/** How close to an end, in km, counts as being on a cap rather than on the wall. */ +const CAP_TOLERANCE = 8 +const isCapZ = (zKm: number): boolean => zKm < CAP_TOLERANCE || zKm > L - CAP_TOLERANCE + +const CAMERA_KEY = 'tower.exteriorCamera' +const CAMERA_SAVE_MS = 400 export interface ExteriorCallbacks { onPickPoint: (p: SurfacePoint) => void @@ -52,6 +58,7 @@ export class ExteriorView { private running = false private lastFrame = 0 private moved = false + private cameraSaveTimer: ReturnType<typeof setTimeout> | null = null /** Spin phase in radians, shared with the rest of the app. */ phase = 0 @@ -88,6 +95,7 @@ export class ExteriorView { this.buildSunSystem() this.buildStars() this.buildLights() + this.restoreCamera() this.buildLabels() this.attach() this.resize() @@ -297,14 +305,23 @@ export class ExteriorView { if (!this.labels.length) return const { width, height } = this.canvas.getBoundingClientRect() const v = new Vector3() + const placed: { x: number; y: number }[] = [] for (const { at, el } of this.labels) { v.copy(at).project(this.camera) // z > 1 means the point is behind the camera; project wraps it to the far side. - const visible = v.z < 1 && Math.abs(v.x) < 1.6 && Math.abs(v.y) < 1.6 + let visible = v.z < 1 && Math.abs(v.x) < 1.6 && Math.abs(v.y) < 1.6 + const x = ((v.x + 1) / 2) * width + const y = ((1 - v.y) / 2) * height + // Drop captions that would land on one already placed. Close in on a cap, + // all four project into the same corner and become an unreadable stack. + if (visible && placed.some((p) => Math.abs(p.x - x) < 170 && Math.abs(p.y - y) < 36)) { + visible = false + } el.style.display = visible ? 'block' : 'none' if (!visible) continue - el.style.left = `${((v.x + 1) / 2) * width}px` - el.style.top = `${((1 - v.y) / 2) * height}px` + placed.push({ x, y }) + el.style.left = `${x}px` + el.style.top = `${y}px` } } @@ -326,6 +343,11 @@ export class ExteriorView { const rim = new DirectionalLight(0x8fa8d8, 0.7) rim.position.set(1.2, 0.9, -0.4) this.scene.add(rim) + // The far cap's outer face points away from the sun and from both fills, so it + // renders as a black hole exactly when you focus on it. + const behind = new DirectionalLight(0x9fb2d4, 0.55) + behind.position.set(-0.5, -0.8, 1) + this.scene.add(behind) this.scene.add(new AmbientLight(0x3a4356, 1.6)) } @@ -353,17 +375,75 @@ export class ExteriorView { dot.add(new Mesh( new SphereGeometry(r * 1.45, 14, 14), new MeshBasicMaterial({ color: 0x05070d, side: BackSide }))) - dot.position.set( - (R + 1.6) * Math.cos(at.theta), (R + 1.6) * Math.sin(at.theta), at.z / KM) + const zKm = at.z / KM + if (isCapZ(zKm)) { + // A cap is a disc across the end, so its place is the axis — not a point on + // the rim, which would ride round the edge as Tower turns. + dot.position.set(0, 0, zKm < L / 2 ? -5 : L + 5) + } else { + dot.position.set( + (R + 1.6) * Math.cos(at.theta), (R + 1.6) * Math.sin(at.theta), zKm) + } dot.userData['placeId'] = p.id dot.renderOrder = 2 this.markers.add(dot) } } + // ------------------------------------------------------------------- camera + + /** + * Restore where the camera was left. Every field is checked before use: a + * corrupt or stale entry here would leave the view pointing at nothing with no + * obvious way back, and silently-bad persisted state has bitten this app once + * already (D-025). + */ + private restoreCamera(): void { + try { + const raw = localStorage.getItem(CAMERA_KEY) + if (!raw) return + const s = JSON.parse(raw) as { p?: unknown; t?: unknown } + const ok = (v: unknown): v is number[] => + Array.isArray(v) && v.length === 3 && v.every((n) => typeof n === 'number' && Number.isFinite(n)) + if (!ok(s.p) || !ok(s.t)) return + const pos = new Vector3(s.p[0], s.p[1], s.p[2]) + const target = new Vector3(s.t[0], s.t[1], s.t[2]) + const d = pos.distanceTo(target) + if (d < this.controls.minDistance || d > this.controls.maxDistance) return + this.camera.position.copy(pos) + this.controls.target.copy(target) + this.controls.update() + } catch { + // Unreadable storage just means the default framing. + } + } + + private saveCamera(): void { + if (this.cameraSaveTimer) clearTimeout(this.cameraSaveTimer) + this.cameraSaveTimer = null + try { + const p = this.camera.position + const t = this.controls.target + localStorage.setItem(CAMERA_KEY, JSON.stringify({ + p: [p.x, p.y, p.z].map((n) => Math.round(n * 100) / 100), + t: [t.x, t.y, t.z].map((n) => Math.round(n * 100) / 100), + })) + } catch { + // Not persisting the camera is survivable. + } + } + // --------------------------------------------------------------- interaction private attach(): void { + // OrbitControls fires 'change' per frame while dragging, so debounce rather + // than writing to storage on every mouse move. + this.controls.addEventListener('change', () => { + if (this.cameraSaveTimer) clearTimeout(this.cameraSaveTimer) + this.cameraSaveTimer = setTimeout(() => this.saveCamera(), CAMERA_SAVE_MS) + }) + window.addEventListener('pagehide', () => this.saveCamera()) + this.canvas.addEventListener('pointerdown', () => { this.moved = false }) this.canvas.addEventListener('pointermove', () => { this.moved = true }) this.canvas.addEventListener('pointerup', (e) => { @@ -401,6 +481,22 @@ export class ExteriorView { lookAt(p: SurfacePoint): void { const worldTheta = p.theta + this.phase const z = p.z / KM + + /** + * A cap is a wall across the end, not a patch of ground, so the usual + * from-the-side framing shows only an edge. Keep the default view's oblique + * angle and slide it onto the cap: standing off along the axis instead looks + * down the barrel and foreshortens the whole 540 km behind an unlit disc. + */ + if (isCapZ(z)) { + const atSun = z < L / 2 + const face = atSun ? 0 : L + const dir = atSun ? -1 : 1 + this.controls.target.set(0, 0, face - dir * 40) + this.camera.position.set(R * 3, -R * 12, face + dir * 150) + this.controls.update() + return + } this.controls.target.set(R * 0.4 * Math.cos(worldTheta), R * 0.4 * Math.sin(worldTheta), z) const d = 260 this.camera.position.set( diff --git a/static/world.json b/static/world.json index 9ee8dc0..f5e1438 100644 --- a/static/world.json +++ b/static/world.json @@ -23,6 +23,28 @@ }, "blurb": "" }, + { + "id": "sun-cap", + "name": "The Sun Cap", + "kind": "landmark", + "geometry": { + "type": "point", + "theta": 0, + "z": 0 + }, + "blurb": "" + }, + { + "id": "far-cap", + "name": "The Far Cap", + "kind": "landmark", + "geometry": { + "type": "point", + "theta": 0, + "z": 540000 + }, + "blurb": "" + }, { "id": "snowcrown", "name": "Snowcrown",