tower / commit
feat(exterior): wrap the terrain onto the hull
README.md | 2 +- decisions.md | 39 +++++++++++++++- design.md | 12 +++++ index.html | 6 ++- src/ui/app.ts | 3 ++ src/views/exterior.ts | 121 +++++++++++++++++++++++++++++++++++++++++++++----- 6 files changed, 169 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 3c46370..959b256 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ 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. 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. 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 48c61ac..47ba90d 100644 --- a/decisions.md +++ b/decisions.md @@ -719,7 +719,7 @@ spin phases 34° apart and confirming the reported θ moved by 0.588 rad against 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 +**Three departures from physics, all 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 @@ -735,3 +735,40 @@ 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. + +--- + +## D-024 — The terrain is wrapped onto the hull as an x-ray + +**Implemented at the author's request.** The exterior view paints the rendered inner +surface onto the outside of the cylinder, alongside the place markers. + +**It is a projection, and the code says so.** From outside, Tower is structure; the +texture shows what is *inside*. That makes it the frankest of the view's three +reading aids — nobody could mistake a forest on the outer hull for a physical claim, +where the fill lighting and the visible beam are subtler about it. It earns its +place because the hull is opaque: this is the only view that shows the whole world +at once. + +**The UVs are rewritten, not derived.** Three's cylinder runs u around the +circumference and v along the axis; the raster is the other way round, x = z and +y = theta. The obvious approach — recovering the angle from each vertex position — +is wrong, because the seam vertices are duplicated and `atan2` gives both the same +angle, smearing one column of triangles across the entire image. Remapping the +original uv attribute stays linear and lets RepeatWrapping absorb the offset. + +**Alignment was verified, not eyeballed.** The raster was sampled at each place's +computed position and the colour classified: four of four mountains landed on rock, +both cap seas on water, the desert on desert. Judging this by eye would have been +hopeless — the whole map is bands of green and tan, and a half-world error looks +much like a correct one from most angles. + +**Two fixes the terrain forced.** Small bright dots read fine against a bare hull +and vanish into forest and desert, so markers gained a dark outline shell; and they +are now scaled per frame to a roughly constant screen size, because 4 km is +sub-pixel at the 1,200 km the default view sits at. The terrain also needed an +emissive map: sunlight runs along the axis, so most of the hull faces away from +every light and the map would otherwise be legible only on a sliver. + +**Also fixed here:** selecting a place from the list moved the panel but not the +camera — only permalinks called `focusOn`. Both paths now do. diff --git a/design.md b/design.md index 0b09105..1d26203 100644 --- a/design.md +++ b/design.md @@ -565,6 +565,18 @@ map's hillshade: 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. +**The terrain raster is wrapped onto the hull** — a third reading aid, and the +frankest of the three. From outside Tower is structure; what the texture shows is +what is *inside*. It is an x-ray, and it is useful precisely because the hull is +opaque. Three's cylinder runs u around and v along, while the raster is the other +way round, so the UVs are rewritten from the original attribute rather than derived +from vertex positions — the seam vertices are duplicated, and deriving an angle from +them smears one column of triangles across the whole image. + +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. + ### 2.4 The interior view Per the chosen scope: **real geometry, simple ground.** diff --git a/index.html b/index.html index 30b4deb..0e270bc 100644 --- a/index.html +++ b/index.html @@ -76,10 +76,12 @@ <section id="view-section"> <h2>View</h2> + <label class="row"><input id="f-terrain" type="checkbox" checked /> Show terrain</label> <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> + <code>content/geography.json</code>. On the exterior it is wrapped onto the + hull — an x-ray of the inner surface, not what Tower looks like from + outside.</p> </div> <div id="exterior-options" class="hidden"> <label class="row"><input id="f-spin" type="checkbox" checked /> Spin</label> diff --git a/src/ui/app.ts b/src/ui/app.ts index 25bcac5..941bdb3 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -156,6 +156,7 @@ export class App { const speed = $<HTMLInputElement>('f-speed') this.exterior.spinning = spin.checked this.exterior.timeScale = SPEEDS[Number(speed.value)]! + this.exterior.showTerrain = $<HTMLInputElement>('f-terrain').checked } this.exterior.resize() this.exterior.setPlaces(this.store.places, this.selectedId) @@ -381,6 +382,7 @@ export class App { box.onchange = () => { this.map.showTerrain = box.checked this.map.draw() + if (this.exterior) this.exterior.showTerrain = box.checked } } @@ -536,6 +538,7 @@ export class App { li.className = (p.id === this.selectedId ? 'sel ' : '') + (p.public ? '' : 'private') li.onclick = () => { this.selectedId = p.id + this.focusOn(p.id) this.render() } const sw = document.createElement('span') diff --git a/src/views/exterior.ts b/src/views/exterior.ts index ba3e797..6b63a1c 100644 --- a/src/views/exterior.ts +++ b/src/views/exterior.ts @@ -6,18 +6,19 @@ * 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 type { Texture } from 'three' 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, + Raycaster, RepeatWrapping, RingGeometry, SRGBColorSpace, Scene, SphereGeometry, + TextureLoader, 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 SurfacePoint, mapThetaOrigin, normalizeTheta } from '../canon/geometry.js' import type { Place } from '../world/schema.js' import { KIND_COLOR } from './palette.js' @@ -43,6 +44,10 @@ export class ExteriorView { private tower = new Group() private markers = new Group() private hull: Mesh + private hullMat: MeshStandardMaterial + private ribs: Mesh[] = [] + private terrainMap: Texture | null = null + private _showTerrain = true private raycaster = new Raycaster() private running = false private lastFrame = 0 @@ -57,7 +62,7 @@ export class ExteriorView { 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) + this.camera = new PerspectiveCamera(42, 1, 8, 30_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) @@ -68,6 +73,7 @@ export class ExteriorView { this.controls.minDistance = 120 this.controls.maxDistance = 6000 + this.hullMat = new MeshStandardMaterial() this.hull = this.buildHull() this.tower.add(this.hull, this.markers) this.scene.add(this.tower) @@ -85,19 +91,99 @@ export class ExteriorView { 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({ + this.remapHullUVs(g) + + this.hullMat = 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 hull = new Mesh(g, this.hullMat) + + // Structural ribs, to give the eye a sense of the 540 km. They read as + // gridlines once the terrain is on, which is useful rather than not. 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) + const rib = new Mesh(new TorusGeometry(R * 1.004, 0.4, 6, 96), ribMat) rib.position.z = z hull.add(rib) + this.ribs.push(rib) } + this.loadTerrain() return hull } + /** + * Rewrite the cylinder's UVs so the terrain raster lands where the map says it + * should. Three's cylinder runs u around the circumference and v along the axis; + * the raster is the other way round, with x = z and y = theta. + * + * Deriving the angle from vertex positions would be simpler and wrong: the seam + * vertices are duplicated, so atan2 gives both the same angle and smears one + * column of triangles across the whole image. Working from the original u keeps + * it linear, and RepeatWrapping absorbs the offset. + * + * Three builds the ring as x = R sin t, z = R cos t, so after rotateX the angle + * in the XY plane is t - pi/2. Canon theta is measured from +X, and the map is + * cut at mapThetaOrigin — hence the constant. + */ + private remapHullUVs(g: CylinderGeometry): void { + const uv = g.attributes['uv']! + const shift = 0.25 + mapThetaOrigin() / (2 * Math.PI) + for (let i = 0; i < uv.count; i++) { + const around = uv.getX(i) + const along = uv.getY(i) + uv.setXY(i, 1 - along, around - shift) + } + uv.needsUpdate = true + } + + /** + * Paint the inner surface onto the outer hull. This is a projection, not a view: + * from outside, Tower is structure, and what the texture shows is what is + * *inside*. It is an x-ray, and useful precisely because the hull is opaque. + */ + private loadTerrain(): void { + new TextureLoader().load( + `${import.meta.env.BASE_URL}terrain-color.png`, + (tex) => { + tex.colorSpace = SRGBColorSpace + tex.wrapS = RepeatWrapping + tex.wrapT = RepeatWrapping + // uv.y is computed, not sampled from the file's row order, so no flip. + tex.flipY = false + tex.anisotropy = this.renderer.capabilities.getMaxAnisotropy() + this.terrainMap = tex + this.applyTerrain() + }, + undefined, + () => { /* no raster yet: the plain hull is a fine fallback */ }, + ) + } + + private applyTerrain(): void { + const on = this._showTerrain && this.terrainMap + this.hullMat.map = on ? this.terrainMap : null + /** + * Self-illuminate the terrain. Sunlight runs along the axis, so most of the + * hull faces away from every light in the scene and the map would be legible + * only on the sliver facing the fill. Emissive keeps it readable all the way + * round while the directional lights still give the cylinder its form. + */ + this.hullMat.emissiveMap = on ? this.terrainMap : null + this.hullMat.emissive.set(on ? 0xffffff : 0x000000) + this.hullMat.emissiveIntensity = on ? 0.62 : 0 + // Bare metal wants a tint; a textured surface wants to be left alone. + this.hullMat.color.set(on ? 0xffffff : 0x8892a3) + this.hullMat.metalness = on ? 0.06 : 0.28 + this.hullMat.roughness = on ? 0.9 : 0.62 + this.hullMat.needsUpdate = true + for (const rib of this.ribs) (rib.material as MeshStandardMaterial).opacity = on ? 0.5 : 1 + } + + set showTerrain(v: boolean) { + this._showTerrain = v + this.applyTerrain() + } + private buildCaps(): void { // The sun cap is an annulus: the beam enters through the hole in the middle. const sun = new Mesh( @@ -193,12 +279,20 @@ export class ExteriorView { ? { theta: g.theta, z: g.z } : { theta: g.points[0]![0], z: g.points[0]![1] } const selected = p.id === selectedId + const r = selected ? 6.5 : 4.2 const dot = new Mesh( - new SphereGeometry(selected ? 5 : 3, 12, 12), + new SphereGeometry(r, 14, 14), new MeshBasicMaterial({ color: new Color(KIND_COLOR[p.kind] ?? '#ccc') })) + // A dark shell behind each dot. Small bright spheres read fine against a bare + // hull and vanish into the terrain once it is painted on; the outline is what + // keeps them legible over green forest and tan desert alike. + dot.add(new Mesh( + new SphereGeometry(r * 1.45, 14, 14), + new MeshBasicMaterial({ color: 0x05070d, side: BackSide }))) dot.position.set( - R * 1.01 * Math.cos(at.theta), R * 1.01 * Math.sin(at.theta), at.z / KM) + (R + 1.6) * Math.cos(at.theta), (R + 1.6) * Math.sin(at.theta), at.z / KM) dot.userData['placeId'] = p.id + dot.renderOrder = 2 this.markers.add(dot) } } @@ -262,6 +356,13 @@ export class ExteriorView { 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 + /** + * Keep markers a roughly constant size on screen. A 4 km dot is legible from + * 200 km away and sub-pixel from 1,200 km, which is where the default view + * sits — so at world scale the places would simply not be there. + */ + const scale = Math.max(0.8, Math.min(2.5, this.camera.position.distanceTo(this.controls.target) / 700)) + for (const m of this.markers.children) m.scale.setScalar(scale) this.controls.update() this.renderer.render(this.scene, this.camera) requestAnimationFrame(tick)