This commit is contained in:
Rainer Leit
2026-09-25 17:02:24 +03:00
parent cc43ed8dc8
commit 9597629951
2149 changed files with 460234 additions and 1770 deletions
+84
View File
@@ -0,0 +1,84 @@
// What a legend's numbers make, before anything is solved: the two angles per class that `terrain plan`
// prints, ported from Tools/Terrain internal/planet/plan.go so the class table here says the same thing.
//
// Steady state for the stream-power law is S = U/(K·A^m). With channels allowed down to a single cell, A at a
// drainage divide is one cell squared, so for n = 1 the uplift rate alone fixes the hillslope angle at the
// divide - the most useful number in the whole legend, because it decides whether the ground is shaped by
// rivers or by landsliding. But almost none of a map is divide: slope falls away downstream, and the median
// over a class comes out at about a third of the divide angle in tangent. That ratio was measured on the Go
// tool's 8 m grid over a factor of twenty in rate (0.34, 0.33, 0.33, 0.32), and it is the reason there are
// two columns. An author who reads the divide angle as the landscape sets every rate two or three times too
// hot.
//
// Pure functions of the legend and the bake's constants; no mesh, no DOM. The constants are the geology
// grid's (cell size, K, m, angle of repose), which is what the numbers are *about*: the ground the bake makes,
// not the globe here, whose relief is a scale.
export const SOLVE_DEFAULTS = { k: 5e-5, m: 0.5, cellM: 8, talusDeg: 35 };
const RAD = Math.PI / 180;
/** Hillslope angle at a divide, in degrees, for a rate in mm/yr and an erodibility multiplier on K. */
export function divideAngleDeg(rateMmYr, kMult, solve) {
const s = solve || SOLVE_DEFAULTS;
const k = (s.k || SOLVE_DEFAULTS.k) * (kMult > 0 ? kMult : 1);
const cellM = s.cellM || SOLVE_DEFAULTS.cellM;
const m = (s.m === undefined || s.m === null) ? SOLVE_DEFAULTS.m : s.m;
if (k <= 0 || cellM <= 0) return 0;
const slope = (rateMmYr / 1000) / (k * Math.pow(cellM * cellM, m));
return Math.atan(slope) / RAD;
}
// Fractions of the *tangent*, not of the angle, because the law is about slope.
const TYPICAL_MEDIAN_FRAC = 0.33;
const TYPICAL_P90_FRAC = 0.45;
/** The median and 90th-percentile slope over a class whose divide angle is `deg`. */
export function typicalFromDivide(deg) {
const t = Math.tan(deg * RAD);
return { median: Math.atan(t * TYPICAL_MEDIAN_FRAC) / RAD, p90: Math.atan(t * TYPICAL_P90_FRAC) / RAD };
}
/** The ground a median slope reads as. Boundaries are angles, not rates, deliberately. */
export function readsAs(deg) {
if (deg < 3) return 'plain';
if (deg < 8) return 'rolling';
if (deg < 16) return 'hill country';
if (deg < 28) return 'mountain';
return 'alpine';
}
/** The rate, in mm/yr, at which a divide at k = 1 reaches the angle of repose; above it the clamp shapes the ground. */
export function clampCeilingMmYr(solve) {
const s = solve || SOLVE_DEFAULTS;
const k = s.k || SOLVE_DEFAULTS.k;
const cellM = s.cellM || SOLVE_DEFAULTS.cellM;
const m = (s.m === undefined || s.m === null) ? SOLVE_DEFAULTS.m : s.m;
const talus = s.talusDeg || SOLVE_DEFAULTS.talusDeg;
return Math.tan(talus * RAD) * k * Math.pow(cellM * cellM, m) * 1000;
}
/**
* Every angle the plan prints for one parsed class (see painted.js parseLegend): the divide, the typical
* median and P90, what it reads as, whether the divide is past the angle of repose, and the same for the
* massif floor when the class has one. Null for a class that is not land.
*/
export function classAngles(c, solve) {
if (!c || c.sea || c.stroke || c.derived) return null;
const s = solve || SOLVE_DEFAULTS;
const talus = s.talusDeg || SOLVE_DEFAULTS.talusDeg;
const divide = divideAngleDeg(c.upliftMmYr, c.kMult, s);
const typ = typicalFromDivide(divide);
const out = {
divide, median: typ.median, p90: typ.p90,
readsAs: readsAs(typ.median),
clamped: divide >= talus,
floor: null,
};
if (c.massif && c.massif.fraction > 0) {
const fd = divideAngleDeg(c.massif.floorMmYr, c.kMult, s);
const ft = typicalFromDivide(fd);
out.floor = { rateMmYr: c.massif.floorMmYr, divide: fd, median: ft.median, readsAs: readsAs(ft.median), fraction: c.massif.fraction };
}
return out;
}