tower / interior-flight / files / tools / terrain.mjs
Branch interior-flight · default branch · all branches
terrain.mjs
1de9dc7c 2026-09-07 Pavel Safronov | 1 | /** |
| 2 | * Terrain generator. `npm run terrain` | |
| 3 | * | |
| 4 | * Reads content/geography.json — the authored source of truth — and writes: | |
5a57ebae 2026-09-07 Pavel Safronov | 5 | * static/terrain-height.png 16-bit greyscale, render.minHeightM..maxHeightM |
62458672 2026-09-07 Pavel Safronov | 6 | * static/terrain-color.png 8-bit RGB, hypsometric tint + hillshade |
| 7 | * static/world.json seed places for the app | |
1de9dc7c 2026-09-07 Pavel Safronov | 8 | * |
| 9 | * The map is the cylinder unrolled: x is z (sun cap at the left), y is theta. | |
| 10 | * y wraps; x does not. Every distance below is in metres, converted to pixels only | |
| 11 | * at the edges, because the canon coordinates are metres (design.md §1.7). | |
| 12 | */ | |
| 13 | import { readFileSync, writeFileSync, existsSync } from 'node:fs' | |
| 14 | import { encodeGray16, encodeRGB } from './png.mjs' | |
| 15 | import { fbm, ridged } from './noise.mjs' | |
| 16 | ||
| 17 | // ------------------------------------------------------------------- progress | |
| 18 | const T0 = Date.now() | |
| 19 | const secs = (from) => `${((Date.now() - from) / 1000).toFixed(1)}s` | |
| 20 | const mb = (n) => `${(n / 1048576).toFixed(1)} MB` | |
| 21 | /** Print `label` padded out to a timing column, so the log reads as a table. */ | |
| 22 | function line(indent, label, right = '') { | |
| 23 | const pad = Math.max(1, 58 - indent * 2 - label.length - right.length) | |
| 24 | console.log(' '.repeat(indent * 2) + label + ' '.repeat(pad) + right) | |
| 25 | } | |
| 26 | /** Run `fn`, timing it and printing one row. */ | |
| 27 | function step(indent, label, fn, extra = () => '') { | |
| 28 | const t = Date.now() | |
| 29 | const out = fn() | |
| 30 | line(indent, label, `${extra(out)}${secs(t)}`.trim()) | |
| 31 | return out | |
| 32 | } | |
| 33 | ||
| 34 | const geo = JSON.parse(readFileSync('content/geography.json', 'utf8')) | |
| 35 | console.log(`\nTower terrain — content/geography.json, ${geo.features.length} features\n`) | |
| 36 | const { radiusM: R, lengthM: L, circumferenceM: C } = geo.datum | |
| 37 | const BIOME = { plain: 0, desert: 1, forest: 2, alpine: 3, water: 4 } | |
| 38 | const ORIGIN = geo.render.mapThetaOrigin ?? 0 | |
| 39 | ||
| 40 | // Mutable view state. Every op reads these, so the same generator renders the | |
| 41 | // whole world or a close crop of the corridor without duplicating any terrain code. | |
| 42 | let W, H, MPP_X, MPP_Y, VZ0, VY0 | |
| 43 | let height, biome, water, biomeStrength | |
| 44 | ||
| 45 | const px2z = (x) => VZ0 + (x + 0.5) * MPP_X | |
| 46 | const px2y = (y) => VY0 + (y + 0.5) * MPP_Y | |
| 47 | /** Canon theta -> map y, honouring where the map is cut. */ | |
| 48 | const theta2y = (t) => { | |
| 49 | const d = (((t - ORIGIN) % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI) | |
| 50 | return d * R | |
| 51 | } | |
| 52 | ||
| 53 | /** | |
| 54 | * Noise sample coordinates at a given feature size, with the theta axis snapped to | |
| 55 | * a whole number of lattice cells. The circumference is not a round multiple of any | |
| 56 | * useful scale, so an unsnapped lattice tears at the seam and streaks the terrain. | |
| 57 | * Snapping makes the y frequency differ from x by a few percent, which is invisible. | |
| 58 | */ | |
| 59 | function nc(z, y, scaleM) { | |
| 60 | const cells = Math.max(1, Math.round(C / scaleM)) | |
| 61 | return { x: z / scaleM, y: (y / C) * cells, wrapY: cells } | |
| 62 | } | |
| 63 | ||
| 64 | /** | |
| 65 | * Domain warp: offset a sample point by low-frequency noise before evaluating a | |
| 66 | * mask. Without it every region reads as the rectangle or ellipse it was declared | |
| 67 | * as; with it the edges wander like real ones. | |
| 68 | */ | |
| 69 | function warp(z, y, ampM, scaleM, seed) { | |
| 70 | if (!ampM) return [z, y] | |
| 71 | const s = nc(z, y, scaleM) | |
| 72 | const wz = fbm(s.x, s.y, { octaves: 3, wrapY: s.wrapY, seed }) - 0.5 | |
| 73 | const wy = fbm(s.x + 37.1, s.y + 13.7, { octaves: 3, wrapY: s.wrapY, seed: seed + 500 }) - 0.5 | |
| 74 | return [z + wz * 2 * ampM, y + wy * 2 * ampM] | |
| 75 | } | |
| 76 | /** Shortest distance along the wrapping theta axis. */ | |
| 77 | const dy = (a, b) => { | |
| 78 | let d = Math.abs(a - b) % C | |
| 79 | return d > C / 2 ? C - d : d | |
| 80 | } | |
| 81 | const smoothstep = (e0, e1, x) => { | |
| 82 | const t = Math.max(0, Math.min(1, (x - e0) / (e1 - e0))) | |
| 83 | return t * t * (3 - 2 * t) | |
| 84 | } | |
| 85 | ||
| 86 | // ---------------------------------------------------------------- relief ops | |
| 87 | ||
| 88 | function opBase(f) { | |
| 89 | const r = f.relief | |
| 90 | for (let y = 0; y < H; y++) { | |
| 91 | for (let x = 0; x < W; x++) { | |
| 92 | const z = px2z(x) | |
| 93 | const wy = px2y(y) | |
| 94 | // Broad rolling relief, plus a finer layer so the ground has texture at | |
| 95 | // reading scale. Still gentle: this is engineered ground nobody dramatised. | |
| 96 | const sb = nc(z, wy, r.noiseScaleM) | |
| 97 | const sf = nc(z, wy, r.detailScaleM) | |
| 98 | const sg = nc(z, wy, 3400) | |
| 99 | const broad = fbm(sb.x, sb.y, { octaves: r.octaves ?? 5, seed: 7, wrapY: sb.wrapY }) | |
| 100 | const fine = fbm(sf.x, sf.y, { octaves: 4, seed: 313, wrapY: sf.wrapY }) | |
| 101 | const grain = ridged(sg.x, sg.y, { octaves: 3, seed: 517, wrapY: sg.wrapY }) | |
9a621732 2026-09-08 Pavel Safronov | 102 | /** |
| 103 | * A fourth layer, down near what the raster can actually hold. | |
| 104 | * | |
| 105 | * The three above bottom out at 3.4 km, and a pixel is 264 m — so between | |
| 106 | * 3.4 km and the ~530 m Nyquist limit there was an octave and a half of | |
| 107 | * nothing, and the ground read as smooth swells with no grain on them. This | |
| 108 | * fills that band. It is deliberately the last thing the base does: carve and | |
| 109 | * flood run afterwards, so rivers still cut through it and lake surfaces stay | |
| 110 | * flat rather than inheriting a rippled floor. | |
| 111 | */ | |
| 112 | const sm = nc(z, wy, r.microScaleM ?? 1500) | |
| 113 | const micro = fbm(sm.x, sm.y, { octaves: 4, seed: 1097, wrapY: sm.wrapY }) | |
1de9dc7c 2026-09-07 Pavel Safronov | 114 | height[y * W + x] = |
| 115 | r.baseM + (broad - 0.45) * 2 * r.noiseAmpM + (fine - 0.5) * 2 * r.detailAmpM | |
9a621732 2026-09-08 Pavel Safronov | 116 | + (grain - 0.5) * 26 + (micro - 0.5) * 2 * (r.microAmpM ?? 26) |
1de9dc7c 2026-09-07 Pavel Safronov | 117 | } |
| 118 | } | |
| 119 | } | |
| 120 | ||
| 121 | /** Raised block with soft edges — the desert plateau and the forest shelf. */ | |
| 122 | /** Theta mask for a footprint, or a flat 1 for a band that rings the world. */ | |
| 123 | function thetaMask(g, r, wyw) { | |
| 124 | if (g.ring) return 1 | |
| 125 | const span = dy(theta2y(g.thetaFrom), theta2y(g.thetaTo)) | |
| 126 | const mid = theta2y((g.thetaFrom + g.thetaTo) / 2) | |
| 127 | return smoothstep(span / 2, span / 2 - r.edgeM, dy(wyw, mid)) | |
| 128 | } | |
| 129 | ||
| 130 | function opPlateau(f) { | |
| 131 | const g = f.geometry | |
| 132 | const r = f.relief | |
| 133 | for (let y = 0; y < H; y++) { | |
| 134 | const wy = px2y(y) | |
| 135 | for (let x = 0; x < W; x++) { | |
| 136 | // Reject on unwarped z first: the warp costs two noise evaluations, and most | |
| 137 | // pixels are nowhere near this footprint. | |
| 138 | const zr = px2z(x) | |
| 139 | if (zr < g.zFrom - r.edgeM - (r.warpM ?? 0) || zr > g.zTo + r.edgeM + (r.warpM ?? 0)) continue | |
| 140 | const [z, wyw] = warp(zr, wy, r.warpM, r.warpScaleM ?? 40000, 61) | |
| 141 | const inTheta = thetaMask(g, r, wyw) | |
| 142 | if (inTheta <= 0) continue | |
| 143 | const inZ = smoothstep(g.zFrom - r.edgeM, g.zFrom, z) * (1 - smoothstep(g.zTo, g.zTo + r.edgeM, z)) | |
| 144 | const m = inTheta * inZ | |
| 145 | if (m <= 0) continue | |
| 146 | const sn = nc(z, wy, r.noiseScaleM) | |
| 147 | const n = fbm(sn.x, sn.y, { octaves: 4, seed: 23, wrapY: sn.wrapY }) | |
| 148 | const i = y * W + x | |
| 149 | height[i] += m * (r.heightM + (n - 0.5) * 2 * r.noiseAmpM) | |
| 150 | // Claim the pixel only if this region's mask is the strongest here, with a | |
| 151 | // noise dither so neighbouring regions meet on a ragged line, not a ruler. | |
| 152 | const claim = m + (n - 0.5) * 0.5 | |
| 153 | if (f.biome && claim > biomeStrength[i] && claim > 0.25) { | |
| 154 | biomeStrength[i] = claim | |
| 155 | biome[i] = BIOME[f.biome] | |
| 156 | } | |
| 157 | } | |
| 158 | } | |
| 159 | } | |
| 160 | ||
| 161 | /** A ridge crossing the theta axis — what the canyon cuts through. */ | |
| 162 | function opRidge(f) { | |
| 163 | const g = f.geometry | |
| 164 | const r = f.relief | |
| 165 | for (let y = 0; y < H; y++) { | |
| 166 | const inTheta = g.ring | |
| 167 | ? 1 | |
| 168 | : smoothstep(dy(theta2y(g.thetaFrom), theta2y(g.thetaTo)) / 2, | |
| 169 | dy(theta2y(g.thetaFrom), theta2y(g.thetaTo)) / 2 - 12000, | |
| 170 | dy(px2y(y), theta2y((g.thetaFrom + g.thetaTo) / 2))) | |
| 171 | if (inTheta <= 0) continue | |
| 172 | const reach = r.widthZM * 2 + (r.warpM ?? 0) | |
| 173 | for (let x = 0; x < W; x++) { | |
| 174 | if (Math.abs(px2z(x) - g.z) > reach) continue | |
| 175 | const [zw] = warp(px2z(x), px2y(y), r.warpM, 30000, 77) | |
| 176 | const d = Math.abs(zw - g.z) | |
| 177 | if (d > r.widthZM * 2) continue | |
| 178 | const bump = Math.exp(-(d * d) / (2 * (r.widthZM / 2) ** 2)) | |
| 179 | const sr = nc(zw, px2y(y), 5000) | |
| 180 | const n = fbm(sr.x, sr.y, { octaves: 3, seed: 41, wrapY: sr.wrapY }) | |
03607270 2026-09-08 Pavel Safronov | 181 | let scale = 0.75 + 0.5 * n |
| 182 | /** | |
| 183 | * Optional ridged noise, which is what turns a ridge into a *range*. Without | |
| 184 | * it a ring band is a smooth swell all the way round — right for the three | |
| 185 | * low walls across the waist, wrong for anything meant to read as peaks. | |
| 186 | */ | |
| 187 | if (r.ridgedAmp) { | |
| 188 | const sd = nc(zw, px2y(y), r.ridgedScaleM ?? 15000) | |
| 189 | const rg = ridged(sd.x, sd.y, { octaves: 6, seed: 91, wrapY: sd.wrapY }) | |
| 190 | scale *= 1 - r.ridgedAmp + r.ridgedAmp * rg * 1.6 | |
| 191 | } | |
| 192 | const h = inTheta * bump * r.heightM * scale | |
| 193 | const i = y * W + x | |
| 194 | /** | |
| 195 | * `max` rather than `+=` matters wherever a ring runs under named summits: | |
| 196 | * added, the ring's 2,900 m would stack onto Snowcrown's 4,300 and push the | |
| 197 | * massif past the raster's 4,400 m ceiling. Taking the greater of the two | |
| 198 | * lets the named peaks stay the high points of their own range, which is | |
| 199 | * what they are. | |
| 200 | */ | |
| 201 | height[i] = r.blend === 'max' ? Math.max(height[i], h) : height[i] + h | |
| 202 | if (f.biome === 'alpine' && h > 1600) { | |
| 203 | biome[i] = BIOME.alpine | |
| 204 | biomeStrength[i] = 2 | |
| 205 | } | |
1de9dc7c 2026-09-07 Pavel Safronov | 206 | } |
| 207 | } | |
| 208 | } | |
| 209 | ||
| 210 | /** The mountain: a dome with ridged noise laid over it. */ | |
| 211 | function opDome(f) { | |
| 212 | const g = f.geometry | |
| 213 | const r = f.relief | |
| 214 | const cy = theta2y(g.theta) | |
| 215 | // Build the massif into a scratch buffer first: ridged noise overshoots by an | |
| 216 | // amount that depends on the noise, so the only way to land the summit on the | |
| 217 | // declared 4,300 m is to normalise after the fact. | |
| 218 | const tmp = new Float64Array(W * H) | |
| 219 | let maxH = 0 | |
| 220 | for (let y = 0; y < H; y++) { | |
| 221 | const ddy = dy(px2y(y), cy) / r.radiusThetaM | |
| 222 | if (ddy > 1.6) continue | |
| 223 | for (let x = 0; x < W; x++) { | |
| 224 | if (Math.abs(px2z(x) - g.z) > r.radiusZM + (r.warpM ?? 0)) continue | |
| 225 | const [zw, yw] = warp(px2z(x), px2y(y), r.warpM, 26000, 131) | |
| 226 | const ddx = (zw - g.z) / r.radiusZM | |
| 227 | const dd = Math.hypot(ddx, dy(yw, cy) / r.radiusThetaM) | |
| 228 | if (dd >= 1) continue | |
| 229 | const dome = Math.pow(Math.cos((dd * Math.PI) / 2), r.falloff) | |
| 230 | const sd = nc(zw, yw, r.ridgedScaleM) | |
| 231 | const rg = ridged(sd.x, sd.y, { octaves: 6, seed: 91, wrapY: sd.wrapY }) | |
| 232 | // Ridges bite hardest on the flanks and least at the summit, which is what | |
| 233 | // stops the massif reading as a smooth blister. | |
| 234 | const flank = Math.sin(dd * Math.PI) * 0.8 + 0.2 | |
| 235 | const h = dome * (1 - r.ridgedAmp * flank + r.ridgedAmp * flank * rg * 1.9) | |
| 236 | tmp[y * W + x] = h | |
| 237 | if (h > maxH) maxH = h | |
| 238 | } | |
| 239 | } | |
| 240 | if (maxH <= 0) return | |
| 241 | const scale = r.heightM / maxH | |
| 242 | for (let i = 0; i < tmp.length; i++) { | |
| 243 | if (tmp[i] <= 0) continue | |
| 244 | const h = tmp[i] * scale | |
| 245 | height[i] += h | |
| 246 | // Only the high ground is bare alpine rock; the skirts stay vegetated. | |
| 247 | if (h > 1600) { | |
| 248 | biome[i] = BIOME.alpine | |
| 249 | biomeStrength[i] = 2 | |
| 250 | } | |
| 251 | } | |
| 252 | } | |
| 253 | ||
| 254 | /** A lake basin, plus its still water surface. */ | |
| 255 | function opBasin(f) { | |
| 256 | const g = f.geometry | |
| 257 | const r = f.relief | |
| 258 | const cy = theta2y(g.theta) | |
| 259 | for (let y = 0; y < H; y++) { | |
| 260 | const ddy = dy(px2y(y), cy) / (g.radiusThetaM + r.edgeM) | |
| 261 | if (ddy > 1.3) continue | |
| 262 | for (let x = 0; x < W; x++) { | |
| 263 | const ddx = (px2z(x) - g.z) / (g.radiusZM + r.edgeM) | |
| 264 | const d = Math.hypot(ddx, ddy) | |
| 265 | if (d >= 1) continue | |
| 266 | const i = y * W + x | |
| 267 | height[i] -= r.depthM * Math.pow(Math.cos((d * Math.PI) / 2), 1.6) | |
| 268 | if (height[i] < f.waterLevelM) { | |
| 269 | water[i] = f.waterLevelM | |
| 270 | biome[i] = BIOME.water | |
| 271 | } | |
| 272 | } | |
| 273 | } | |
| 274 | } | |
| 275 | ||
| 276 | /** | |
| 277 | * Carve a river valley along a polyline, interpolating the long profile. | |
| 278 | * Reaches let one river be a broad mountain valley, then a slot canyon, then a | |
| 279 | * shallow floodplain — which is the whole shape of this watercourse. | |
| 280 | */ | |
| 281 | /** Catmull-Rom through the authored waypoints, so the river curves instead of zigzagging. */ | |
| 282 | function smoothPolyline(points, profile, perSegment = 14) { | |
| 283 | const at = (i) => points[Math.max(0, Math.min(points.length - 1, i))] | |
| 284 | const pAt = (i) => profile[Math.max(0, Math.min(profile.length - 1, i))] | |
| 285 | const out = [] | |
| 286 | for (let i = 0; i < points.length - 1; i++) { | |
| 287 | const p0 = at(i - 1) | |
| 288 | const p1 = at(i) | |
| 289 | const p2 = at(i + 1) | |
| 290 | const p3 = at(i + 2) | |
| 291 | for (let s = 0; s < perSegment; s++) { | |
| 292 | const u = s / perSegment | |
| 293 | const u2 = u * u | |
| 294 | const u3 = u2 * u | |
| 295 | const c = (a, b, cc, d) => | |
| 296 | 0.5 * (2 * b + (cc - a) * u + (2 * a - 5 * b + 4 * cc - d) * u2 + (-a + 3 * b - 3 * cc + d) * u3) | |
| 297 | out.push({ | |
| 298 | y: c(p0.y, p1.y, p2.y, p3.y), | |
| 299 | z: c(p0.z, p1.z, p2.z, p3.z), | |
| 300 | elev: pAt(i) + (pAt(i + 1) - pAt(i)) * u, | |
| 301 | src: i + u, | |
| 302 | }) | |
| 303 | } | |
| 304 | } | |
| 305 | const last = points[points.length - 1] | |
| 306 | out.push({ y: last.y, z: last.z, elev: profile[profile.length - 1], src: points.length - 1 }) | |
| 307 | return out | |
| 308 | } | |
| 309 | ||
| 310 | function opCarve(f) { | |
| 311 | const raw = f.geometry.points.map(([t, z]) => ({ y: theta2y(t), z })) | |
| 312 | const pts = smoothPolyline(raw, f.profileM) | |
| 313 | const reaches = f.relief.reaches | |
| 314 | const KEYS = ['valleyWidthM', 'channelWidthM', 'depthM', 'wallExponent', 'shoulderM'] | |
| 315 | const BLEND = 2.2 // waypoints over which one reach becomes the next | |
| 316 | ||
| 317 | /** | |
| 318 | * Reach parameters at a fractional position along the river, blended across | |
| 319 | * boundaries. Switching reaches abruptly steps the valley from 5 km wide to 18 km | |
| 320 | * in a single pixel column, which reads as a wall across the map. | |
| 321 | */ | |
| 322 | function reachAt(sf) { | |
| 323 | let idx = reaches.findIndex((r) => sf < r.toIndex) | |
| 324 | if (idx < 0) idx = reaches.length - 1 | |
| 325 | const cur = reaches[idx] | |
| 326 | const out = {} | |
| 327 | const edge = cur.toIndex | |
| 328 | const next = reaches[idx + 1] | |
| 329 | if (next && sf > edge - BLEND) { | |
| 330 | const t = smoothstep(edge - BLEND, edge + BLEND, sf) | |
| 331 | for (const k of KEYS) out[k] = cur[k] + (next[k] - cur[k]) * t | |
| 332 | return out | |
| 333 | } | |
| 334 | const prev = reaches[idx - 1] | |
| 335 | if (prev && sf < prev.toIndex + BLEND) { | |
| 336 | const t = smoothstep(prev.toIndex - BLEND, prev.toIndex + BLEND, sf) | |
| 337 | for (const k of KEYS) out[k] = prev[k] + (cur[k] - prev[k]) * t | |
| 338 | return out | |
| 339 | } | |
| 340 | for (const k of KEYS) out[k] = cur[k] | |
| 341 | return out | |
| 342 | } | |
| 343 | ||
| 344 | // Precompute segments once, with their theta unwrapped and their influence | |
| 345 | // radius, then bin them into a coarse grid. Testing every segment against every | |
| 346 | // pixel is ~3 billion distance tests for four rivers at world resolution; the | |
| 347 | // grid turns it into a handful per pixel. | |
| 348 | const segs = [] | |
| 349 | for (let s = 0; s < pts.length - 1; s++) { | |
| 350 | const a = pts[s] | |
| 351 | const b = pts[s + 1] | |
| 352 | let by = b.y | |
| 353 | if (Math.abs(by - a.y) > C / 2) by += by > a.y ? -C : C | |
| 354 | const rr = reachAt(a.src) | |
| 355 | segs.push({ a, by, infl: rr.valleyWidthM / 2 + rr.shoulderM }) | |
| 356 | } | |
| 357 | ||
| 358 | const CELL = 4000 | |
| 359 | const NZ = Math.ceil(L / CELL) | |
| 360 | const NY = Math.ceil(C / CELL) | |
| 361 | const grid = new Map() | |
| 362 | const wrapCell = (cy) => ((cy % NY) + NY) % NY | |
| 363 | for (let s = 0; s < segs.length; s++) { | |
| 364 | const { a, by, infl } = segs[s] | |
| 365 | const z0 = Math.min(a.z, pts[s + 1].z) - infl | |
| 366 | const z1 = Math.max(a.z, pts[s + 1].z) + infl | |
| 367 | const y0 = Math.min(a.y, by) - infl | |
| 368 | const y1 = Math.max(a.y, by) + infl | |
| 369 | for (let cz = Math.floor(z0 / CELL); cz <= Math.floor(z1 / CELL); cz++) { | |
| 370 | if (cz < 0 || cz >= NZ) continue | |
| 371 | for (let cy = Math.floor(y0 / CELL); cy <= Math.floor(y1 / CELL); cy++) { | |
| 372 | const key = cz * NY + wrapCell(cy) | |
| 373 | let list = grid.get(key) | |
| 374 | if (!list) grid.set(key, (list = [])) | |
| 375 | list.push(s) | |
| 376 | } | |
| 377 | } | |
| 378 | } | |
| 379 | ||
| 380 | for (let y = 0; y < H; y++) { | |
| 381 | const wy = px2y(y) | |
| 382 | const cy = wrapCell(Math.floor(wy / CELL)) | |
| 383 | for (let x = 0; x < W; x++) { | |
| 384 | const wz = px2z(x) | |
| 385 | const list = grid.get(Math.floor(wz / CELL) * NY + cy) | |
| 386 | if (!list) continue | |
| 387 | let best = Infinity | |
| 388 | let bestElev = 0 | |
| 389 | let bestSrc = 0 | |
| 390 | for (const si of list) { | |
| 391 | const { a, by } = segs[si] | |
| 392 | const b = pts[si + 1] | |
| 393 | let py = wy | |
| 394 | if (Math.abs(py - a.y) > C / 2) py += py > a.y ? -C : C | |
| 395 | const vz = b.z - a.z | |
| 396 | const vy = by - a.y | |
| 397 | const tt = Math.max(0, Math.min(1, ((wz - a.z) * vz + (py - a.y) * vy) / (vz * vz + vy * vy || 1))) | |
| 398 | const d = Math.hypot(wz - (a.z + vz * tt), py - (a.y + vy * tt)) | |
| 399 | if (d < best) { | |
| 400 | best = d | |
| 401 | bestElev = a.elev + (b.elev - a.elev) * tt | |
| 402 | bestSrc = a.src | |
| 403 | } | |
| 404 | } | |
| 405 | if (best === Infinity) continue | |
| 406 | const reach = reachAt(bestSrc) | |
| 407 | const half = reach.valleyWidthM / 2 | |
| 408 | const shoulder = reach.shoulderM | |
| 409 | if (best > half + shoulder) continue | |
| 410 | const i = y * W + x | |
| 411 | const chan = reach.channelWidthM / 2 | |
| 412 | const u = Math.max(0, Math.min(1, (best - chan) / Math.max(1, half - chan))) | |
| 413 | const rim = bestElev + Math.pow(u, reach.wallExponent) * reach.depthM | |
| 414 | const k = 1 - smoothstep(half, half + shoulder, best) | |
| 415 | height[i] = Math.min(height[i], rim * k + height[i] * (1 - k)) | |
| 416 | if (best < chan) { | |
| 417 | water[i] = bestElev + 2 | |
| 418 | biome[i] = BIOME.water | |
| 419 | biomeStrength[i] = 3 | |
| 420 | } | |
| 421 | } | |
| 422 | } | |
| 423 | } | |
| 424 | ||
| 425 | /** | |
| 426 | * Carve a footprint down and fill it to a water level. Handles the axial sea (a | |
| 427 | * trough long in z and narrow in theta) and the cap seas (bands ringing the world). | |
| 428 | */ | |
| 429 | function opFlood(f) { | |
| 430 | const g = f.geometry | |
| 431 | const r = f.relief | |
| 432 | for (let y = 0; y < H; y++) { | |
| 433 | const wy = px2y(y) | |
| 434 | for (let x = 0; x < W; x++) { | |
| 435 | const zr = px2z(x) | |
| 436 | if (zr < g.zFrom - r.edgeM - (r.warpM ?? 0) || zr > g.zTo + r.edgeM + (r.warpM ?? 0)) continue | |
| 437 | const [z, wyw] = warp(zr, wy, r.warpM ?? 0, r.warpScaleM ?? 30000, 197) | |
| 438 | const inTheta = thetaMask(g, r, wyw) | |
| 439 | if (inTheta <= 0) continue | |
| 440 | const inZ = smoothstep(g.zFrom - r.edgeM, g.zFrom + r.edgeM * 0.2, z) * | |
| 441 | (1 - smoothstep(g.zTo - r.edgeM * 0.2, g.zTo + r.edgeM, z)) | |
| 442 | const m = inTheta * inZ | |
| 443 | if (m <= 0.001) continue | |
| 444 | const i = y * W + x | |
| 445 | height[i] -= r.depthM * Math.pow(m, 0.7) | |
| 446 | if (height[i] < f.waterLevelM && m > 0.05) { | |
| 447 | water[i] = f.waterLevelM | |
| 448 | biome[i] = BIOME.water | |
| 449 | biomeStrength[i] = 3 | |
| 450 | } | |
| 451 | } | |
| 452 | } | |
| 453 | } | |
| 454 | ||
| 455 | /** | |
| 456 | * A mountain tarn. Floods relative to the ground it finds rather than to an | |
| 457 | * absolute level, so a lake can be dropped anywhere on a range without first | |
| 458 | * knowing how high that spot came out. | |
| 459 | */ | |
| 460 | function opTarn(f) { | |
| 461 | const g = f.geometry | |
| 462 | const r = f.relief | |
| 463 | const cy = theta2y(g.theta) | |
| 464 | const rz = g.radiusZM | |
| 465 | const rt = g.radiusThetaM | |
| 466 | const inside = [] | |
| 467 | ||
| 468 | // Carve the bowl and raise a moraine rim around it. | |
| 469 | for (let y = 0; y < H; y++) { | |
| 470 | const ddy = dy(px2y(y), cy) / rt | |
| 471 | if (ddy > 1.05) continue | |
| 472 | for (let x = 0; x < W; x++) { | |
| 473 | const d = Math.hypot((px2z(x) - g.z) / rz, ddy) | |
| 474 | if (d >= 1) continue | |
| 475 | const i = y * W + x | |
| 476 | height[i] -= r.depthM * Math.pow(Math.cos((d * Math.PI) / 2), 1.2) | |
| 477 | if (d > 0.68) height[i] += (r.rimM ?? r.depthM * 0.5) * Math.sin(((d - 0.68) / 0.32) * Math.PI) | |
| 478 | if (d < 0.85) inside.push(i) | |
| 479 | } | |
| 480 | } | |
| 481 | if (!inside.length) return | |
| 482 | ||
| 483 | // Fill from the bowl floor upward. Taking the level from the rim instead lets the | |
| 484 | // water spill out downhill and leaves a half-moon rather than a lake. | |
| 485 | let floor = Infinity | |
| 486 | for (const i of inside) if (height[i] < floor) floor = height[i] | |
| 487 | const level = floor + r.depthM * (r.fillFraction ?? 0.55) | |
| 488 | for (const i of inside) { | |
| 489 | if (height[i] < level) { | |
| 490 | water[i] = level | |
| 491 | biome[i] = BIOME.water | |
| 492 | biomeStrength[i] = 3 | |
| 493 | } | |
| 494 | } | |
| 495 | f.$computedLevelM = Math.round(level) | |
| 496 | } | |
| 497 | ||
| 498 | const OPS = { base: opBase, plateau: opPlateau, ridge: opRidge, dome: opDome, | |
| 499 | basin: opBasin, carve: opCarve, flood: opFlood, tarn: opTarn } | |
| 500 | ||
| 501 | /** Build the height/biome/water fields for one rectangular view of the surface. */ | |
| 502 | function generate(view, label, verbose) { | |
| 503 | W = view.width | |
| 504 | H = view.height | |
| 505 | VZ0 = view.zFrom | |
| 506 | VY0 = view.yFrom | |
| 507 | MPP_X = (view.zTo - view.zFrom) / W | |
| 508 | MPP_Y = (view.yTo - view.yFrom) / H | |
| 509 | height = new Float64Array(W * H) | |
| 510 | biome = new Uint8Array(W * H) | |
| 511 | water = new Float64Array(W * H) | |
| 512 | biomeStrength = new Float64Array(W * H) | |
| 513 | ||
| 514 | line(1, `${label} ${W} x ${H}`, `${MPP_X.toFixed(0)} m/px`) | |
| 515 | const started = Date.now() | |
| 516 | const shaped = [] | |
| 517 | for (const f of geo.features) { | |
| 518 | if (!f.relief) continue | |
| 519 | const op = OPS[f.relief.op] | |
| 520 | if (!op) throw new Error(`unknown relief op "${f.relief.op}" on ${f.id}`) | |
| 521 | // Capture W/H/etc, which the ops read from module state. | |
| 522 | const w = W, h = H, mx = MPP_X, my = MPP_Y, vz = VZ0, vy = VY0 | |
| 523 | const run = () => { | |
| 524 | W = w; H = h; MPP_X = mx; MPP_Y = my; VZ0 = vz; VY0 = vy | |
| 525 | op(f) | |
| 526 | } | |
| 527 | if (verbose) step(3, `${f.relief.op.padEnd(9)} ${f.name}`, run) | |
| 528 | else run() | |
| 529 | shaped.push(f.id) | |
| 530 | } | |
| 531 | W = view.width; H = view.height | |
| 532 | line(2, `relief ${shaped.length} features`, secs(started)) | |
| 533 | return { width: W, height: H, field: height, biome, water, mppX: MPP_X, mppY: MPP_Y } | |
| 534 | } | |
| 535 | ||
| 536 | // ------------------------------------------------------------------- rendering | |
| 537 | ||
| 538 | const SNOW = geo.render.snowlineM | |
| 539 | ||
| 540 | const RAMP = { | |
| 541 | 0: [[-40, [104, 118, 88]], [120, [118, 130, 92]], [340, [132, 138, 98]], | |
| 542 | [700, [140, 138, 106]], [1600, [146, 138, 120]]], | |
| 543 | 1: [[400, [198, 178, 132]], [700, [186, 160, 112]], [1000, [166, 138, 96]], | |
| 544 | [1400, [148, 120, 86]]], | |
| 545 | 2: [[80, [52, 86, 54]], [260, [62, 100, 58]], [500, [76, 108, 64]], [1000, [98, 114, 76]]], | |
| 546 | 3: [[600, [104, 106, 96]], [1400, [124, 122, 114]], [2200, [146, 142, 136]], | |
| 547 | [2900, [166, 162, 156]]], | |
| 548 | 4: [[0, [40, 84, 128]], [5000, [58, 108, 150]]], | |
| 549 | } | |
| 550 | ||
| 551 | function tint(b, h) { | |
| 552 | const stops = RAMP[b] | |
| 553 | if (h <= stops[0][0]) return stops[0][1] | |
| 554 | for (let i = 1; i < stops.length; i++) { | |
| 555 | if (h <= stops[i][0]) { | |
| 556 | const t = (h - stops[i - 1][0]) / (stops[i][0] - stops[i - 1][0]) | |
| 557 | return stops[i - 1][1].map((c, k) => c + (stops[i][1][k] - c) * t) | |
| 558 | } | |
| 559 | } | |
| 560 | return stops[stops.length - 1][1] | |
| 561 | } | |
| 562 | ||
| 563 | /** | |
| 564 | * Hillshade. The light direction is a cartographic convention, not Tower's real | |
| 565 | * lighting — Tower's sun is a luminous line down the axis, so true shading there | |
| 566 | * is soft and nearly shadowless. This is for reading the map, not for realism. | |
| 567 | */ | |
| 568 | function colorise(g) { | |
| 569 | const { azimuthDeg, altitudeDeg, strength } = geo.render.hillshade | |
| 570 | const az = (azimuthDeg * Math.PI) / 180 | |
| 571 | const alt = (altitudeDeg * Math.PI) / 180 | |
| 572 | const lx = Math.cos(alt) * Math.sin(az) | |
| 573 | const ly = Math.cos(alt) * Math.cos(az) | |
| 574 | const lz = Math.sin(alt) | |
| 575 | const { width: w, height: h, field, biome: bio, water: wat, mppX, mppY } = g | |
| 576 | const rgb = new Uint8Array(w * h * 3) | |
| 577 | ||
| 578 | for (let y = 0; y < h; y++) { | |
| 579 | for (let x = 0; x < w; x++) { | |
| 580 | const i = y * w + x | |
| 581 | const nx = (field[y * w + Math.max(0, x - 1)] - field[y * w + Math.min(w - 1, x + 1)]) / (2 * mppX) | |
| 582 | const ny = (field[((y - 1 + h) % h) * w + x] - field[((y + 1) % h) * w + x]) / (2 * mppY) | |
| 583 | const len = Math.hypot(nx, ny, 1) | |
| 584 | let shade = (nx * lx + ny * ly + lz) / len | |
| 585 | shade = 1 - strength + strength * Math.max(0, shade) * 1.35 | |
| 586 | ||
| 587 | let c = tint(bio[i], field[i]) | |
| 588 | if (wat[i] > 0) { | |
| 589 | const d = Math.min(1, Math.max(0, wat[i] - field[i]) / 90) | |
| 590 | c = [58 - 20 * d, 96 - 26 * d, 132 - 24 * d] | |
| 591 | shade = 0.93 + 0.07 * shade | |
| 592 | } else { | |
9a621732 2026-09-08 Pavel Safronov | 593 | /** |
| 594 | * Two layers of per-pixel variation, because one was not enough to stop a | |
| 595 | * region reading as flat paint. | |
| 596 | * | |
| 597 | * The broad one dims and brightens. The fine one **mottles**: it pushes the | |
| 598 | * channels apart rather than scaling them together, so ground reads as | |
| 599 | * patchy — vegetation against bare soil — instead of as a single colour | |
| 600 | * turned up and down. Green moves most and blue least, which is roughly how | |
| 601 | * real ground cover varies, and it keeps deserts sandy rather than tinting | |
| 602 | * them toward the same green as the forest. | |
| 603 | */ | |
1de9dc7c 2026-09-07 Pavel Safronov | 604 | const sj = nc(px2z(x), px2y(y), 5200) |
| 605 | const j = fbm(sj.x, sj.y, { octaves: 3, seed: 733, wrapY: sj.wrapY }) | |
9a621732 2026-09-08 Pavel Safronov | 606 | const sm = nc(px2z(x), px2y(y), 1150) |
| 607 | const m = fbm(sm.x, sm.y, { octaves: 4, seed: 941, wrapY: sm.wrapY }) | |
1de9dc7c 2026-09-07 Pavel Safronov | 608 | const k = 0.9 + 0.2 * j |
9a621732 2026-09-08 Pavel Safronov | 609 | const mo = (m - 0.5) * 0.13 |
| 610 | c = [c[0] * (k - mo * 0.55), c[1] * (k + mo), c[2] * (k - mo * 0.25)] | |
1de9dc7c 2026-09-07 Pavel Safronov | 611 | if (field[i] > SNOW) { |
| 612 | // Snow does not lie on cliffs. Blending it out with slope is what turns a | |
| 613 | // white blob into a mountain with rock showing through. | |
| 614 | const slope = Math.hypot(nx, ny) | |
| 615 | const holds = 1 - Math.min(1, Math.max(0, (slope - 0.55) / 0.55)) | |
| 616 | const t = Math.min(1, (field[i] - SNOW) / 550) * holds | |
| 617 | c = c.map((v, q) => v + ([247, 249, 253][q] - v) * t) | |
| 618 | } | |
| 619 | } | |
| 620 | rgb[i * 3] = Math.max(0, Math.min(255, c[0] * shade)) | |
| 621 | rgb[i * 3 + 1] = Math.max(0, Math.min(255, c[1] * shade)) | |
| 622 | rgb[i * 3 + 2] = Math.max(0, Math.min(255, c[2] * shade)) | |
| 623 | } | |
| 624 | } | |
| 625 | return rgb | |
| 626 | } | |
| 627 | ||
| 628 | // --------------------------------------------------------------------- outputs | |
| 629 | ||
| 630 | const worldView = { | |
| 631 | width: geo.render.gridWidth, height: geo.render.gridHeight, | |
| 632 | zFrom: 0, zTo: L, yFrom: 0, yTo: C, | |
| 633 | } | |
| 634 | const world = generate(worldView, 'world', !process.argv.includes('--quiet')) | |
| 635 | const worldRGB = step(2, 'colourise', () => colorise(world)) | |
| 636 | ||
| 637 | let peak = 0 | |
| 638 | let low = Infinity | |
| 639 | for (const v of world.field) { | |
| 640 | if (v > peak) peak = v | |
| 641 | if (v < low) low = v | |
| 642 | } | |
| 643 | ||
| 644 | // A close view of the developed corridor. At world scale the canyon is six pixels | |
| 645 | // wide, which is not enough to judge anything by. | |
| 646 | const crop = geo.render.corridor | |
| 647 | const cropView = { | |
| 648 | width: crop.width, height: crop.height, | |
| 649 | zFrom: crop.zFrom, zTo: crop.zTo, | |
| 650 | yFrom: theta2y(crop.thetaFrom), yTo: theta2y(crop.thetaFrom) + (crop.thetaTo - crop.thetaFrom) * R, | |
| 651 | } | |
| 652 | const corridor = generate(cropView, 'corridor', false) | |
| 653 | const corridorRGB = step(2, 'colourise', () => colorise(corridor)) | |
| 654 | ||
| 655 | line(1, 'encode') | |
| 656 | const write = (name, make) => | |
| 657 | step(2, name, () => { | |
| 658 | const buf = make() | |
62458672 2026-09-07 Pavel Safronov | 659 | writeFileSync(`static/${name}`, buf) |
1de9dc7c 2026-09-07 Pavel Safronov | 660 | return buf |
| 661 | }, (b) => `${mb(b.length).padEnd(10)}`) | |
| 662 | ||
5a57ebae 2026-09-07 Pavel Safronov | 663 | /** |
| 664 | * Normalise the height field into the declared working range. | |
| 665 | * | |
| 666 | * The raster does not have to span anything in particular — it only has to span the | |
| 667 | * terrain that exists, and the narrower the range the finer every step in it. That | |
| 668 | * matters more than it looks: browsers decode a 16-bit PNG down to 8 bits, so what | |
| 669 | * the app actually reads is 256 levels across whatever range is declared here. | |
| 670 | * Clipping the bottom at zero also flattened every basin below the datum, which is | |
| 671 | * why lake floors had none. | |
| 672 | */ | |
| 673 | const HMIN = geo.render.minHeightM | |
| 674 | const HMAX = geo.render.maxHeightM | |
| 675 | let lo = Infinity, hi = -Infinity | |
| 676 | for (const h of world.field) { if (h < lo) lo = h; if (h > hi) hi = h } | |
| 677 | const span = HMAX - HMIN | |
1de9dc7c 2026-09-07 Pavel Safronov | 678 | const norm = new Float64Array(world.field.length) |
5a57ebae 2026-09-07 Pavel Safronov | 679 | let clipped = 0 |
| 680 | for (let i = 0; i < norm.length; i++) { | |
| 681 | const v = (world.field[i] - HMIN) / span | |
| 682 | if (v < 0 || v > 1) clipped++ | |
| 683 | norm[i] = Math.min(1, Math.max(0, v)) | |
| 684 | } | |
| 685 | line(1, 'height range', | |
| 686 | `terrain ${lo.toFixed(0)}..${hi.toFixed(0)} m in ${HMIN}..${HMAX} m, ` + | |
| 687 | `${(span / 255).toFixed(1)} m per 8-bit step`) | |
| 688 | if (clipped) { | |
9a621732 2026-09-08 Pavel Safronov | 689 | /** |
| 690 | * Fail, rather than warn. This warned for one run and nobody saw it: the terrain | |
| 691 | * had grown 1 m past the floor and every pixel down there was quietly flattened, | |
| 692 | * which is D-024's failure mode exactly — the raster displaces terrain rather | |
| 693 | * than breaking it, so nothing downstream complains. The unit test cannot catch | |
| 694 | * it either, because it compares the declaration against numbers written down | |
| 695 | * when it was last true rather than against the terrain that just came out. | |
| 696 | */ | |
| 697 | console.error(`\n ! ${clipped} pixel(s) fall outside minHeightM..maxHeightM and were clamped.`) | |
| 698 | console.error(` Terrain spans ${lo.toFixed(0)}..${hi.toFixed(0)} m; widen the range in`) | |
| 699 | console.error(' content/geography.json to keep them.\n') | |
| 700 | process.exitCode = 1 | |
5a57ebae 2026-09-07 Pavel Safronov | 701 | } |
1de9dc7c 2026-09-07 Pavel Safronov | 702 | write('terrain-height.png', () => encodeGray16(world.width, world.height, norm)) |
| 703 | write('terrain-color.png', () => encodeRGB(world.width, world.height, worldRGB)) | |
| 704 | write('terrain-corridor.png', () => encodeRGB(corridor.width, corridor.height, corridorRGB)) | |
| 705 | ||
| 706 | // -------------------------------------------------------- seed places for the app | |
| 707 | ||
| 708 | const KIND = { mountain: 'mountain', river: 'river', canyon: 'canyon', forest: 'forest', | |
| 709 | desert: 'desert', lake: 'lake', city: 'city', tower: 'tower', region: 'region' } | |
| 710 | ||
| 711 | function toPlaceGeometry(g) { | |
| 712 | if (g.type === 'point') return { type: 'point', theta: g.theta, z: g.z } | |
| 713 | if (g.type === 'polyline') return { type: 'polyline', points: g.points } | |
| 714 | if (g.type === 'ellipse') { | |
| 715 | const pts = [] | |
| 716 | for (let i = 0; i < 32; i++) { | |
| 717 | const a = (i / 32) * 2 * Math.PI | |
| 718 | pts.push([g.theta + (g.radiusThetaM / R) * Math.cos(a), g.z + g.radiusZM * Math.sin(a)]) | |
| 719 | } | |
| 720 | return { type: 'polygon', points: pts } | |
| 721 | } | |
| 722 | if (g.type === 'rect') { | |
| 723 | return { type: 'polygon', points: [ | |
| 724 | [g.thetaFrom, g.zFrom], [g.thetaTo, g.zFrom], [g.thetaTo, g.zTo], [g.thetaFrom, g.zTo]] } | |
| 725 | } | |
04d6779e 2026-09-07 Pavel Safronov | 726 | // A band rings the world. As a polygon it would collapse — theta 0 and theta 2pi |
| 727 | // land on the same map row — so it stays its own shape all the way through. | |
| 728 | if (g.type === 'band') return { type: 'band', zFrom: g.zFrom, zTo: g.zTo } | |
1de9dc7c 2026-09-07 Pavel Safronov | 729 | throw new Error(`cannot convert geometry ${g.type}`) |
| 730 | } | |
| 731 | ||
| 732 | const places = geo.features | |
| 733 | // Ridges are bands with no z extent — relief only, not places on a map. | |
| 734 | .filter((f) => f.geometry.type !== 'whole-surface' && f.relief?.op !== 'ridge') | |
| 735 | .map((f) => ({ | |
| 736 | id: f.id, name: f.name, kind: KIND[f.kind] ?? 'landmark', | |
| 737 | geometry: toPlaceGeometry(f.geometry), blurb: '', | |
| 738 | })) | |
| 739 | ||
62458672 2026-09-07 Pavel Safronov | 740 | const target = 'static/world.json' |
1de9dc7c 2026-09-07 Pavel Safronov | 741 | const existing = existsSync(target) ? JSON.parse(readFileSync(target, 'utf8')) : { places: [] } |
| 742 | const authored = existing.places?.filter((p) => !places.some((q) => q.id === p.id)) ?? [] | |
| 743 | if (authored.length && !process.argv.includes('--force')) { | |
| 744 | console.warn(`\n ! ${target} holds ${authored.length} place(s) not in geography.json.`) | |
| 745 | console.warn(' Not overwriting. Re-run with --force to replace them.\n') | |
| 746 | } else { | |
| 747 | writeFileSync(target, JSON.stringify({ version: 1, places }, null, 2) + '\n') | |
| 748 | } | |
| 749 | ||
| 750 | const probeArg = process.argv.indexOf('--probe') | |
| 751 | if (probeArg >= 0) { | |
| 752 | const theta = Number(process.argv[probeArg + 1] ?? 0) | |
| 753 | const row = Math.round((theta2y(theta) / C) * world.height) % world.height | |
| 754 | console.log(`\n elevation profile along theta = ${theta} (row ${row})`) | |
| 755 | let prev = null | |
| 756 | for (let x = 0; x < world.width; x += 4) { | |
| 757 | const z = (x + 0.5) * world.mppX | |
| 758 | const h = world.field[row * world.width + x] | |
| 759 | const jump = prev === null ? 0 : h - prev | |
| 760 | if (Math.abs(jump) > 60) console.log(` z ${(z / 1000).toFixed(1)} km ${h.toFixed(0)} m JUMP ${jump.toFixed(0)} m`) | |
| 761 | prev = h | |
| 762 | } | |
| 763 | console.log('') | |
| 764 | } | |
| 765 | ||
| 766 | line(1, `seed ${places.length} places`, target) | |
| 767 | console.log('') | |
| 768 | line(1, 'elevation', `${low.toFixed(0)} m .. ${peak.toFixed(0)} m`) | |
| 769 | line(1, 'done', secs(T0)) | |
| 770 | console.log('') |