Files
UnrealPrototyping/Tools/Orogen/js/painted.js
T
2026-09-25 17:02:24 +03:00

925 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 };
}