tower / commit
feat(views): give the end caps a pattern
src/views/cap-pattern.ts | 109 +++++++++++++++++++++++++++++++++++++++++++++++ src/views/exterior.ts | 30 ++++++++++--- 2 files changed, 132 insertions(+), 7 deletions(-) diff --git a/src/views/cap-pattern.ts b/src/views/cap-pattern.ts new file mode 100644 index 0000000..0e64a09 --- /dev/null +++ b/src/views/cap-pattern.ts @@ -0,0 +1,109 @@ +/** + * The pattern on the end caps, drawn once and shared by both 3D views. + * + * The caps are the only surfaces in Tower that are not landscape: 100 km discs of + * structure closing each end. Blank, they read as fog with an edge. Checkered, they + * read as built — and they become the one thing in the world with a known scale, so + * a glance at the far cap tells you how far away it is. + * + * The checker is **polar**, not square. A cap is a disc on a spinning cylinder, so + * its natural coordinates are radius and angle; a square grid would meet the rim at + * every possible angle and look laid over the geometry rather than part of it. + * + * Cells are kept roughly square by doubling the number of sectors each time the + * radius doubles. That leaves a seam at each doubling, which is what a real + * segmented diaphragm would have anyway — the alternative is either slivers at the + * hub or slabs at the rim. + */ +import { CanvasTexture, LinearFilter, SRGBColorSpace, type Texture } from 'three' + +const RADIUS_KM = 50 +/** + * Six rings, so the cap reads as a board rather than a mesh. Finer cells looked + * better face-on from inside and collapsed into a dark cross-hatch from outside, + * where the caps are always seen near edge-on (D-030) and foreshortened ten to one. + * A pattern has to survive its worst viewing angle, not its best. + */ +const RINGS = 6 +const CELL_KM = RADIUS_KM / RINGS +/** Sector count at the rim; halved inward at every halving of the radius. */ +const RIM_SECTORS = 32 + +const LIGHT = '#c3cad6' +const DARK = '#8d96a5' +const RIB = '#dae1eb' + +/** Sectors at a given radius, chosen to keep cells about CELL_KM wide. */ +const sectorsAt = (rKm: number): number => { + const ideal = (2 * Math.PI * rKm) / CELL_KM + // Snap to a power-of-two fraction of the rim count, so rings nest exactly. + let n = RIM_SECTORS + while (n > 4 && n / 2 >= ideal) n /= 2 + return n +} + +/** + * Draw the pattern into a square canvas covering the cap's bounding box, which is + * exactly how Three maps UVs onto both CircleGeometry and RingGeometry — so the + * same image lands correctly on the full far cap and on the sun cap's annulus. + */ +export function drawCapPattern(size = 2048): HTMLCanvasElement { + const c = document.createElement('canvas') + c.width = size + c.height = size + const ctx = c.getContext('2d')! + const mid = size / 2 + const px = mid / RADIUS_KM + + ctx.fillStyle = DARK + ctx.fillRect(0, 0, size, size) + + for (let i = 0; i < RINGS; i++) { + const r0 = i * CELL_KM + const r1 = r0 + CELL_KM + const n = sectorsAt(r1) + for (let s = 0; s < n; s++) { + if ((i + s) % 2 === 0) continue + const a0 = (s / n) * Math.PI * 2 + const a1 = ((s + 1) / n) * Math.PI * 2 + ctx.beginPath() + ctx.arc(mid, mid, r1 * px, a0, a1) + ctx.arc(mid, mid, r0 * px, a1, a0, true) + ctx.closePath() + ctx.fillStyle = LIGHT + ctx.fill() + } + } + + // A brighter ring every 25 km, and one at the rim. Structure, and a coarser + // scale for when the checker itself is too fine to resolve through the haze. + ctx.strokeStyle = RIB + ctx.lineWidth = Math.max(1, size / 900) + for (const r of [25, 50]) { + ctx.beginPath() + ctx.arc(mid, mid, r * px - ctx.lineWidth / 2, 0, Math.PI * 2) + ctx.stroke() + } + return c +} + +/** The pattern as a texture. Built once; both views share the one instance. */ +let cached: Texture | null = null +export function capPatternTexture(): Texture { + if (cached) return cached + const tex = new CanvasTexture(drawCapPattern()) + // Declared for the exterior's standard material, which decodes it; the interior's + // custom shader does its own decode and ignores this. + tex.colorSpace = SRGBColorSpace + /** + * Mipmapped, and it has to be. A high-contrast geometric pattern is the worst + * case for point sampling: minified without mipmaps the far cap moirés into a + * dark cross-hatch that reads as damage rather than structure. Averaging to grey + * once the cells fall below a pixel is the correct answer and the honest one — + * you cannot resolve 5 km squares from 540 km either. + */ + tex.anisotropy = 16 + tex.magFilter = LinearFilter + cached = tex + return cached +} diff --git a/src/views/exterior.ts b/src/views/exterior.ts index 9497aa6..67acd12 100644 --- a/src/views/exterior.ts +++ b/src/views/exterior.ts @@ -21,6 +21,7 @@ import { } from '../canon/constants.js' import { type SurfacePoint, mapThetaOrigin, normalizeTheta } from '../canon/geometry.js' import type { Place } from '../world/schema.js' +import { capPatternTexture } from './cap-pattern.js' import { KIND_COLOR } from './palette.js' const KM = 1000 @@ -199,10 +200,19 @@ export class ExteriorView { } private buildCaps(): void { + // The same pattern the interior view paints on their far side (D-041). A cap is + // structure, not landscape, and the checker is what says so from either side. + const pattern = capPatternTexture() // 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 })) + new MeshStandardMaterial({ + color: 0xdfe5ee, map: pattern, roughness: 0.5, metalness: 0.3, side: DoubleSide, + // Same licence as the hull's terrain: sunlight runs along the axis, so a cap + // is edge-on to every real light and renders as a dark disc exactly when it + // is being looked at. Enough emissive to keep the pattern legible. + emissiveMap: pattern, emissive: new Color(0xffffff), emissiveIntensity: 0.42, + })) this.tower.add(sun) const aperture = new Mesh( @@ -213,7 +223,12 @@ export class ExteriorView { const far = new Mesh( new CircleGeometry(R, 96), - new MeshStandardMaterial({ color: 0x6c7583, roughness: 0.72, metalness: 0.25, side: DoubleSide })) + new MeshStandardMaterial({ + // Lighter than the bare cap used to be: the pattern supplies the tone now, + // and this face is turned away from every light in the scene. + color: 0xd8dee8, map: pattern, roughness: 0.72, metalness: 0.25, side: DoubleSide, + emissiveMap: pattern, emissive: new Color(0xffffff), emissiveIntensity: 0.5, + })) far.position.z = L this.tower.add(far) } @@ -484,16 +499,17 @@ export class ExteriorView { /** * 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. + * from-the-side framing shows only an edge. This sits about 40° off the axis: + * far enough round to see the cap's face — which now carries a pattern worth + * seeing (D-041) — without looking straight down the barrel and foreshortening + * the whole 540 km behind it. */ 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.target.set(0, 0, face - dir * 30) + this.camera.position.set(R * 1.6, -R * 3.6, face + dir * 240) this.controls.update() return }