// Painted-map import — the uplift-painting workflow from the Salty terrain generator // (Tools/Terrain, `terrain plan` / `terrain bake`) on World Orogen's sphere mesh. // // The author paints a flat equirectangular map where every colour is a *class*: a rate of rock // uplift and an erodibility, never a height. A legend JSON beside the painting says what the // colours mean. This module turns the two into a planet: // // 1. classify — every pixel goes to its nearest legend colour (nearest, never "unmatched", // so a JPEG halo or a stray pixel lands on something sensible; the report // says how many were far from everything). // 2. vote — every Voronoi region takes the majority class of the pixels under it. // 3. strokes — the white outline an artist draws round every island dissolves into // whichever real class is nearest; a white blob touching a pole is the ice cap. // 4. coast — the drawn shoreline is roughened by adding noise to the signed distance // from it, because a drawn coast is a smooth curve and a real one is fractal. // 5. uplift — class rate × the planet's upland fabric (a massif is where one fabric, // cut at a quantile of the *planet*, stands high) × a coastal-plain ramp × // a regional swell; erodibility = class k × the planet's rock field. // 6. solve — dh/dt = U − K·A^m·S, integrated implicitly up the drainage stack // (Braun & Willett 2013) with the ocean fixed at sea level, until the land is // in balance with its uplift. Rivers, divides and the valley hierarchy come out // of the physics; the painting decides only where the land rises and how fast. // // Paint the uplift, never the height: a solve handed a painted surface erodes it into something // else within a few hundred steps and throws the drainage network away. // // Everything here is pure computation with no DOM, so it runs inside planet-worker.js. The // legend parser and the pixel classifier also run on the main thread, to show the match report // before anything is solved. import { SimplexNoise } from './simplex-noise.js'; import { elevToHeightKm } from './color-map.js'; export const DEFAULT_WARN_DISTANCE = 60; export const DEFAULT_COASTAL_FLOOR_MM_YR = 0.02; export const MAX_MASSIF_FRACTION = 0.6; export const EARTH_CIRCUMFERENCE_KM = 40030; export const EARTH_RADIUS_KM = 6371; // Defaults for the planet block, matching RawContent/World/Planet.json in the Salty repo. export const PLANET_DEFAULTS = { circumferenceKm: 100, massifWavelengthKm: 7, lithologyWavelengthKm: 8, lithology: [0.6, 1.0, 1.8], variation: 0.30, coastDetail: 0.35, steps: 200, peakKm: 4.5, oceanDepthKm: 4.0, seed: 7945, // The bake's geology grid - Planet.json's pipeline block - which the class table's angles are about // (painted-report.js). Not used by the solve here, whose relief is a scale. k: 5e-5, m: 0.5, cellM: 8, talusDeg: 35, }; // ─── Legend ─────────────────────────────────────────────────────── /** * Parse a legend JSON object (the same schema Tools/Terrain reads) into a normalised legend. * Throws with a readable message when the legend cannot make a world. */ export function parseLegend(obj) { if (!obj || typeof obj !== 'object') throw new Error('The legend is not a JSON object'); if (!Array.isArray(obj.classes) || obj.classes.length === 0) throw new Error('The legend has no "classes" array'); if (obj.classes.length > 255) throw new Error('The legend has more than 255 classes'); const classes = obj.classes.map((c, i) => { if (!c || typeof c !== 'object') throw new Error(`Class ${i} is not an object`); if (typeof c.name !== 'string' || !c.name) throw new Error(`Class ${i} has no name`); let rgb = null; if (Array.isArray(c.rgb) && c.rgb.length === 3) { rgb = c.rgb.map(v => Math.max(0, Math.min(255, Math.round(+v || 0)))); } if (!rgb && !c.derived) throw new Error(`Class "${c.name}" has no rgb`); const sea = !!c.sea; let massif = null; if (c.massif && +c.massif.fraction > 0 && !sea) { massif = { floorMmYr: Math.max(0, +c.massif.floor_mm_yr || 0), fraction: Math.min(MAX_MASSIF_FRACTION, +c.massif.fraction), }; } return { index: i, name: c.name, rgb: rgb || [0, 0, 0], sea, depthM: sea ? Math.max(0, +c.depth_m || 0) : 0, upliftMmYr: sea ? 0 : Math.max(0, +c.uplift_mm_yr || 0), kMult: (+c.k_mult > 0) ? +c.k_mult : 1, stroke: !!c.stroke, derived: !!c.derived, snow: !!c.snow, edgeClass: typeof c.edge_class === 'string' ? c.edge_class : '', edgeIndex: -1, massif, coastalPlainKm: Math.max(0, +c.coastal_plain_km || 0), coastalFloorMmYr: Math.max(0, +c.coastal_floor_mm_yr || 0), lithologyMix: (c.lithology_mix === undefined || c.lithology_mix === null) ? 1 : Math.max(0, Math.min(1, +c.lithology_mix)), raw: c, }; }); for (const c of classes) { if (!c.edgeClass) continue; const j = classes.findIndex(o => o.name === c.edgeClass); if (j < 0) throw new Error(`Class "${c.name}" names edge_class "${c.edgeClass}", which is not in the legend`); c.edgeIndex = j; } const paintable = classes.filter(c => !c.derived); if (!paintable.some(c => c.sea)) throw new Error('The legend has no sea class'); if (!classes.some(c => !c.sea && !c.stroke)) throw new Error('The legend has no land class'); const planet = readPlanetBlock(obj); return { classes, warnDistance: +obj.warn_distance > 0 ? +obj.warn_distance : DEFAULT_WARN_DISTANCE, planet, source: obj, }; } /** * The "planet" block: the numbers Planet.json carries in the Go tool. Read from a legend that carries an * optional copy of it, or from Planet.json itself, which has the same keys plus the pipeline block the * class table's angles need. Missing keys keep `base`, which is PLANET_DEFAULTS for a legend. */ function readPlanetBlock(obj, base = PLANET_DEFAULTS) { const p = obj.planet || {}; const pipe = obj.pipeline || {}; const lith = pipe.lithology || p.lithology || null; const kmults = lith && Array.isArray(lith.k_multipliers) && lith.k_multipliers.length >= 2 ? lith.k_multipliers.map(v => Math.max(0.05, +v || 1)) : (base.lithology || PLANET_DEFAULTS.lithology).slice(); const num = (v, d) => (v !== undefined && v !== null && isFinite(+v)) ? +v : d; const fluvial = pipe.fluvial || {}; const thermal = pipe.thermal || {}; // The geology cell is the detail quad times the geology factor, as manifest.go derives it. const quadM = num(obj.quad_cm, 0) / 100; const geologyFactor = num(pipe.geology_factor, 0); return { circumferenceKm: Math.max(1, num(p.circumference_km, base.circumferenceKm)), massifWavelengthKm: Math.max(0, num(p.massif_wavelength_km, base.massifWavelengthKm)), lithologyWavelengthKm: Math.max(0, num(p.lithology_wavelength_km, base.lithologyWavelengthKm)), lithology: kmults, variation: Math.max(0, Math.min(0.6, num(p.uplift_variation, base.variation))), seed: num(obj.source && obj.source.seed, num(p.seed, base.seed)), k: Math.max(0, num(fluvial.k, base.k)), m: num(fluvial.m, base.m), cellM: quadM > 0 && geologyFactor > 0 ? quadM * geologyFactor : num(p.cell_m, base.cellM), talusDeg: num(thermal.talus_deg, base.talusDeg), overlayLegend: typeof p.overlay_legend === 'string' ? p.overlay_legend : (base.overlayLegend || ''), overlay: typeof p.overlay === 'string' ? p.overlay : (base.overlay || ''), }; } /** * Read Planet.json - the Go tool's own manifest - onto a parsed legend, so its planet block, its lithology * multipliers, its seed and the geology grid's constants travel without being retyped. Keys it lacks keep * what the legend had. Returns the merged block. */ export function applyPlanetManifest(legend, manifest) { if (!manifest || typeof manifest !== 'object') throw new Error('Planet.json is not a JSON object'); if (!manifest.planet || typeof manifest.planet !== 'object') throw new Error('Planet.json has no "planet" block; the painted path reads a painted planet'); legend.planet = readPlanetBlock(manifest, legend.planet); return legend.planet; } /** The class's plain-at-the-waterline rate, never above the class rate. */ export function plainFloorMmYr(c) { let floor = c.coastalFloorMmYr > 0 ? c.coastalFloorMmYr : DEFAULT_COASTAL_FLOOR_MM_YR; if (floor > c.upliftMmYr) floor = c.upliftMmYr; return floor; } /** * Write a legend back out as JSON text, keeping every key of the loaded file and only replacing * the numbers the table edits, so the file still reads in Tools/Terrain with its commentary intact. */ export function serializeLegend(legend) { const out = JSON.parse(JSON.stringify(legend.source)); for (let i = 0; i < legend.classes.length; i++) { const c = legend.classes[i]; const raw = out.classes[i]; if (!raw) continue; if (c.sea) raw.depth_m = c.depthM; else { raw.uplift_mm_yr = c.upliftMmYr; raw.k_mult = c.kMult; if (c.massif && raw.massif) { raw.massif.floor_mm_yr = c.massif.floorMmYr; raw.massif.fraction = c.massif.fraction; } } } return JSON.stringify(out, null, 2); } // ─── Pixel classification ───────────────────────────────────────── /** * Assign every pixel of an RGBA image to its nearest paintable class. * Returns the class raster plus a match report. */ export function classifyImage(rgba, w, h, legend) { const cls = legend.classes; const n = cls.length; const paint = []; for (let i = 0; i < n; i++) if (!cls[i].derived) paint.push(i); const np = paint.length; const pr = new Int32Array(np), pg = new Int32Array(np), pb = new Int32Array(np); for (let k = 0; k < np; k++) { const c = cls[paint[k]]; pr[k] = c.rgb[0]; pg[k] = c.rgb[1]; pb[k] = c.rgb[2]; } // Colours are quantised to six bits a channel and classified once per cell. A legend's // classes sit tens of units apart (warn distance 60), so the ±2 of the cell is nothing. const cacheCls = new Uint8Array(1 << 18).fill(255); const cacheD2 = new Float32Array(1 << 18); const total = w * h; const out = new Uint8Array(total); const counts = new Int32Array(n); const warn2 = legend.warnDistance * legend.warnDistance; let far = 0, maxD2 = 0, maxAt = 0; for (let p = 0, o = 0; p < total; p++, o += 4) { const r = rgba[o], g = rgba[o + 1], b = rgba[o + 2]; const key = ((r >> 2) << 12) | ((g >> 2) << 6) | (b >> 2); let c = cacheCls[key]; if (c === 255) { const cr = (r & ~3) + 2, cg = (g & ~3) + 2, cb = (b & ~3) + 2; let best = 0, bestD = Infinity; for (let k = 0; k < np; k++) { const dr = cr - pr[k], dg = cg - pg[k], db = cb - pb[k]; const d = dr * dr + dg * dg + db * db; if (d < bestD) { bestD = d; best = k; } } c = paint[best]; cacheCls[key] = c; cacheD2[key] = bestD; } out[p] = c; counts[c]++; const d2 = cacheD2[key]; if (d2 > warn2) far++; if (d2 > maxD2) { maxD2 = d2; maxAt = p; } } // The wrap: the left and right columns are the same meridian. let wrapDiffer = 0, wrapLandSea = 0; for (let y = 0; y < h; y++) { const a = out[y * w], b = out[y * w + w - 1]; if (a !== b) { wrapDiffer++; if (cls[a].sea !== cls[b].sea) wrapLandSea++; } } return { classes: out, counts, total, far, maxDist: Math.sqrt(maxD2), maxAt: [maxAt % w, (maxAt / w) | 0], wrapRows: h, wrapDiffer, wrapLandSea, }; } // ─── Region sampling ────────────────────────────────────────────── const clamp1 = v => Math.max(-1, Math.min(1, v)); /** * Vote the class raster onto the mesh: every region takes the majority class of a box of * pixels the size of its own footprint, so a thin stroke or a JPEG halo never decides a cell. * Returns the class per region and the latitude per region (reused by the stroke pass). */ export function sampleClassesToMesh(mesh, r_xyz, classRaster, w, h, numClasses) { const N = mesh.numRegions; const r_class = new Uint8Array(N); const r_lat = new Float32Array(N); const spacing = Math.sqrt(4 * Math.PI / Math.max(1, N - 1)); // radians between neighbours const pxPerRadX = w / (2 * Math.PI); const pxPerRadY = h / Math.PI; const counts = new Int32Array(numClasses); const MAXS = 7; for (let r = 0; r < N; r++) { const x = r_xyz[3 * r], y = r_xyz[3 * r + 1], z = r_xyz[3 * r + 2]; const lat = Math.asin(clamp1(y)); const lon = Math.atan2(x, z); r_lat[r] = lat; const cx = (lon / Math.PI + 1) * 0.5 * w; const cy = (0.5 - lat / Math.PI) * h; const cosLat = Math.max(Math.cos(lat), 1e-3); let hx = 0.5 * spacing * pxPerRadX / cosLat; if (hx > w / 2) hx = w / 2; const hy = 0.5 * spacing * pxPerRadY; const nx = Math.min(MAXS, Math.max(1, Math.round(2 * hx))); const ny = Math.min(MAXS, Math.max(1, Math.round(2 * hy))); counts.fill(0); for (let j = 0; j < ny; j++) { const sy = ny === 1 ? cy : cy - hy + (2 * hy) * (j + 0.5) / ny; let py = Math.floor(sy); if (py < 0) py = 0; else if (py >= h) py = h - 1; const row = py * w; for (let i = 0; i < nx; i++) { const sx = nx === 1 ? cx : cx - hx + (2 * hx) * (i + 0.5) / nx; let px = Math.floor(sx); px = ((px % w) + w) % w; counts[classRaster[row + px]]++; } } let best = 0; for (let c = 1; c < numClasses; c++) if (counts[c] > counts[best]) best = c; r_class[r] = best; } return { r_class, r_lat }; } /** * Resolve stroke classes on the mesh. A stroke component touching a pole becomes its * edge_class (the ice cap painted in the same white as the outlines); everything else * dissolves into the nearest non-stroke class by BFS. */ export function resolveStrokes(mesh, r_class, r_lat, legend) { const cls = legend.classes; const N = mesh.numRegions; const isStroke = new Uint8Array(cls.length); let any = false; for (let i = 0; i < cls.length; i++) if (cls[i].stroke) { isStroke[i] = 1; any = true; } if (!any) return { edgeAssigned: 0, dissolved: 0 }; const { adjOffset, adjList } = mesh; const spacing = Math.sqrt(4 * Math.PI / Math.max(1, N - 1)); const poleLat = Math.PI / 2 - 2.5 * spacing; const queue = new Int32Array(N); const visited = new Uint8Array(N); let edgeAssigned = 0, dissolved = 0; // Connected components of each stroke class; the ones at a pole become the edge class. for (let r = 0; r < N; r++) { if (visited[r] || !isStroke[r_class[r]]) continue; const ci = r_class[r]; const edge = cls[ci].edgeIndex; let head = 0, tail = 0, touches = false; queue[tail++] = r; visited[r] = 1; while (head < tail) { const c = queue[head++]; if (Math.abs(r_lat[c]) > poleLat) touches = true; for (let j = adjOffset[c], jEnd = adjOffset[c + 1]; j < jEnd; j++) { const nb = adjList[j]; if (!visited[nb] && r_class[nb] === ci) { visited[nb] = 1; queue[tail++] = nb; } } } if (touches && edge >= 0) { for (let k = 0; k < tail; k++) r_class[queue[k]] = edge; edgeAssigned += tail; } } // Dissolve what is left into the nearest real class. const assigned = new Uint8Array(N); let head = 0, tail = 0; for (let r = 0; r < N; r++) { if (!isStroke[r_class[r]]) { assigned[r] = 1; queue[tail++] = r; } } while (head < tail) { const c = queue[head++]; for (let j = adjOffset[c], jEnd = adjOffset[c + 1]; j < jEnd; j++) { const nb = adjList[j]; if (!assigned[nb]) { assigned[nb] = 1; r_class[nb] = r_class[c]; queue[tail++] = nb; dissolved++; } } } return { edgeAssigned, dissolved }; } // ─── Coast ──────────────────────────────────────────────────────── /** * Hop distance from the coast: for land, to the nearest sea region; for sea, to the nearest * land region. 1 means adjacent. 0 means the planet has no coast at all. */ export function coastDistance(mesh, r_land) { const N = mesh.numRegions; const { adjOffset, adjList } = mesh; const hop = new Int32Array(N); const queue = new Int32Array(N); let head = 0, tail = 0; for (let r = 0; r < N; r++) { const land = r_land[r]; for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) { if (r_land[adjList[j]] !== land) { hop[r] = 1; queue[tail++] = r; break; } } } while (head < tail) { const c = queue[head++]; const land = r_land[c]; const d = hop[c] + 1; for (let j = adjOffset[c], jEnd = adjOffset[c + 1]; j < jEnd; j++) { const nb = adjList[j]; if (hop[nb] === 0 && r_land[nb] === land) { hop[nb] = d; queue[tail++] = nb; } } } return hop; } /** Connected components of land regions; returns per-region component id and each component's max coast hop. */ function landComponents(mesh, r_land, hop) { const N = mesh.numRegions; const { adjOffset, adjList } = mesh; const comp = new Int32Array(N).fill(-1); const compMax = []; const queue = new Int32Array(N); for (let r = 0; r < N; r++) { if (!r_land[r] || comp[r] >= 0) continue; const id = compMax.length; let head = 0, tail = 0, mx = 0; queue[tail++] = r; comp[r] = id; while (head < tail) { const c = queue[head++]; if (hop[c] > mx) mx = hop[c]; for (let j = adjOffset[c], jEnd = adjOffset[c + 1]; j < jEnd; j++) { const nb = adjList[j]; if (r_land[nb] && comp[nb] < 0) { comp[nb] = id; queue[tail++] = nb; } } } compMax.push(mx); } return { comp, compMax }; } /** * Roughen the drawn coastline by adding fractal noise to the signed hop distance from it. * `amount` 0..1 is up to three cells of shift; an islet may lose at most two thirds of its * width so an archipelago does not vanish. Returns a new land mask. */ export function roughenCoast(mesh, r_xyz, r_land, amount, seed, r_jitter = null) { const N = mesh.numRegions; const out = new Uint8Array(r_land); if (amount <= 0) return { r_land: out, flipped: 0 }; const hop = coastDistance(mesh, r_land); const amp = amount * 3; // The overlay's coast_jitter marks scale the reach per region (painted-overlay.js): 0 pins the shore as // painted, above 1 chews it harder. The early-out below has to use the largest reach any of them asks. let ampMax = amp; if (r_jitter) { let jm = 1; for (let r = 0; r < N; r++) if (r_jitter[r] > jm) jm = r_jitter[r]; ampMax = amp * jm; } const { comp, compMax } = landComponents(mesh, r_land, hop); const noise = new SimplexNoise(seed * 31 + 17); // Bays about eight cells wide, with four octaves below that. const spacing = Math.sqrt(4 * Math.PI / Math.max(1, N - 1)); const F = 1 / (8 * spacing); let flipped = 0; for (let r = 0; r < N; r++) { if (hop[r] === 0) continue; const d = hop[r] - 0.5; if (d > ampMax + 1) continue; let a = r_jitter ? amp * r_jitter[r] : amp; if (a <= 0 || d > a + 1) continue; if (r_land[r]) { const cap = 0.66 * compMax[comp[r]]; if (cap < a) a = cap; if (a < 0.5) continue; } const n = noise.fbm(r_xyz[3 * r] * F, r_xyz[3 * r + 1] * F, r_xyz[3 * r + 2] * F, 4, 0.5); const s = (r_land[r] ? d : -d) + 2 * n * a; const land = s > 0 ? 1 : 0; if (land !== r_land[r]) { out[r] = land; flipped++; } } return { r_land: out, flipped }; } /** * After the coast moves, a region can be land wearing a sea class or the other way round. * Give each such region the class of the nearest region that agrees with its new type. */ export function reconcileClasses(mesh, r_class, r_land, legend) { const cls = legend.classes; const N = mesh.numRegions; const { adjOffset, adjList } = mesh; const queue = new Int32Array(N); const done = new Uint8Array(N); let changed = 0; for (const wantLand of [1, 0]) { done.fill(0); let head = 0, tail = 0; for (let r = 0; r < N; r++) { const consistent = (cls[r_class[r]].sea ? 0 : 1) === r_land[r]; if (consistent) { done[r] = 1; if (r_land[r] === wantLand) queue[tail++] = r; } else if (r_land[r] !== wantLand) done[r] = 1; // the other pass's problem } while (head < tail) { const c = queue[head++]; for (let j = adjOffset[c], jEnd = adjOffset[c + 1]; j < jEnd; j++) { const nb = adjList[j]; if (!done[nb]) { done[nb] = 1; r_class[nb] = r_class[c]; queue[tail++] = nb; changed++; } } } // A region nothing reached (an all-sea planet turned to land somewhere) takes the first // class of the wanted type. for (let r = 0; r < N; r++) { if (done[r]) continue; const idx = cls.findIndex(c => !c.derived && !c.stroke && (c.sea ? 0 : 1) === wantLand); if (idx >= 0) { r_class[r] = idx; changed++; } done[r] = 1; } } return changed; } // ─── Uplift field ───────────────────────────────────────────────── /** Exact rank of every value in 0..1 over the whole array: the share of the planet standing below it. */ export function rankField(values) { const N = values.length; const idx = new Uint32Array(N); for (let i = 0; i < N; i++) idx[i] = i; idx.sort((a, b) => values[a] - values[b]); const rank = new Float32Array(N); const denom = Math.max(1, N - 1); for (let i = 0; i < N; i++) rank[idx[i]] = i / denom; return rank; } function massifShape(rank, fraction) { const lo = 1 - 1.5 * fraction; const hi = 1 - 0.5 * fraction; const t = (rank - lo) / (hi - lo); if (t <= 0) return 0; if (t >= 1) return 1; return t * t * (3 - 2 * t); } /** The class rate where the fabric is high, the floor where it is low, cut in rank so `fraction` means what it says. */ export function massifRate(floorMmYr, rateMmYr, rank, fraction) { return floorMmYr + (rateMmYr - floorMmYr) * massifShape(rank, fraction); } function sampleFbm(noise, r_xyz, N, F, octaves, gain) { const out = new Float32Array(N); for (let r = 0; r < N; r++) { out[r] = noise.fbm(r_xyz[3 * r] * F, r_xyz[3 * r + 1] * F, r_xyz[3 * r + 2] * F, octaves, gain); } return out; } /** * Build the uplift-rate field (mm/yr per region) and the erodibility multiplier from the * classes and the legend. Distances in the legend are in the painted planet's kilometres and * are scaled to Orogen's Earth-sized globe by 40030 / circumferenceKm. */ export function buildUpliftField(mesh, r_xyz, r_class, r_land, legend, opts) { const cls = legend.classes; const N = mesh.numRegions; const circ = Math.max(1, opts.circumferenceKm); const scale = EARTH_CIRCUMFERENCE_KM / circ; const avgEdgeKm = Math.PI * EARTH_RADIUS_KM / Math.sqrt(N); const seed = opts.seed | 0; const hop = coastDistance(mesh, r_land); // A noise wavelength of λ painted kilometres is F = circ / (2π λ) noise units per radian. const freqFor = km => circ / (2 * Math.PI * Math.max(1e-6, km)); let rank = null; if (opts.massifWavelengthKm > 0 && cls.some(c => c.massif)) { const fab = sampleFbm(new SimplexNoise(seed * 7 + 1), r_xyz, N, freqFor(opts.massifWavelengthKm), 5, 0.45); rank = rankField(fab); } let rock = null; const kmults = opts.lithology || []; if (opts.lithologyWavelengthKm > 0 && kmults.length >= 2 && cls.some(c => c.lithologyMix > 0)) { const fab = sampleFbm(new SimplexNoise(seed * 7 + 2), r_xyz, N, freqFor(opts.lithologyWavelengthKm), 4, 0.5); const rr = rankField(fab); rock = new Float32Array(N); const types = kmults.length; for (let r = 0; r < N; r++) rock[r] = kmults[Math.min(types - 1, Math.floor(rr[r] * types))]; } let swell = null; const variation = Math.max(0, opts.variation || 0); if (variation > 0) swell = sampleFbm(new SimplexNoise(seed * 7 + 3), r_xyz, N, freqFor(25), 4, 0.5); const r_rate = new Float32Array(N); const r_k = new Float32Array(N); let rateMax = 0, landCount = 0; for (let r = 0; r < N; r++) { const c = cls[r_class[r]]; let k = c.kMult; if (rock && c.lithologyMix > 0) k *= 1 + c.lithologyMix * (rock[r] - 1); r_k[r] = k; if (!r_land[r]) continue; landCount++; let rate = c.upliftMmYr; if (rank && c.massif) rate = massifRate(c.massif.floorMmYr, rate, rank[r], c.massif.fraction); if (c.coastalPlainKm > 0) { const plainKm = c.coastalPlainKm * scale; const shoreKm = Math.max(0, hop[r] - 0.5) * avgEdgeKm; let t = Math.min(1, shoreKm / plainKm); t = t * t * (3 - 2 * t); const floor = plainFloorMmYr(c); if (rate > floor) rate = floor + (rate - floor) * t; } if (swell) { let sw = 0.5 + swell[r] * 0.8; if (sw < 0) sw = 0; else if (sw > 1) sw = 1; rate *= 1 + variation * (2 * sw - 1); } r_rate[r] = rate; if (rate > rateMax) rateMax = rate; } return { r_rate, r_k, rateMax, avgEdgeKm, scale, hop, landCount, massifRank: rank }; } // ─── The solve ──────────────────────────────────────────────────── function hash01(a, b) { let x = (Math.imul(a, 374761393) + Math.imul(b, 668265263)) | 0; x = Math.imul(x ^ (x >>> 13), 1274126177); x ^= x >>> 16; return (x >>> 0) / 4294967296; } /** Binary min-heap over region indices with a copied key, sized once. */ class RegionHeap { constructor(capacity) { this.keys = new Float64Array(capacity); this.items = new Int32Array(capacity); this.size = 0; } clear() { this.size = 0; } push(item, key) { let i = this.size++; const keys = this.keys, items = this.items; while (i > 0) { const p = (i - 1) >> 1; if (keys[p] <= key) break; keys[i] = keys[p]; items[i] = items[p]; i = p; } keys[i] = key; items[i] = item; } pop() { const keys = this.keys, items = this.items; const top = items[0]; const n = --this.size; if (n > 0) { const key = keys[n], item = items[n]; let i = 0; while (true) { let l = 2 * i + 1; if (l >= n) break; const r = l + 1; if (r < n && keys[r] < keys[l]) l = r; if (keys[l] >= key) break; keys[i] = keys[l]; items[i] = items[l]; i = l; } keys[i] = key; items[i] = item; } return top; } } /** * Solve the stream-power equation on the mesh from an uplift field. * * Units are dimensionless: U is the rate as a fraction of the largest class rate, K is the * erodibility multiplier, A is drainage area in cells and lengths are in mean edges, so a * divide one cell from the sea at full rate stands about one unit high. For n = 1 the steady * state is linear in U/K, so the relief is set afterwards by a single scale (see toElevation) * and the shape of the land — where the rivers run, how the valleys nest, how far a coast is * from its divide — is what the solve decides. * * Each step: priority-flood so every land cell has a downhill path to the ocean, steepest * receivers, a donor stack, drainage area down the stack, the implicit update up it, and a * touch of hillslope diffusion so the divides are rounded rather than needles. */ export function solveUplift(mesh, neighborDist, r_land, r_rate, r_k, rateMax, params, onProgress) { const N = mesh.numRegions; const { adjOffset, adjList } = mesh; const steps = Math.max(1, params.steps | 0); const dt = 1; const m = params.m ?? 0.5; const alpha = params.diffusion ?? 0.04; const seed = params.seed | 0; const eps = 1e-4; let sumL = 0; for (let i = 0; i < adjList.length; i++) sumL += neighborDist[i]; const meanEdge = sumL / Math.max(1, adjList.length); const invMean = 1 / meanEdge; const U = new Float32Array(N); const h = new Float64Array(N); for (let r = 0; r < N; r++) { if (!r_land[r]) continue; U[r] = rateMax > 0 ? r_rate[r] / rateMax : 0; // A little initial relief, scaled by the rate, only so the first routing has something // to bite on. The solve produces the relief; starting from ridges means tearing them down. h[r] = 0.05 * U[r] * (0.75 + 0.5 * hash01(r, seed)) + 1e-3 * hash01(r, seed + 1); } const receiver = new Int32Array(N); const recvLen = new Float32Array(N); const donorOff = new Int32Array(N + 1); const donorList = new Int32Array(N); const cursor = new Int32Array(N); const stack = new Int32Array(N); const area = new Float32Array(N); const closed = new Uint8Array(N); const tmp = new Float64Array(N); const heap = new RegionHeap(N); function flood(step) { closed.fill(0); heap.clear(); for (let r = 0; r < N; r++) { if (!r_land[r]) { closed[r] = 1; heap.push(r, h[r]); } } while (heap.size > 0) { const c = heap.pop(); const hc = h[c]; for (let j = adjOffset[c], jEnd = adjOffset[c + 1]; j < jEnd; j++) { const nb = adjList[j]; if (closed[nb]) continue; closed[nb] = 1; // The epsilon is scattered per cell and per step, so a filled flat has no // gradient the router could read as the flood's own traversal order. if (h[nb] <= hc) h[nb] = hc + eps * (0.5 + hash01(nb, step * 7919 + 13)); heap.push(nb, h[nb]); } } } function receivers() { for (let r = 0; r < N; r++) { if (!r_land[r]) { receiver[r] = r; recvLen[r] = meanEdge; continue; } const hr = h[r]; let best = -1, bestS = 0, bestJ = -1; for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) { const nb = adjList[j]; const drop = hr - h[nb]; if (drop <= 0) continue; const s = drop / neighborDist[j]; if (s > bestS) { bestS = s; best = nb; bestJ = j; } } if (best < 0) { receiver[r] = r; recvLen[r] = meanEdge; } else { receiver[r] = best; recvLen[r] = neighborDist[bestJ]; } } } function buildStack() { donorOff.fill(0); for (let i = 0; i < N; i++) { const r = receiver[i]; if (r !== i) donorOff[r + 1]++; } for (let i = 0; i < N; i++) donorOff[i + 1] += donorOff[i]; cursor.set(donorOff.subarray(0, N)); for (let i = 0; i < N; i++) { const r = receiver[i]; if (r !== i) donorList[cursor[r]++] = i; } let tail = 0; for (let i = 0; i < N; i++) if (receiver[i] === i) stack[tail++] = i; for (let read = 0; read < tail; read++) { const c = stack[read]; for (let d = donorOff[c], dEnd = donorOff[c + 1]; d < dEnd; d++) stack[tail++] = donorList[d]; } return tail; } function accumulate(len) { area.fill(1); for (let k = len - 1; k >= 0; k--) { const i = stack[k]; const r = receiver[i]; if (r !== i) area[r] += area[i]; } } function update(len) { for (let k = 0; k < len; k++) { const i = stack[k]; if (!r_land[i]) continue; const r = receiver[i]; if (r === i) { h[i] += dt * U[i]; continue; } const L = recvLen[i] * invMean; const f = r_k[i] * dt * Math.pow(area[i], m) / L; const hr = h[r]; let next = (h[i] + dt * U[i] + f * hr) / (1 + f); if (next < hr) next = hr; h[i] = next; } } function diffuse() { if (alpha <= 0) return; for (let r = 0; r < N; r++) { if (!r_land[r]) continue; let sum = 0, cnt = 0; for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) { sum += h[adjList[j]]; cnt++; } tmp[r] = cnt > 0 ? h[r] + alpha * (sum / cnt - h[r]) : h[r]; } for (let r = 0; r < N; r++) if (r_land[r]) h[r] = tmp[r]; } const t0 = (typeof performance !== 'undefined') ? performance.now() : Date.now(); let stackLen = 0; const report = Math.max(1, Math.floor(steps / 20)); for (let step = 0; step < steps; step++) { flood(step); receivers(); stackLen = buildStack(); accumulate(stackLen); update(stackLen); diffuse(); if (onProgress && (step % report === 0 || step === steps - 1)) onProgress(step + 1, steps); } // One last fill and routing, so area and receiver describe the surface that is returned. flood(steps); receivers(); stackLen = buildStack(); accumulate(stackLen); const basin = new Int32Array(N); for (let k = 0; k < stackLen; k++) { const i = stack[k]; const r = receiver[i]; basin[i] = r === i ? i : basin[r]; } const t1 = (typeof performance !== 'undefined') ? performance.now() : Date.now(); return { h, area, receiver, recvLen, basin, meanEdge, solveMs: t1 - t0 }; } // ─── Scaling to Orogen's elevation ──────────────────────────────── // Inverse of elevToHeightKm on land: a table over 0..6 km, built once. let _invTable = null; const INV_BINS = 6000; function invHeightKm(km) { if (!_invTable) { const table = new Float32Array(INV_BINS + 1); let t = 0; const dtStep = 1 / 65536; for (let b = 0; b <= INV_BINS; b++) { const target = 6 * b / INV_BINS; while (t < 1 && elevToHeightKm(t) < target) t += dtStep; table[b] = Math.min(1, t); } _invTable = table; } if (km <= 0) return 0; if (km >= 6) return 1; const x = km / 6 * INV_BINS; const b = Math.floor(x); const f = x - b; return _invTable[b] * (1 - f) + _invTable[Math.min(INV_BINS, b + 1)] * f; } /** * Turn the solved units into Orogen's elevation field, plus the derived layers. * Land is scaled so its 99.5th percentile stands at peakKm; sea takes the class depth, * scaled so the deepest class sits at oceanDepthKm, with a short ramp down from the shore. */ export function toElevation(mesh, solved, r_land, r_class, legend, hop, opts) { const cls = legend.classes; const N = mesh.numRegions; const { h, area, receiver, recvLen, basin, meanEdge } = solved; const avgEdgeKm = opts.avgEdgeKm; const landVals = []; for (let r = 0; r < N; r++) if (r_land[r]) landVals.push(h[r]); landVals.sort((a, b) => a - b); const p995 = landVals.length ? landVals[Math.min(landVals.length - 1, Math.floor(landVals.length * 0.995))] : 0; const kmScale = p995 > 0 ? opts.peakKm / p995 : 0; let deepest = 0; for (const c of cls) if (c.sea && c.depthM > deepest) deepest = c.depthM; const depthScale = deepest > 0 ? (opts.oceanDepthKm * 1000) / deepest : 0; const r_elevation = new Float32Array(N); const slopeDeg = new Float32Array(N); const flowLog = new Float32Array(N); const basinOut = new Int32Array(N); let maxKm = 0; for (let r = 0; r < N; r++) { if (r_land[r]) { let km = h[r] * kmScale; if (km > 6) km = 6; if (km > maxKm) maxKm = km; let e = invHeightKm(km); if (e < 0.002) e = 0.002; r_elevation[r] = e; const rec = receiver[r]; if (rec !== r) { const dz = (h[r] - h[rec]) * kmScale; const dx = recvLen[r] / meanEdge * avgEdgeKm; slopeDeg[r] = Math.atan2(Math.max(0, dz), Math.max(1e-6, dx)) * 180 / Math.PI; } flowLog[r] = Math.log10(Math.max(1, area[r])); basinOut[r] = basin[r]; } else { const c = cls[r_class[r]]; let depthKm = c.depthM / 1000 * depthScale; const f = Math.min(1, Math.max(0, (hop[r] - 0.5) / 2)); depthKm *= f; if (depthKm < 0.005) depthKm = 0.005; let e = -depthKm / 10; if (e < -0.5) e = -0.5; r_elevation[r] = e; slopeDeg[r] = -1; flowLog[r] = -1; basinOut[r] = -1; } } return { r_elevation, slopeDeg, flowLog, basin: basinOut, kmScale, p995, maxKm }; }