tower / commit
fix(exterior): the beam pointed the wrong way; label and persist the view
README.md | 4 ++- decisions.md | 49 ++++++++++++++++++++++++++ design.md | 8 +++-- index.html | 1 + src/style.css | 8 +++++ src/ui/app.ts | 27 ++++++++++++++- src/views/exterior.ts | 96 +++++++++++++++++++++++++++++++++++++++++++++------ 7 files changed, 178 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 959b256..30e87c6 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,9 @@ Any selection updates the URL, so a link is always shareable: **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, with the terrain wrapped onto the hull as an x-ray of what is inside. Drag to orbit, scroll to zoom, click the hull to drop a point; the spin runs at +cap, with the terrain wrapped onto the hull as an x-ray of what is inside. Each +piece is captioned. The active view is remembered and travels in the permalink, so +`#view=exterior&place=the-narrows` opens where you left off. 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. diff --git a/decisions.md b/decisions.md index e0126d5..57d3429 100644 --- a/decisions.md +++ b/decisions.md @@ -843,3 +843,52 @@ the editor reports it as *rings the world, z 148.0 km to 218.0 km (70.0 km deep) Tests assert the four ringing regions survive as bands through parse and public export, and that a band with unusable bounds is rejected like any other geometry. + +--- + +## D-028 — The beam was inverted, and cones fringe + +**Found because the author asked what the brown cone and the white circle were.** +That question is its own answer: the two most important objects in the exterior view +were unlabelled and one of them was drawn backwards. + +**The beam pointed the wrong way.** `CylinderGeometry(radiusTop, radiusBottom)` +combined with `rotateX(-pi/2)` — which sends +Y to −Z — put the *first* radius at +the collector. Writing the pair in the order the eye reads them gave a cone wide at +the sun cap and narrow at the mirror: light converging **on** the collector instead +of into the aperture, with a 122 km mouth swallowing a 50 km hull. It had been that +way since the view was built, and the flat brown wash it rendered as hid it. + +**The brown was a second bug.** A uniform additive layer at 0.085 opacity over a +black sky sums to about RGB(43,39,30) — a dull brown that reads as a solid object, +which is the opposite of a beam. + +**Cones fringe, so the beam is rays now.** Brightening it exposed bright dashes +scattered across the collector's rim. Isolated by bisection: removing the cone +cleared the disc, removing the stars did not, and shortening the cone moved the +dashes with its mouth. At the mouth the cone's shell is edge-on, so its triangles +project to slivers and the additive blend stacks them. Rays from the mirror to the +aperture have no silhouette to alias, need no alpha ramp to fake convergence, and +read more like light than a translucent solid ever did. + +**Everything is captioned now.** Collector, beam, sun cap, far cap, as screen-space +labels. If the person who commissioned the world cannot identify an object in it, a +reader has no chance. + +--- + +## D-029 — The view persists, which surfaced a boot-order bug + +**Implemented at the author's request.** Map or exterior is remembered in +`localStorage` and carried in the permalink hash, so a shared link can say *look at +this from outside*. The hash wins; storage is the fallback for a bare URL. + +**And it immediately broke the exterior on load.** Restoring that view un-hides the +canvas and constructs the renderer in the same task, before the browser has laid the +element out — so `resize()` measured a zero-sized box, bailed out, and the renderer +kept its default 300×150 buffer stretched across a full-size canvas. Clicking into +the view had always worked because layout had long since settled by then; only +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. diff --git a/design.md b/design.md index 1d26203..f969f2b 100644 --- a/design.md +++ b/design.md @@ -559,8 +559,12 @@ map's hillshade: 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. + scatter light, so the converging beam would not be visible at all. It is drawn + as rays because it is the thing the view is about. + +Everything in the scene is captioned — collector, beam, both caps — with screen-space +labels. A pale disc and a cone of light are not self-explanatory, and the collector +is the least intuitive object in Tower: it is larger than the world it feeds. 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. diff --git a/index.html b/index.html index eeffe22..d974971 100644 --- a/index.html +++ b/index.html @@ -15,6 +15,7 @@ <button data-view="map" class="active">Map</button> <button data-view="exterior">Exterior</button> </div> + <div id="ext-labels" class="labels3d hidden"></div> <div id="spin" class="overlay hidden"></div> <div id="cursor" class="overlay hidden"></div> <div id="sightline" class="overlay hidden"></div> diff --git a/src/style.css b/src/style.css index 5de6b4e..cac81d3 100644 --- a/src/style.css +++ b/src/style.css @@ -26,6 +26,14 @@ canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block } .views button.active { background: #1d2942; color: var(--accent); } #spin { left: auto; right: 12px; bottom: auto; top: 12px; } +.labels3d { position: absolute; inset: 0; pointer-events: none; overflow: hidden; } +.labels3d span { + position: absolute; transform: translate(-50%, -50%); white-space: nowrap; + font: 11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; + color: rgba(226,236,250,0.9); text-shadow: 0 0 4px #05070d, 0 0 8px #05070d; + letter-spacing: 0.04em; +} +.labels3d span small { display: block; color: var(--dim); letter-spacing: 0; } .overlay { position: absolute; bottom: 12px; left: 12px; diff --git a/src/ui/app.ts b/src/ui/app.ts index 9a1bd39..f9bb10e 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -20,6 +20,8 @@ import { SPIN_PERIOD_S } from '../canon/constants.js' type Tool = 'select' | PlaceKind type ViewName = 'map' | 'exterior' +const VIEW_KEY = 'tower.view' +const isView = (v: string | null): v is ViewName => v === 'map' || v === 'exterior' /** Time multipliers for the exterior spin, slowest first. */ const SPEEDS = [1, 10, 60, 300, 1200] @@ -91,10 +93,26 @@ export class App { this.map.fit() this.readHash() this.render() + void this.setView(this.initialView()) } // ----------------------------------------------------------------- views + /** + * Which view to open on. The hash wins so a shared link can carry the view along + * with the place; otherwise the last one used, so a reload does not throw the + * author back to the map every time. + */ + private initialView(): ViewName { + const fromHash = new URLSearchParams(location.hash.slice(1)).get('view') + if (isView(fromHash)) return fromHash + try { + const stored = localStorage.getItem(VIEW_KEY) + if (isView(stored)) return stored + } catch { /* unreadable storage is not an error here */ } + return 'map' + } + private buildViews(): void { for (const b of document.querySelectorAll<HTMLButtonElement>('#views button')) { b.onclick = () => { void this.setView(b.dataset['view'] as ViewName) } @@ -122,12 +140,18 @@ export class App { */ private async setView(view: ViewName): Promise<void> { this.view = view + try { + localStorage.setItem(VIEW_KEY, view) + } catch { + // Private window or blocked site data: the view just will not persist. + } 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) + $('ext-labels').classList.toggle('hidden', isMap) for (const b of document.querySelectorAll<HTMLButtonElement>('#views button')) { b.classList.toggle('active', b.dataset['view'] === view) } @@ -151,7 +175,7 @@ export class App { this.selectedId = id this.render() }, - }) + }, $('ext-labels')) const spin = $<HTMLInputElement>('f-spin') const speed = $<HTMLInputElement>('f-speed') this.exterior.spinning = spin.checked @@ -184,6 +208,7 @@ export class App { */ private writeHash(): void { const params = new URLSearchParams() + if (this.view !== 'map') params.set('view', this.view) if (this.selectedId) params.set('place', this.selectedId) else if (this.map.probe?.from) { const p = this.map.probe.from diff --git a/src/views/exterior.ts b/src/views/exterior.ts index e9a7c2e..c67ac78 100644 --- a/src/views/exterior.ts +++ b/src/views/exterior.ts @@ -10,7 +10,8 @@ import type { Texture } from 'three' import { AdditiveBlending, AmbientLight, BackSide, BufferGeometry, CircleGeometry, Color, CylinderGeometry, DirectionalLight, DoubleSide, Float32BufferAttribute, Group, Mesh, - MeshBasicMaterial, MeshStandardMaterial, PerspectiveCamera, Points, PointsMaterial, + LineBasicMaterial, LineSegments, MeshBasicMaterial, MeshStandardMaterial, + PerspectiveCamera, Points, PointsMaterial, Raycaster, RepeatWrapping, RingGeometry, SRGBColorSpace, Scene, SphereGeometry, TextureLoader, Vector2, Vector3, WebGLRenderer, } from 'three' @@ -58,7 +59,14 @@ export class ExteriorView { timeScale = 60 spinning = true - constructor(private canvas: HTMLCanvasElement, private cb: ExteriorCallbacks) { + /** Screen-space captions, so nothing in the scene is left unexplained. */ + private labels: { at: Vector3; el: HTMLSpanElement }[] = [] + + constructor( + private canvas: HTMLCanvasElement, + private cb: ExteriorCallbacks, + private labelHost?: HTMLElement, + ) { this.renderer = new WebGLRenderer({ canvas, antialias: true }) this.renderer.setClearColor(0x05070d) this.camera = new PerspectiveCamera(42, 1, 8, 30_000) @@ -80,8 +88,18 @@ export class ExteriorView { this.buildSunSystem() this.buildStars() this.buildLights() + this.buildLabels() this.attach() this.resize() + /** + * Watch the canvas box rather than only the window. Restoring this view on load + * un-hides the canvas and constructs the renderer in the same task, before the + * browser has laid the element out — so `resize()` sees a zero-sized box, bails, + * and the renderer keeps its default 300x150 buffer stretched across the canvas. + * Clicking the view later worked because layout had long since settled, which is + * why this only appeared once the view began persisting across reloads. + */ + new ResizeObserver(() => this.resize()).observe(canvas) } // ------------------------------------------------------------------ geometry @@ -204,16 +222,38 @@ export class ExteriorView { 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. + * The converging beam, drawn as rays from the mirror's rim to the aperture. + * + * A translucent cone is the obvious construction and it fringes: at the mouth + * the shell is edge-on, so its triangles project to slivers and the additive + * blend stacks them into bright dashes across the collector. Isolated by + * bisection — removing the cone cleared the disc, removing the stars did not, + * and shortening the cone moved the dashes with its mouth. Rays have no + * silhouette to alias, and read more like light besides. + * + * In vacuum none of this would be visible; there is nothing out here to + * scatter it. 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, + const RAYS = 220 + const verts = new Float32Array(RAYS * 6) + const cols = new Float32Array(RAYS * 8) + for (let i = 0; i < RAYS; i++) { + const a = (i / RAYS) * Math.PI * 2 + // Spread the starts across the mirror rather than only round its rim, so the + // beam has a body instead of being a hollow funnel. + const r = COLLECTOR_R * Math.sqrt(0.15 + 0.85 * ((i * 0.6180339887) % 1)) + verts.set([ + r * Math.cos(a), r * Math.sin(a), -COLLECTOR_STANDOFF, + BEAM_R * Math.cos(a), BEAM_R * Math.sin(a), 0, + ], i * 6) + cols.set([1, 0.95, 0.8, 0.0, 1, 0.97, 0.86, 0.38], i * 8) + } + const beam = new BufferGeometry() + beam.setAttribute('position', new Float32BufferAttribute(verts, 3)) + beam.setAttribute('color', new Float32BufferAttribute(cols, 4)) + this.scene.add(new LineSegments(beam, new LineBasicMaterial({ + vertexColors: true, transparent: true, + blending: AdditiveBlending, depthWrite: false, }))) } @@ -235,6 +275,39 @@ export class ExteriorView { this.scene.add(glow) } + /** + * A pale disc and a glowing cone are not self-explanatory, and the collector is + * the least intuitive object in Tower: it is bigger than the world it feeds. + */ + private buildLabels(): void { + if (!this.labelHost) return + const add = (at: Vector3, title: string, sub: string) => { + const el = document.createElement('span') + el.innerHTML = `${title}<small>${sub}</small>` + this.labelHost!.append(el) + this.labels.push({ at, el }) + } + add(new Vector3(0, COLLECTOR_R * 0.82, -COLLECTOR_STANDOFF), 'COLLECTOR', '244 km across') + add(new Vector3(0, 96, -COLLECTOR_STANDOFF * 0.5), 'BEAM', '54.3 TW into a 3.2 km hole') + add(new Vector3(0, R + 26, 0), 'SUN CAP', 'faces the sun') + add(new Vector3(0, R + 26, L), 'FAR CAP', '540 km away') + } + + private updateLabels(): void { + if (!this.labels.length) return + const { width, height } = this.canvas.getBoundingClientRect() + const v = new Vector3() + 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 + 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` + } + } + private buildLights(): void { // The real light runs along the axis, from beyond the collector. const sun = new DirectionalLight(0xfff3dd, 2.2) @@ -356,6 +429,7 @@ export class ExteriorView { for (const m of this.markers.children) m.scale.setScalar(scale) this.controls.update() this.renderer.render(this.scene, this.camera) + this.updateLabels() requestAnimationFrame(tick) } requestAnimationFrame(tick)