tower / commit
feat(exterior): Tower from outside, with hull picking
README.md | 8 ++ decisions.md | 35 +++++++ design.md | 30 +++++- index.html | 21 +++- package-lock.json | 75 ++++++++++++++ package.json | 6 +- src/style.css | 16 ++- src/ui/app.ts | 98 +++++++++++++++++- src/views/exterior.ts | 275 ++++++++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 554 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index fc9cc94..3c46370 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,14 @@ Any selection updates the URL, so a link is always shareable: | `#place=the-narrows` | select and centre a place | | `#at=0.03,170000` | drop a sightline endpoint at (θ, z) | +## Views + +**Map** is the unrolled inner surface. **Exterior** is Tower from outside — hull, +end caps, the 244 km collector flying ahead and the beam converging into the sun +cap. Drag to orbit, scroll to zoom, click the hull to drop a point; the spin runs at +1x to 1200x real time, and a point picked off the turning hull still comes back in +canon coordinates. + ## Working in the map Pick a tool, click to place; **Lake**, **Forest**, **Desert** and **Region** trace a diff --git a/decisions.md b/decisions.md index d1a9359..48c61ac 100644 --- a/decisions.md +++ b/decisions.md @@ -700,3 +700,38 @@ a permalink is worthless if the id churns. **Labels are placed by priority and skipped on collision.** Settlements outrank regions; overlapping labels are dropped rather than drawn on top of each other. With 22 seeded places the mountain stations were an unreadable smear. + +--- + +## D-023 — The exterior view, and what it is honest about + +**Implemented.** Phase 3: Tower from outside, with the hull, both caps, the +collector, the converging beam, the spin, and picking on the hull. + +**Canon numbers are the code's numbers.** Scene units are kilometres and the axis +lies along Z with the sun cap at z = 0, so a canon point is literally +`(R cos θ, R sin θ, z)`. No conversion layer to get wrong. + +**Picking has to undo the spin.** The hull turns, so a ray hit is in world space +while the answer wanted is canon θ, which is fixed to the hull. Resolving the hit +through `worldToLocal` handles it. Verified by clicking the same screen pixel at two +spin phases 34° apart and confirming the reported θ moved by 0.588 rad against a +0.593 rad phase change, with z identical — the pixel really is landing on different +ground as Tower turns beneath it. + +**Two departures from physics, both stated in the code.** Sunlight runs parallel to +the axis, so the flank is edge-on and correctly renders almost black; fill lights +make it readable. And in vacuum the beam between collector and cap has nothing to +scatter off, so it would be invisible; it is drawn faintly because it is the point +of the view. Same licence as the map's hillshade, and worth naming rather than +letting a reader mistake either for a physical claim. + +**Three.js loads on first use of the view.** It is by far the largest dependency and +starts an animation loop; a reader who only opens the map should not pay for it. + +**Earth is not drawn.** It is ~90° off the axis and hundreds of thousands of km +away; at true scale it is invisible, and at any visible scale it is a lie about +distance. Given how much of this world came out of taking distances seriously, a +compressed Earth would be the wrong kind of shortcut. The sunward glow is honest — +the sun really is that way — and D-017's point stands: from inside, Earth is never +visible at all. diff --git a/design.md b/design.md index 99430ee..0b09105 100644 --- a/design.md +++ b/design.md @@ -508,7 +508,7 @@ src/ state/ store.ts selected point, time-of-day, spin phase, view mode, writer flag views/ - exterior.ts Three.js: hull, slats, collector, beam, orbit, point picker + exterior.ts Three.js: hull, caps, collector, beam, spin, point picker map.ts Canvas 2D: unrolled surface, slats, terminator, point picker interior.ts Three.js: shell geometry, beam, diffusers, ground sky.ts the atmospheric shader of §1.5 @@ -543,6 +543,28 @@ type TowerState = { The exterior and map pickers both write `point`; the interior view reads it. That is the entire interaction loop. +### 2.4 The exterior view + +Scene units are kilometres, so the code reads in canon numbers. Tower's axis lies +along world Z with the sun cap at z = 0, which puts a canon surface point at +`(R cos θ, R sin θ, z)` in the hull's own frame. The hull spins about Z and carries +that frame with it, so **picking resolves the ray hit back through +`worldToLocal`** — otherwise every point picked off a turning hull would be wrong by +the spin phase. + +Two deliberate departures from physics, both reading aids of the same kind as the +map's hillshade: + +- **Fill lighting.** Real sunlight here runs parallel to the axis, so Tower's flank + is edge-on to it and renders very nearly black — accurate and useless. Two fill + lights lift the hull enough to read its shape. +- **A visible beam.** In vacuum there is nothing between collector and cap to + scatter light, so the converging cone would not be visible at all. It is drawn + faintly because it is the thing the view is about. + +The default camera frames the whole system rather than Tower alone: the collector +is 244 km across and flies 320 km ahead, and it is the larger object. + ### 2.4 The interior view Per the chosen scope: **real geometry, simple ground.** @@ -660,7 +682,7 @@ a moment. Reading the world, not editing it. 1. **Design** — this document and `decisions.md`. ← current 2. **Scaffold** — Vite/TS/Three, CI to Pages, `canon/` implemented and unit-tested. -3. **Exterior** — hull, collector, beam, slats, orbit; hull point picking. +3. **Exterior** — ✅ hull, caps, collector, beam, spin, and hull point picking. 4. **Map** — unrolled surface, slat rendering, picking, sync with exterior. 5. **Interior** — shell geometry, sky shader, beam, stars, time-of-day. 6. **Writer mode** — ✅ *built ahead of 3–5, at the author's request, because the @@ -672,8 +694,8 @@ a moment. Reading the world, not editing it. writer modes (D-011); permalinks (D-010). 8. **Polish** — transitions, mobile, performance. -Phases 3 (exterior) and 5 (interior) are still ahead; phase 2's `canon/` landed -with phase 6 since the map could not be drawn without it. +Phase 5 (interior) is the last major piece. Phase 2's `canon/` landed with phase 6, +since the map could not be drawn without it. **Shareable permalinks are implemented** (phase 7) — the app has readers, and "stand here and look at this" is the whole reason it exists as a diff --git a/index.html b/index.html index ffbb7ac..30b4deb 100644 --- a/index.html +++ b/index.html @@ -10,6 +10,12 @@ <div id="app"> <main> <canvas id="map"></canvas> + <canvas id="exterior" class="hidden"></canvas> + <div id="views" class="views"> + <button data-view="map" class="active">Map</button> + <button data-view="exterior">Exterior</button> + </div> + <div id="spin" class="overlay hidden"></div> <div id="cursor" class="overlay hidden"></div> <div id="sightline" class="overlay hidden"></div> </main> @@ -70,9 +76,18 @@ <section id="view-section"> <h2>View</h2> - <label class="row"><input id="f-terrain" type="checkbox" checked /> Show terrain</label> - <p class="hint">Rendered by <code>npm run terrain</code> from - <code>content/geography.json</code>.</p> + <div id="map-options"> + <label class="row"><input id="f-terrain" type="checkbox" checked /> Show terrain</label> + <p class="hint">Rendered by <code>npm run terrain</code> from + <code>content/geography.json</code>.</p> + </div> + <div id="exterior-options" class="hidden"> + <label class="row"><input id="f-spin" type="checkbox" checked /> Spin</label> + <label>Speed <span id="speed-value" class="dim"></span> + <input id="f-speed" type="range" min="0" max="4" step="1" value="2" /></label> + <p class="hint">Tower turns once every 7 min 29 s. Drag to orbit, scroll to + zoom, click the hull to drop a point.</p> + </div> </section> <section id="data-section"> diff --git a/package-lock.json b/package-lock.json index 40a3564..e2d5f3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,13 +7,24 @@ "": { "name": "tower", "version": "0.1.0", + "dependencies": { + "three": "^0.180.0" + }, "devDependencies": { "@types/node": "^26.4.1", + "@types/three": "^0.180.0", "typescript": "^7.0.2", "vite": "^8.2.2", "vitest": "^5.0.0" } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -314,6 +325,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -349,6 +367,36 @@ "undici-types": "~8.3.0" } }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.180.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz", + "integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": "*", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~0.22.0" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript/typescript-aix-ppc64": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", @@ -727,6 +775,13 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@webgpu/types": { + "version": "0.1.72", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.72.tgz", + "integrity": "sha512-0cF7RFM2edNoiIS1ODJp0/Gzv4/xSXhwoR0YCza+OWpJWtn4wmo9DvK91aLlH9+uUnwIriP7ZiC3WitmyhuzBw==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -802,6 +857,13 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1088,6 +1150,13 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/meshoptimizer": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz", + "integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", @@ -1235,6 +1304,12 @@ "dev": true, "license": "MIT" }, + "node_modules/three": { + "version": "0.180.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz", + "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "6.1.4", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", diff --git a/package.json b/package.json index fd9caab..9233358 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ }, "devDependencies": { "@types/node": "^26.4.1", + "@types/three": "^0.180.0", "typescript": "^7.0.2", "vite": "^8.2.2", "vitest": "^5.0.0" @@ -22,5 +23,8 @@ "type": "git", "url": "https://gitlab.com/PavelSafronov/tower.git" }, - "homepage": "https://pavelsafronov.gitlab.io/tower/" + "homepage": "https://pavelsafronov.gitlab.io/tower/", + "dependencies": { + "three": "^0.180.0" + } } diff --git a/src/style.css b/src/style.css index 1c5d33e..5de6b4e 100644 --- a/src/style.css +++ b/src/style.css @@ -11,7 +11,21 @@ body { } #app { display: grid; grid-template-columns: 1fr 340px; height: 100%; } main { position: relative; overflow: hidden; } -canvas { width: 100%; height: 100%; display: block; cursor: crosshair; touch-action: none; } +canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; + cursor: crosshair; touch-action: none; } +#exterior { cursor: grab; } +#exterior:active { cursor: grabbing; } +.views { + position: absolute; top: 12px; left: 12px; display: flex; gap: 0; + border: 1px solid var(--line); border-radius: 6px; overflow: hidden; + background: rgba(10,13,20,0.86); +} +.views button { + width: auto; margin: 0; border: none; border-radius: 0; padding: 6px 14px; + background: transparent; font-size: 12px; +} +.views button.active { background: #1d2942; color: var(--accent); } +#spin { left: auto; right: 12px; bottom: auto; top: 12px; } .overlay { position: absolute; bottom: 12px; left: 12px; diff --git a/src/ui/app.ts b/src/ui/app.ts index 3bf5ffa..7d89b85 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -15,8 +15,14 @@ import { import { KIND_COLOR } from '../views/palette.js' import { WorldStore, backupFilename, download } from '../world/store.js' import { TowerMap } from '../views/map.js' +import { ExteriorView } from '../views/exterior.js' +import { SPIN_PERIOD_S } from '../canon/constants.js' type Tool = 'select' | PlaceKind +type ViewName = 'map' | 'exterior' + +/** Time multipliers for the exterior spin, slowest first. */ +const SPEEDS = [1, 10, 60, 300, 1200] const $ = <T extends HTMLElement>(id: string): T => { const el = document.getElementById(id) @@ -37,9 +43,13 @@ const pathLength = (pts: readonly [number, number][]): number => { return total } const pct = (x: number): string => `${(x * 100).toFixed(1)}%` +const fmtDuration = (s: number): string => + s >= 60 ? `${Math.floor(s / 60)} min ${Math.round(s % 60)} s` : `${s.toFixed(1)} s` export class App { private map: TowerMap + private exterior: ExteriorView | null = null + private view: ViewName = 'map' /** * Writer mode is a UI mode, not a security boundary (D-011). It is guessable * either way, and that is fine: with no backend an editor can only change the @@ -61,6 +71,7 @@ export class App { onDragPlace: (id, p) => this.onDragPlace(id, p), onHover: (p) => this.onHover(p), }) + this.buildViews() this.buildTools() this.buildKindSelect() this.wireEditor() @@ -69,7 +80,10 @@ export class App { this.wireData() this.applyMode() window.addEventListener('hashchange', () => this.readHash()) - window.addEventListener('resize', () => this.map.resize()) + window.addEventListener('resize', () => { + this.map.resize() + this.exterior?.resize() + }) window.addEventListener('keydown', (e) => this.onKey(e)) // A close mid-debounce must not lose the last edit. window.addEventListener('pagehide', () => this.store.flush()) @@ -79,6 +93,72 @@ export class App { this.render() } + // ----------------------------------------------------------------- views + + private buildViews(): void { + for (const b of document.querySelectorAll<HTMLButtonElement>('#views button')) { + b.onclick = () => this.setView(b.dataset['view'] as ViewName) + } + const spin = $<HTMLInputElement>('f-spin') + const speed = $<HTMLInputElement>('f-speed') + const apply = () => { + const scale = SPEEDS[Number(speed.value)]! + // Set the label first: it must update whether or not the view exists yet. + $('speed-value').textContent = + `— ${scale}x, one turn in ${fmtDuration(SPIN_PERIOD_S / scale)}` + if (!this.exterior) return + this.exterior.spinning = spin.checked + this.exterior.timeScale = scale + } + spin.onchange = apply + speed.oninput = apply + apply() + } + + /** + * The exterior renderer is built on first use. It pulls in Three.js and starts an + * animation loop, neither of which a reader who only ever opens the map should pay + * for. + */ + private setView(view: ViewName): void { + this.view = view + const isMap = view === 'map' + $('map').classList.toggle('hidden', isMap ? false : true) + $('exterior').classList.toggle('hidden', isMap) + $('map-options').classList.toggle('hidden', !isMap) + $('exterior-options').classList.toggle('hidden', isMap) + $('spin').classList.toggle('hidden', isMap) + for (const b of document.querySelectorAll<HTMLButtonElement>('#views button')) { + b.classList.toggle('active', b.dataset['view'] === view) + } + if (isMap) { + this.exterior?.stop() + this.map.resize() + return + } + if (!this.exterior) { + this.exterior = new ExteriorView($<HTMLCanvasElement>('exterior'), { + onPickPoint: (p) => { + this.map.probe = { from: p, to: null } + this.selectedId = null + this.render() + }, + onPickPlace: (id) => { + this.selectedId = id + this.render() + }, + }) + const spin = $<HTMLInputElement>('f-spin') + const speed = $<HTMLInputElement>('f-speed') + this.exterior.spinning = spin.checked + this.exterior.timeScale = SPEEDS[Number(speed.value)]! + } + this.exterior.resize() + this.exterior.setPlaces(this.store.places, this.selectedId) + this.exterior.start() + this.render() + } + /** Readers get the map, the place list and the sightline probe — nothing else. */ private applyMode(): void { $('mode').textContent = this.writer ? 'writer' : 'reader' @@ -136,6 +216,7 @@ export class App { ? { theta: g.theta, z: g.z } : { theta: g.points[0]![0], z: g.points[0]![1] } this.map.centreOn(point) + if (this.view === 'exterior') this.exterior?.lookAt(point) } // ------------------------------------------------------------------- toolbar @@ -426,6 +507,10 @@ export class App { this.map.places = this.store.places this.map.selectedId = this.selectedId this.map.dragEnabled = this.tool === 'select' + if (this.view === 'exterior') { + this.exterior?.setPlaces(this.store.places, this.selectedId) + this.renderSpinReadout() + } this.map.draw() const total = this.store.places.length @@ -516,6 +601,17 @@ export class App { return `${g.points.length} vertices` } + /** Where the spin has got to, since nothing in Tower's own sky would tell you. */ + private renderSpinReadout(): void { + const e = this.exterior + if (!e) return + $('spin').textContent = [ + `spin phase ${((e.phase * 180) / Math.PI).toFixed(0).padStart(3)}°`, + `period ${fmtDuration(SPIN_PERIOD_S)}`, + `rim speed 700.2 m/s`, + ].join('\n') + } + private renderSave(): void { const el = $('save') el.classList.toggle('error', this.store.saveState === 'error') diff --git a/src/views/exterior.ts b/src/views/exterior.ts new file mode 100644 index 0000000..ba3e797 --- /dev/null +++ b/src/views/exterior.ts @@ -0,0 +1,275 @@ +/** + * The exterior view: Tower seen from outside. See design.md §1.3, §1.4, §1.6. + * + * Scene units are kilometres, so the numbers in the code are the numbers in canon. + * Tower's axis lies along world Z with the sun cap at z = 0, which makes a canon + * surface point (theta, z) sit at (R cos theta, R sin theta, z) in the hull's own + * frame — the hull then spins about Z and the frame carries the point with it. + */ +import { + AdditiveBlending, AmbientLight, BackSide, BufferGeometry, CircleGeometry, Color, + CylinderGeometry, DirectionalLight, DoubleSide, Float32BufferAttribute, Group, Mesh, + MeshBasicMaterial, MeshStandardMaterial, PerspectiveCamera, Points, PointsMaterial, + Raycaster, RingGeometry, Scene, SphereGeometry, TorusGeometry, Vector2, Vector3, + WebGLRenderer, +} from 'three' +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js' +import { + BEAM_RADIUS_M, COLLECTOR_DIAMETER_M, LENGTH_M, OMEGA_RAD_S, RADIUS_M, +} from '../canon/constants.js' +import { type SurfacePoint, normalizeTheta } from '../canon/geometry.js' +import type { Place } from '../world/schema.js' +import { KIND_COLOR } from './palette.js' + +const KM = 1000 +const R = RADIUS_M / KM +const L = LENGTH_M / KM +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 + +export interface ExteriorCallbacks { + onPickPoint: (p: SurfacePoint) => void + onPickPlace: (id: string) => void +} + +export class ExteriorView { + private renderer: WebGLRenderer + private scene = new Scene() + private camera: PerspectiveCamera + private controls: OrbitControls + /** Everything that co-rotates with the hull, so markers stay put on the surface. */ + private tower = new Group() + private markers = new Group() + private hull: Mesh + private raycaster = new Raycaster() + private running = false + private lastFrame = 0 + private moved = false + + /** Spin phase in radians, shared with the rest of the app. */ + phase = 0 + /** Multiple of real time. Tower turns once per 7.5 min, which is slow to watch. */ + timeScale = 60 + spinning = true + + constructor(private canvas: HTMLCanvasElement, private cb: ExteriorCallbacks) { + this.renderer = new WebGLRenderer({ canvas, antialias: true }) + this.renderer.setClearColor(0x05070d) + this.camera = new PerspectiveCamera(42, 1, 1, 40_000) + // Framed to hold the whole system: the collector is 244 km across and 320 km + // ahead of the sun cap, so Tower alone is not the subject. + this.camera.position.set(260, -1180, 300) + this.controls = new OrbitControls(this.camera, canvas) + this.controls.target.set(0, 0, (L - COLLECTOR_STANDOFF) / 2) + this.controls.enableDamping = true + this.controls.dampingFactor = 0.08 + this.controls.minDistance = 120 + this.controls.maxDistance = 6000 + + this.hull = this.buildHull() + this.tower.add(this.hull, this.markers) + this.scene.add(this.tower) + this.buildCaps() + this.buildSunSystem() + this.buildStars() + this.buildLights() + this.attach() + this.resize() + } + + // ------------------------------------------------------------------ geometry + + private buildHull(): Mesh { + const g = new CylinderGeometry(R, R, L, 160, 1, true) + g.rotateX(Math.PI / 2) + g.translate(0, 0, L / 2) + const hull = new Mesh(g, new MeshStandardMaterial({ + color: 0x8892a3, roughness: 0.62, metalness: 0.28, side: DoubleSide, + })) + // Structural ribs, purely to give the eye a sense of the 540 km. + const ribMat = new MeshStandardMaterial({ color: 0xaab3c2, roughness: 0.45, metalness: 0.35 }) + for (let z = 20; z < L; z += 20) { + const rib = new Mesh(new TorusGeometry(R * 1.004, 0.55, 6, 96), ribMat) + rib.position.z = z + hull.add(rib) + } + return hull + } + + private buildCaps(): void { + // The sun cap is an annulus: the beam enters through the hole in the middle. + const sun = new Mesh( + new RingGeometry(BEAM_R, R, 96), + new MeshStandardMaterial({ color: 0xb3bccb, roughness: 0.5, metalness: 0.3, side: DoubleSide })) + this.tower.add(sun) + + const aperture = new Mesh( + new CircleGeometry(BEAM_R, 48), + new MeshBasicMaterial({ color: 0xfff0cf })) + aperture.position.z = 0.4 + this.tower.add(aperture) + + const far = new Mesh( + new CircleGeometry(R, 96), + new MeshStandardMaterial({ color: 0x6c7583, roughness: 0.72, metalness: 0.25, side: DoubleSide })) + far.position.z = L + this.tower.add(far) + } + + private buildSunSystem(): void { + // The collector flies ahead of the sun cap: 244 km across, wider than Tower. + const collector = new Mesh( + new CircleGeometry(COLLECTOR_R, 128), + new MeshStandardMaterial({ + color: 0xe6edf8, roughness: 0.2, metalness: 0.5, side: DoubleSide, + emissive: new Color(0x93a2be), emissiveIntensity: 1.15, + })) + collector.position.z = -COLLECTOR_STANDOFF + this.scene.add(collector) + + /** + * The converging beam. In vacuum this would not actually be visible — there is + * nothing out here to scatter it — so it is drawn faintly as a reading aid, + * the same licence as the hillshade on the map. + */ + const cone = new CylinderGeometry(BEAM_R, COLLECTOR_R, COLLECTOR_STANDOFF, 96, 1, true) + cone.rotateX(-Math.PI / 2) + cone.translate(0, 0, -COLLECTOR_STANDOFF / 2) + this.scene.add(new Mesh(cone, new MeshBasicMaterial({ + color: 0xffe6b0, transparent: true, opacity: 0.085, + blending: AdditiveBlending, depthWrite: false, side: DoubleSide, + }))) + } + + private buildStars(): void { + const n = 3500 + const pos = new Float32Array(n * 3) + for (let i = 0; i < n; i++) { + const v = new Vector3().randomDirection().multiplyScalar(18_000) + pos.set([v.x, v.y, v.z], i * 3) + } + const g = new BufferGeometry() + g.setAttribute('position', new Float32BufferAttribute(pos, 3)) + this.scene.add(new Points(g, new PointsMaterial({ color: 0xdfe8ff, size: 26, sizeAttenuation: true }))) + + const glow = new Mesh( + new SphereGeometry(300, 24, 24), + new MeshBasicMaterial({ color: 0xffe9bd, transparent: true, opacity: 0.5, side: BackSide })) + glow.position.z = -14_000 + this.scene.add(glow) + } + + private buildLights(): void { + // The real light runs along the axis, from beyond the collector. + const sun = new DirectionalLight(0xfff3dd, 2.2) + sun.position.set(0, 0, -8000) + this.scene.add(sun) + + /** + * Fill light, and a reading aid rather than physics. Sunlight here is parallel + * to the axis, so Tower's flank is edge-on to it and correctly renders almost + * black — accurate and useless. These two lift the hull enough to read its + * shape, the same licence as the hillshade on the map. + */ + const key = new DirectionalLight(0xc8d6f0, 1.5) + key.position.set(-1, -1.4, 0.35) + this.scene.add(key) + const rim = new DirectionalLight(0x8fa8d8, 0.7) + rim.position.set(1.2, 0.9, -0.4) + this.scene.add(rim) + this.scene.add(new AmbientLight(0x3a4356, 1.6)) + } + + // ------------------------------------------------------------------- markers + + /** Put a dot on the hull for every place, at its canon (theta, z). */ + setPlaces(places: readonly Place[], selectedId: string | null): void { + this.markers.clear() + for (const p of places) { + const g = p.geometry + const at: SurfacePoint = g.type === 'point' + ? { theta: g.theta, z: g.z } + : { theta: g.points[0]![0], z: g.points[0]![1] } + const selected = p.id === selectedId + const dot = new Mesh( + new SphereGeometry(selected ? 5 : 3, 12, 12), + new MeshBasicMaterial({ color: new Color(KIND_COLOR[p.kind] ?? '#ccc') })) + dot.position.set( + R * 1.01 * Math.cos(at.theta), R * 1.01 * Math.sin(at.theta), at.z / KM) + dot.userData['placeId'] = p.id + this.markers.add(dot) + } + } + + // --------------------------------------------------------------- interaction + + private attach(): void { + this.canvas.addEventListener('pointerdown', () => { this.moved = false }) + this.canvas.addEventListener('pointermove', () => { this.moved = true }) + this.canvas.addEventListener('pointerup', (e) => { + if (this.moved) return + const r = this.canvas.getBoundingClientRect() + const ndc = new Vector2( + ((e.clientX - r.left) / r.width) * 2 - 1, + -((e.clientY - r.top) / r.height) * 2 + 1) + this.raycaster.setFromCamera(ndc, this.camera) + + const onMarker = this.raycaster.intersectObjects(this.markers.children, false)[0] + if (onMarker) return this.cb.onPickPlace(onMarker.object.userData['placeId'] as string) + + const onHull = this.raycaster.intersectObject(this.hull, false)[0] + if (!onHull) return + // Into the hull's own frame, so the spin is undone and theta is canon. + const local = this.tower.worldToLocal(onHull.point.clone()) + this.cb.onPickPoint({ + theta: normalizeTheta(Math.atan2(local.y, local.x)), + z: Math.max(0, Math.min(LENGTH_M, local.z * KM)), + }) + }) + } + + resize(): void { + const { width, height } = this.canvas.getBoundingClientRect() + if (!width || !height) return + this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) + this.renderer.setSize(width, height, false) + this.camera.aspect = width / height + this.camera.updateProjectionMatrix() + } + + /** Frame a surface point: swing the camera round to look at it from outside. */ + lookAt(p: SurfacePoint): void { + const worldTheta = p.theta + this.phase + const z = p.z / KM + this.controls.target.set(R * 0.4 * Math.cos(worldTheta), R * 0.4 * Math.sin(worldTheta), z) + const d = 260 + this.camera.position.set( + (R + d) * Math.cos(worldTheta), (R + d) * Math.sin(worldTheta), z + 90) + this.controls.update() + } + + // -------------------------------------------------------------------- loop + + start(): void { + if (this.running) return + this.running = true + this.lastFrame = performance.now() + const tick = (now: number) => { + if (!this.running) return + const dt = Math.min(0.1, (now - this.lastFrame) / 1000) + this.lastFrame = now + if (this.spinning) this.phase = (this.phase + OMEGA_RAD_S * dt * this.timeScale) % (2 * Math.PI) + this.tower.rotation.z = this.phase + this.controls.update() + this.renderer.render(this.scene, this.camera) + requestAnimationFrame(tick) + } + requestAnimationFrame(tick) + } + + stop(): void { + this.running = false + } +}