tower / commit
feat(caps): frame the cap windows in identical triangles (D-078)
README.md | 8 +- decisions.md | 23 +++++ design.md | 13 ++- src/views/cap-pattern.ts | 238 +++++++++++++++-------------------------------- test/cap-pattern.test.ts | 35 +++++++ 5 files changed, 145 insertions(+), 172 deletions(-) diff --git a/README.md b/README.md index 3dfddc0..58f3cd2 100644 --- a/README.md +++ b/README.md @@ -377,10 +377,10 @@ it back up the column for a second pass, which lifts the beam's utilisation from to 94%. By day the sun takes the whole beam before it gets there (below), so the mirror is lit only at night, by the banked beam: a faint disc where the rod ends. -The checker survives on the far cap as the window's **mullions** — the same polar -tessellation at the same pitch, so it is still the only object in the world with a -known cell size and still the only rule anyone has for judging distance. It is just -read against sky now instead of against a plate. +Both caps' windows are framed by **mullions** in a lattice of identical equilateral +triangles, 8.3 km on a side, six meeting at the axis. That is the pitch of the checker the +caps once had, so the frame is still the only object in the world with a known size and +the only rule anyone has for judging distance, read against sky. **Tower has a sun, and it crosses the world once a day.** The mirrorlets that fly in the beam gather into one shoal and throw the beam's light out sideways there, which makes a diff --git a/decisions.md b/decisions.md index b0f9dd2..290b1b5 100644 --- a/decisions.md +++ b/decisions.md @@ -1194,6 +1194,8 @@ whichever axial direction has the most room and prints the distance it used. ## D-041 — The caps get a pattern, and it is polar +*Superseded by D-078 for the pattern: the caps' frames are identical triangles, at this pitch.* + *Half superseded by D-045: the far cap is now a window, and this pattern survives on it as the window's mullions rather than as a filled checker. Everything below still holds — the tessellation, the pitch, the mipmapping and the reason for all @@ -1356,6 +1358,8 @@ that change the constant would have been a number in a file and nothing else. ## D-045 — The far cap is a window, and the checker becomes its frame +*Amended by D-078: the mullions are a triangle lattice, not the polar tessellation.* + §1.4 has said since the first draft that the caps are the only views out of Tower and that the sky through them turns rather than sweeps — a pinwheel about the axis with one motionless star at the middle, one revolution every 7 min 29 s. It was @@ -2597,3 +2601,22 @@ discs would stand inside Tower and be seen through the windows. The plating is t so that, from inside the tube, it hides the far side's parts wherever the ground has not been generated yet. All the parts are instanced: five draw calls for a few thousand pieces. Seeded, so it is the same plant on every load, and built only when first chosen. + +## D-078 — The cap windows are framed in identical triangles + +**Asked for** (author, 2026-09-27): "the endcaps have circular window patterns. please +change these to be identical triangle pieces." + +Both caps' mullions are now a lattice of **identical equilateral triangles**: three +families of straight members at 60° to each other, spaced one triangle height apart, with a +vertex on the axis where six triangles meet. The rings and spokes of the polar tessellation +(D-041, D-045), including the heavy ring at 25 km, are gone. The side is 8.3 km — the old +cells' pitch, six across the radius — so the frame keeps its job as the one object in the +world with a known size (D-041), and it is coarse enough to survive the near edge-on views +from outside (D-030). + +A disc cannot be tiled by whole triangles, so the rim cuts the outermost ones; the heavier +rim mullion closes them. The far cap's mirror sits on the centre vertex, and the sun cap's +aperture on the same point of its own lattice. `test/cap-pattern.test.ts` pins the lattice: +three directions 60° apart, every line a whole triangle height from the axis. The polar +checker drawing, which nothing had used since both caps became windows, was removed with it. diff --git a/design.md b/design.md index 954554e..3615b63 100644 --- a/design.md +++ b/design.md @@ -318,8 +318,8 @@ The only views out are at the **end caps**, and they are peculiar: used, and more names are added as they come into use (author, 2026-09-27). - The **far cap is glazed whole** — a 100 km window looking into deep anti-sun space, not a wall with windows at its foot (D-045). Its structure survives as the - window's **mullions**, on the same polar tessellation the checker used and at the - same cell pitch, so the one object in the world with a known scale still is one: + window's **mullions**, a lattice of identical equilateral triangles 8.3 km on a side + (D-078) — the old checker's pitch — so the one object in the world with a known scale still is one: the rule now reads against the sky instead of against a plate. The **sun cap** faces the collector rather than the sun, and is **glazed too** (D-050): §1.3.0 retires the "heavy glare management" this section used to demand of it, because no @@ -957,11 +957,10 @@ pair of binoculars, and captions on every place still visible through the haze. Selecting a place walks the observer to it (D-038) — the haze horizon closes at 100–150 km, so turning to face something usually just points at haze. -The caps are no longer the same object. **The sun cap is checkered** (D-041) — -polar, six rings, cells about 8 km. **The far cap is glazed** (D-045), and the -checker survives on it as the window's mullions: the same tessellation at the same -pitch, so it is still the only object in the world with a known size and still -doubles as a scale for judging distance, now read against sky rather than plate. +**Both caps are glazed** (D-045, D-050), framed by a lattice of identical equilateral +triangles 8.3 km on a side (D-078), with a vertex on the axis where six meet. That is the +pitch of the polar checker it replaced (D-041), so it is still the only object in the world +with a known size and still doubles as a scale for judging distance, read against sky. The exterior draws these same caps — the same meshes and shaders, not a copy of them (D-060). diff --git a/src/views/cap-pattern.ts b/src/views/cap-pattern.ts index 13fb19e..4ac33ca 100644 --- a/src/views/cap-pattern.ts +++ b/src/views/cap-pattern.ts @@ -1,110 +1,67 @@ /** - * The pattern on the end caps, drawn once and shared by both 3D views. + * The window frames 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. + * Both caps are glazed (D-045, D-050), and the structure holding the glass is a lattice + * of **identical equilateral triangles** (D-078): three families of straight mullions at + * 60° to each other, with a vertex on the axis where six triangles meet. It replaced a + * polar tessellation of rings and spokes, at the author's request. * - * 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. + * The triangles keep the old cells' pitch, CELL_KM on a side, so the cap is still the one + * object in the world with a known scale (D-041): a glance at the far cap's lattice tells + * you how far away it is. A disc cannot be tiled by whole triangles, so the rim cuts the + * outermost ones; the rim mullion closes them. */ import { CanvasTexture, LinearFilter, SRGBColorSpace, type Texture } from 'three' import { FAR_CAP_MIRROR_RADIUS_M } from '../canon/constants.js' 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. + * A triangle's side, km: the old polar cells' pitch, six across the radius. Finer looked + * better face-on from inside and collapsed into a dark cross-hatch from outside, where + * the caps are seen near edge-on (D-030) and foreshortened ten to one. */ -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 +export const TRIANGLE_KM = RADIUS_KM / 6 -const LIGHT = '#c3cad6' -const DARK = '#8d96a5' -const RIB = '#dae1eb' -/** The far cap's window frame. Lighter than the checker: it is seen against sky. */ +/** The window frame. Light: it is seen against sky. */ const MULLION = '#aeb7c6' /** The bullseye mirror. Near-white: it is returning 1.7 MW/m² back up the axis. */ const MIRROR = '#f2f6ff' -/** 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. + * The lattice's mullions as line segments in km, centred on the axis and clipped to the + * cap: for each of three directions 60° apart, the lines of that family, one triangle + * height apart. Every line through the lattice is one of these, so the triangles they + * bound are all the same triangle. */ -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() +export function triangleMullions(): [number, number, number, number][] { + const height = (TRIANGLE_KM * Math.sqrt(3)) / 2 + const out: [number, number, number, number][] = [] + for (let f = 0; f < 3; f++) { + const a = (f * Math.PI) / 3 + const dx = Math.cos(a) + const dy = Math.sin(a) + // Offset perpendicular to the line: (−dy, dx). + const reach = Math.floor(RADIUS_KM / height) + for (let k = -reach; k <= reach; k++) { + const off = k * height + const half = Math.sqrt(Math.max(0, RADIUS_KM * RADIUS_KM - off * off)) + if (half <= 0) continue + const cx = -dy * off + const cy = dx * off + out.push([cx - dx * half, cy - dy * half, cx + dx * half, cy + dy * half]) } } - - // 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 + return out } /** - * The far cap's glazing: the same polar tessellation, drawn as the frame around - * transparent panes instead of a filled checker. - * - * §1.4 makes the caps the only views out of Tower, and D-045 glazes the far one - * whole. The checker cannot simply be deleted, because it is the only object in - * the world with a known cell size and so the only rule anyone has for judging - * distance (D-041). So it becomes the mullions: the cells stay exactly where they - * were and at exactly the same pitch, and you look through them instead of at - * them. The rule now reads against the sky, which is a better place for it. + * The glazing: frame around transparent panes. Alpha is the whole point of this canvas — + * opaque where there is structure, clear where there is a pane; the colour channels + * carry the mullion's own tone. * - * Alpha is the whole point of this canvas — opaque where there is structure, - * clear where there is a pane. The colour channels carry the mullion's own tone. + * Drawn into a square canvas covering the cap's bounding box, which is how Three maps UVs + * onto both CircleGeometry and RingGeometry, so the one image lands correctly on the far + * cap's disc and on the sun cap's annulus. */ export function drawCapGlazing(size = 2048, bullseye = true): HTMLCanvasElement { const c = document.createElement('canvas') @@ -120,52 +77,37 @@ export function drawCapGlazing(size = 2048, bullseye = true): HTMLCanvasElement ctx.lineCap = 'round' /** - * Mullions are 400 m of structure across a 100 km window — heavy for a window - * and slight for a wall, which is about right for something holding an - * atmosphere back. Below a pixel on screen the line simply fades, and the cap - * goes clear rather than moiring, which is the behaviour D-041 wanted. + * Mullions are 400 m of structure across a 100 km window — heavy for a window and + * slight for a wall, which is about right for something holding an atmosphere back. + * Below a pixel on screen the line simply fades, and the cap goes clear rather than + * moiring, which is the behaviour D-041 wanted. */ const mullionKm = 0.4 ctx.lineWidth = Math.max(1, mullionKm * px) - - for (let i = 0; i < RINGS; i++) { - const r0 = i * CELL_KM - const r1 = r0 + CELL_KM - const n = sectorsAt(r1) - // The ring itself. + for (const [x0, y0, x1, y1] of triangleMullions()) { ctx.beginPath() - ctx.arc(mid, mid, r1 * px, 0, Math.PI * 2) + ctx.moveTo(mid + x0 * px, mid + y0 * px) + ctx.lineTo(mid + x1 * px, mid + y1 * px) ctx.stroke() - // The radial members bounding each cell in it. - for (let s = 0; s < n; s++) { - const a = (s / n) * Math.PI * 2 - ctx.beginPath() - ctx.moveTo(mid + Math.cos(a) * r0 * px, mid + Math.sin(a) * r0 * px) - ctx.lineTo(mid + Math.cos(a) * r1 * px, mid + Math.sin(a) * r1 * px) - ctx.stroke() - } } - // The same two heavy rings the checker has, so the coarse scale survives when - // the cell pitch is too fine to resolve through 540 km of air. + // The rim: heavier, closing the triangles the edge cuts, and the frame the glass sits in. ctx.lineWidth = Math.max(1.5, mullionKm * 2.5 * px) - for (const r of [25, 50]) { - ctx.beginPath() - ctx.arc(mid, mid, r * px - ctx.lineWidth / 2, 0, Math.PI * 2) - ctx.stroke() - } + ctx.beginPath() + ctx.arc(mid, mid, RADIUS_KM * px - ctx.lineWidth / 2, 0, Math.PI * 2) + ctx.stroke() /** - * The bullseye: not glass, and the only part of the far cap that is not (D-048). - * The beam arrives collimated in its own cross-section, so what has to survive - * 1.7 MW/m² is a 4 km disc on the axis and nothing else — the mirror that sends - * it back up the column for a second pass. + * The bullseye: not glass, and the only part of the far cap that is not (D-048). The + * beam arrives collimated in its own cross-section, so what has to survive 1.7 MW/m² + * is a 4 km disc on the axis and nothing else — the mirror that sends it back up the + * column for a second pass. It sits on the lattice's centre vertex. * - * Alpha 1, so the star field does not show through it. It does not occult the - * pole star, though the geometry invites that guess: the pinwheel turns about the - * direction parallel to the axis, which from the floor is horizontal, so its - * centre lands on the window at the observer's own radius — down at eye level — - * while the mirror is an object on the axis, about 10° above it from 270 km. + * Alpha 1, so the star field does not show through it. It does not occult the pole + * star, though the geometry invites that guess: the pinwheel turns about the direction + * parallel to the axis, which from the floor is horizontal, so its centre lands on the + * window at the observer's own radius — down at eye level — while the mirror is an + * object on the axis, about 10° above it from 270 km. */ if (!bullseye) return c const mirrorKm = FAR_CAP_MIRROR_RADIUS_M / 1000 @@ -173,9 +115,8 @@ export function drawCapGlazing(size = 2048, bullseye = true): HTMLCanvasElement ctx.beginPath() ctx.arc(mid, mid, mirrorKm * px, 0, Math.PI * 2) ctx.fill() - // A collar around it, so at a distance the mirror reads as set into structure - // rather than as a bright fleck of nothing. - ctx.strokeStyle = MULLION + // A collar around it, so at a distance the mirror reads as set into structure rather + // than as a bright fleck of nothing. ctx.lineWidth = Math.max(1, mullionKm * 1.5 * px) ctx.beginPath() ctx.arc(mid, mid, mirrorKm * px, 0, Math.PI * 2) @@ -184,59 +125,34 @@ export function drawCapGlazing(size = 2048, bullseye = true): HTMLCanvasElement } /** - * How far out the mirror reaches, as a fraction of the cap's radius. The shaders - * need it: the bullseye is the one part of the window that is lit by the beam, so - * it brightens and banks with the beam instead of with the ambient light. + * How far out the mirror reaches, as a fraction of the cap's radius. The shaders need it: + * the bullseye is the one part of the window that is lit by the beam, so it brightens and + * banks with the beam instead of with the ambient light. */ export const MIRROR_UV_RADIUS = FAR_CAP_MIRROR_RADIUS_M / 1000 / RADIUS_KM -/** 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. +function glazingTexture(bullseye: boolean): Texture { + const tex = new CanvasTexture(drawCapGlazing(2048, bullseye)) 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. - */ + // Mipmapped: an aliasing window frame twinkles, which reads as the stars behind it + // moving. Averaged away once the mullions fall below a pixel, the cap goes clear. tex.anisotropy = 16 tex.magFilter = LinearFilter - cached = tex - return cached + return tex } -/** The glazing as a texture, on the same terms. Alpha is the pane mask. */ +/** The far cap's glazing, built once; both views share the one instance. */ let glazingCached: Texture | null = null export function capGlazingTexture(): Texture { - if (glazingCached) return glazingCached - const tex = new CanvasTexture(drawCapGlazing()) - tex.colorSpace = SRGBColorSpace - // Mipmapped for the same reason the checker is, and it matters more here: an - // aliasing window frame twinkles, which reads as the stars behind it moving. - tex.anisotropy = 16 - tex.magFilter = LinearFilter - glazingCached = tex - return glazingCached + return (glazingCached ??= glazingTexture(true)) } /** - * The sun cap's glazing: the same frame, without the bullseye. Its middle is the - * beam aperture — a hole in the ring, not a mirror — so drawing one there would put - * a sliver of false structure around the rim of the opening. + * The sun cap's glazing: the same frame, without the bullseye. Its middle is the beam + * aperture — a hole in the ring, not a mirror — so drawing one there would put a sliver + * of false structure around the rim of the opening. */ let sunGlazingCached: Texture | null = null export function sunCapGlazingTexture(): Texture { - if (sunGlazingCached) return sunGlazingCached - const tex = new CanvasTexture(drawCapGlazing(2048, false)) - tex.colorSpace = SRGBColorSpace - tex.anisotropy = 16 - tex.magFilter = LinearFilter - sunGlazingCached = tex - return sunGlazingCached + return (sunGlazingCached ??= glazingTexture(false)) } diff --git a/test/cap-pattern.test.ts b/test/cap-pattern.test.ts new file mode 100644 index 0000000..c232ab3 --- /dev/null +++ b/test/cap-pattern.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { TRIANGLE_KM, triangleMullions } from '../src/views/cap-pattern.js' + +describe('the cap windows are identical triangles (D-078)', () => { + const lines = triangleMullions() + const height = (TRIANGLE_KM * Math.sqrt(3)) / 2 + + it('runs its mullions in three directions, 60° apart', () => { + const dirs = new Set(lines.map(([x0, y0, x1, y1]) => { + const a = (Math.atan2(y1 - y0, x1 - x0) * 180) / Math.PI + return Math.round(((a % 180) + 180) % 180) + })) + expect([...dirs].sort((a, b) => a - b)).toEqual([0, 60, 120]) + }) + + it('spaces every line a whole triangle height from the axis, so every triangle is the same', () => { + for (const [x0, y0, x1, y1] of lines) { + const len = Math.hypot(x1 - x0, y1 - y0) + // Distance from the axis to the line: |cross(p0, d)|. + const off = Math.abs(x0 * (y1 - y0) - y0 * (x1 - x0)) / len + const k = off / height + expect(Math.abs(k - Math.round(k))).toBeLessThan(1e-9) + } + }) + + it('stays inside the cap, and puts a vertex on the axis', () => { + for (const [x0, y0, x1, y1] of lines) { + expect(Math.hypot(x0, y0)).toBeLessThanOrEqual(50 + 1e-9) + expect(Math.hypot(x1, y1)).toBeLessThanOrEqual(50 + 1e-9) + } + // One line of each family passes through the centre. + const through = lines.filter(([x0, y0, x1, y1]) => Math.abs(x0 * y1 - y0 * x1) < 1e-9) + expect(through).toHaveLength(3) + }) +})