tower / files / src / world / frost.ts
frost.ts
e18dad04 2026-09-30 Pavel Safronov | 1 | /** |
| 2 | * The far cap's frost, and the order Tower clears it in. See design.md §1.9.6, D-090, D-091. | |
| 3 | * | |
| 4 | * The far cap is single-glazed, and cold glass frosts on its inner face. Tower clears one | |
| 5 | * pane per turn of the world, in an order it shuffles afresh every loop. The order is drawn | |
| 6 | * from the world's seed and the loop's number, so which panes are clear at any moment is a | |
| 7 | * pure function of the clock — the same in both views, and in the book — with no history to | |
| 8 | * keep. | |
| 9 | * | |
| 10 | * Panes are the triangles of the cap's lattice (D-078): side `PANE_KM`, a vertex on the | |
| 11 | * axis, and one family of mullions parallel to x. A pane is named by its lattice cell (a, b) | |
| 12 | * and which half of it it is: with basis (s, 0) and (s/2, h), every point is a·e1 + b·e2, | |
| 13 | * and the cell splits along a + b = whole into a lower and an upper triangle. | |
| 14 | */ | |
| 15 | import { RADIUS_M, SPIN_PERIOD_S } from '../canon/constants.js' | |
| 16 | import { hash3, mulberry32 } from '../terrain/noise.js' | |
| 17 | ||
| 18 | const R_KM = RADIUS_M / 1000 | |
| 19 | /** A pane's side, km: six to the cap's radius, as the mullions are drawn (cap-pattern.ts). */ | |
| 20 | export const PANE_KM = R_KM / 6 | |
| 21 | const H_KM = (PANE_KM * Math.sqrt(3)) / 2 | |
| 22 | ||
| 23 | export interface Pane { | |
| 24 | a: number | |
| 25 | b: number | |
| 26 | /** 0 for the lower triangle of the cell, 1 for the upper. */ | |
| 27 | up: 0 | 1 | |
| 28 | } | |
| 29 | ||
| 30 | /** The pane a point on the cap is in, km from the axis. */ | |
| 31 | export function paneAt(x: number, y: number): Pane { | |
| 32 | const B = y / H_KM | |
| 33 | const A = x / PANE_KM - B / 2 | |
| 34 | const a = Math.floor(A), b = Math.floor(B) | |
| 35 | return { a, b, up: A - a + (B - b) >= 1 ? 1 : 0 } | |
| 36 | } | |
| 37 | ||
| 38 | /** A pane's three corners, km. */ | |
| 39 | export function paneCorners(p: Pane): [number, number][] { | |
| 40 | const at = (a: number, b: number): [number, number] => [a * PANE_KM + (b * PANE_KM) / 2, b * H_KM] | |
| 41 | return p.up | |
| 42 | ? [at(p.a + 1, p.b), at(p.a + 1, p.b + 1), at(p.a, p.b + 1)] | |
| 43 | : [at(p.a, p.b), at(p.a + 1, p.b), at(p.a, p.b + 1)] | |
| 44 | } | |
| 45 | ||
| 46 | /** Nearest distance from the axis to a segment. */ | |
| 47 | function segmentDistance([x0, y0]: [number, number], [x1, y1]: [number, number]): number { | |
| 48 | const dx = x1 - x0, dy = y1 - y0 | |
| 49 | const t = Math.max(0, Math.min(1, -(x0 * dx + y0 * dy) / (dx * dx + dy * dy))) | |
| 50 | return Math.hypot(x0 + dx * t, y0 + dy * t) | |
| 51 | } | |
| 52 | ||
| 53 | /** | |
| 54 | * Every pane with glass in it: each triangle the cap's disc reaches into, whole or cut by | |
| 55 | * the rim. In a fixed order, so a pane's index means the same pane everywhere. | |
| 56 | */ | |
| 57 | export function farCapPanes(): Pane[] { | |
| 58 | const out: Pane[] = [] | |
| 59 | const reach = Math.ceil(R_KM / H_KM) + 2 | |
| 60 | for (let b = -reach; b <= reach; b++) { | |
| 61 | for (let a = -2 * reach; a <= 2 * reach; a++) { | |
| 62 | for (const up of [0, 1] as const) { | |
| 63 | const c = paneCorners({ a, b, up }) | |
| 64 | // A triangle meets the disc where its nearest point to the axis is inside it. The | |
| 65 | // axis itself is a corner of six of them, so the edges are enough. | |
| 66 | const near = Math.min(segmentDistance(c[0]!, c[1]!), segmentDistance(c[1]!, c[2]!), | |
| 67 | segmentDistance(c[2]!, c[0]!)) | |
| 68 | if (near < R_KM - 1e-9) out.push({ a, b, up }) | |
| 69 | } | |
| 70 | } | |
| 71 | } | |
| 72 | return out | |
| 73 | } | |
| 74 | ||
| 75 | /** The panes, built once. */ | |
| 76 | let panesCached: Pane[] | null = null | |
| 77 | export function panes(): Pane[] { | |
| 78 | return (panesCached ??= farCapPanes()) | |
| 79 | } | |
| 80 | ||
| 81 | /** One pane is cleared per turn of the world (D-090): 7 min 29 s. */ | |
| 82 | export const TURN_S = SPIN_PERIOD_S | |
| 83 | ||
| 84 | /** A loop clears every pane once: 282 turns, 35.1 hours. */ | |
| 85 | export const loopS = (): number => panes().length * TURN_S | |
| 86 | ||
| 87 | /** | |
| 88 | * How fast frost closes a cleared pane: its cover is 1 - e^(-age / FROST_TAU_S). Ten hours | |
| 89 | * leaves about one pane in ten nearly clear at any moment, which is §1.9.6's guess; the frost | |
| 90 | * rate is rough to a factor of ten, and this is the knob that answers for it. | |
| 91 | */ | |
| 92 | export const FROST_TAU_S = 10 * 3600 | |
| 93 | ||
| 94 | /** The order of loop `k`: pane indices in the order they are cleared, shuffled from the seed. */ | |
| 95 | export function loopOrder(seed: number, k: number): number[] { | |
| 96 | const n = panes().length | |
| 97 | const order = Array.from({ length: n }, (_, i) => i) | |
| 98 | const r = mulberry32(hash3(seed, k, 0xf405)) | |
| 99 | for (let i = n - 1; i > 0; i--) { | |
| 100 | const j = Math.floor(r() * (i + 1)) | |
| 101 | const t = order[i]! | |
| 102 | order[i] = order[j]! | |
| 103 | order[j] = t | |
| 104 | } | |
| 105 | return order | |
| 106 | } | |
| 107 | ||
| 108 | /** Where each pane falls in loop `k`'s order: the inverse of `loopOrder`. */ | |
| 109 | function slots(seed: number, k: number): number[] { | |
| 110 | const order = loopOrder(seed, k) | |
| 111 | const slot = new Array<number>(order.length) | |
| 112 | order.forEach((pane, s) => { slot[pane] = s }) | |
| 113 | return slot | |
| 114 | } | |
| 115 | ||
| 116 | const cover = (ageS: number): number => 1 - Math.exp(-Math.max(0, ageS) / FROST_TAU_S) | |
| 117 | ||
| 118 | /** The two loops' slots last asked for: a frame at a time, the loop rarely changes. */ | |
| 119 | let memo: { seed: number; k: number; now: number[]; before: number[] } | null = null | |
| 120 | ||
| 121 | /** | |
| 122 | * The frost on every pane at `tS` seconds of world time, 0 (clear) to 1 (white), into | |
| 123 | * `out`, in `panes()` order. The pane being cleared this turn thaws across it. | |
| 124 | */ | |
| 125 | export function frostAt(seed: number, tS: number, out: Float32Array): Float32Array { | |
| 126 | const L = loopS() | |
| 127 | const k = Math.floor(tS / L) | |
| 128 | if (!memo || memo.seed !== seed || memo.k !== k) { | |
| 129 | memo = { seed, k, now: slots(seed, k), before: slots(seed, k - 1) } | |
| 130 | } | |
| 131 | const start = k * L | |
| 132 | const s = Math.floor((tS - start) / TURN_S) | |
| 133 | const progress = (tS - start) / TURN_S - s | |
| 134 | const n = panes().length | |
| 135 | for (let i = 0; i < n; i++) { | |
| 136 | const mine = memo.now[i]! | |
| 137 | // When this pane last came clear: earlier in this loop, or in the loop before. | |
| 138 | const clearedAt = mine < s ? start + (mine + 1) * TURN_S : start - L + (memo.before[i]! + 1) * TURN_S | |
| 139 | if (mine === s) { | |
| 140 | // Being cleared now: the frost it had built by the start of its turn, thawing. | |
| 141 | out[i] = cover(start + s * TURN_S - clearedAt) * (1 - progress) | |
| 142 | } else { | |
| 143 | out[i] = cover(tS - clearedAt) | |
| 144 | } | |
| 145 | } | |
| 146 | return out | |
| 147 | } |