Tooling
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
// Shared climate utilities: smoothing, ITCZ lookup, and percentile selection.
|
||||
|
||||
// ── Laplacian smoothing ──────────────────────────────────────────────────────
|
||||
|
||||
export function smoothField(mesh, field, passes) {
|
||||
const { adjOffset, adjList, numRegions } = mesh;
|
||||
const tmp = new Float32Array(numRegions);
|
||||
let src = field, dst = tmp;
|
||||
|
||||
for (let pass = 0; pass < passes; pass++) {
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
let sum = src[r];
|
||||
let count = 1;
|
||||
const end = adjOffset[r + 1];
|
||||
for (let ni = adjOffset[r]; ni < end; ni++) {
|
||||
sum += src[adjList[ni]];
|
||||
count++;
|
||||
}
|
||||
dst[r] = sum / count;
|
||||
}
|
||||
const swap = src; src = dst; dst = swap;
|
||||
}
|
||||
// If result ended up in tmp, copy back to field
|
||||
if (src !== field) field.set(src);
|
||||
}
|
||||
|
||||
// ── ITCZ latitude lookup (linear interpolation with wrapping) ────────────────
|
||||
|
||||
export function makeItczLookup(itczLons, itczLats) {
|
||||
const n = itczLons.length;
|
||||
const step = (2 * Math.PI) / n;
|
||||
const lonStart = -Math.PI + step * 0.5;
|
||||
|
||||
return function (lon) {
|
||||
let fi = (lon - lonStart) / step;
|
||||
fi = ((fi % n) + n) % n;
|
||||
const i0 = Math.floor(fi);
|
||||
const i1 = (i0 + 1) % n;
|
||||
const frac = fi - i0;
|
||||
return itczLats[i0] * (1 - frac) + itczLats[i1] * frac;
|
||||
};
|
||||
}
|
||||
|
||||
// ── Floyd-Rivest selection (O(N) expected percentile) ────────────────────────
|
||||
|
||||
function floydRivest(arr, left, right, k) {
|
||||
while (right > left) {
|
||||
if (right - left > 600) {
|
||||
const n = right - left + 1;
|
||||
const i = k - left + 1;
|
||||
const z = Math.log(n);
|
||||
const s = 0.5 * Math.exp(2 * z / 3);
|
||||
const sd = 0.5 * Math.sqrt(z * s * (n - s) / n) * (i - n / 2 < 0 ? -1 : 1);
|
||||
const newLeft = Math.max(left, Math.floor(k - i * s / n + sd));
|
||||
const newRight = Math.min(right, Math.floor(k + (n - i) * s / n + sd));
|
||||
floydRivest(arr, newLeft, newRight, k);
|
||||
}
|
||||
|
||||
const t = arr[k];
|
||||
if (t !== t) return; // NaN pivot — cannot partition, bail out
|
||||
let i = left;
|
||||
let j = right;
|
||||
|
||||
arr[k] = arr[left];
|
||||
arr[left] = t;
|
||||
|
||||
if (arr[right] > t) {
|
||||
arr[left] = arr[right];
|
||||
arr[right] = t;
|
||||
}
|
||||
|
||||
while (i < j) {
|
||||
const tmp = arr[i];
|
||||
arr[i] = arr[j];
|
||||
arr[j] = tmp;
|
||||
i++;
|
||||
j--;
|
||||
while (arr[i] < t) i++;
|
||||
while (arr[j] > t) j--;
|
||||
}
|
||||
|
||||
if (arr[left] === t) {
|
||||
const tmp = arr[left];
|
||||
arr[left] = arr[j];
|
||||
arr[j] = tmp;
|
||||
} else {
|
||||
j++;
|
||||
const tmp = arr[j];
|
||||
arr[j] = arr[right];
|
||||
arr[right] = tmp;
|
||||
}
|
||||
|
||||
if (j <= k) left = j + 1;
|
||||
if (k <= j) right = j - 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the p-th percentile of a numeric array in O(N) expected time.
|
||||
* Returns the value at index floor(n * p) of the sorted order.
|
||||
* Makes a copy so the input is not mutated. Returns 1 if the result is 0.
|
||||
*/
|
||||
export function percentile(arr, p) {
|
||||
const n = arr.length;
|
||||
if (n === 0) return 1;
|
||||
const work = new Float32Array(arr);
|
||||
const k = Math.floor(n * p);
|
||||
floydRivest(work, 0, n - 1, k);
|
||||
return work[k] || 1;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// Coarse reference grid for resolution-independent plate boundaries.
|
||||
// Generates plates on a fixed ~20K-region mesh, then projects onto any
|
||||
// high-res mesh with FBM noise perturbation for fractal boundaries.
|
||||
|
||||
import { makeRng } from './rng.js';
|
||||
import { buildSphere } from './sphere-mesh.js';
|
||||
import { SimplexNoise } from './simplex-noise.js';
|
||||
import { generatePlates } from './plates.js';
|
||||
import { assignOceanLand } from './ocean-land.js';
|
||||
import {
|
||||
N_COARSE, COARSE_JITTER, COARSE_PERTURB_BASE, COARSE_PERTURB_LOW_T,
|
||||
COARSE_FBM_BASE_FREQ, COARSE_FBM_OCTAVES, COARSE_FBM_DECAY, COARSE_FBM_FREQ_MULT,
|
||||
PLATE_LOW_PLATE_T_HIGH, PLATE_LOW_PLATE_T_RANGE,
|
||||
} from './terrain-config.js';
|
||||
|
||||
/**
|
||||
* Generate plates and ocean/land on a fixed coarse reference mesh.
|
||||
* Uses isolated RNG so it doesn't affect the main mesh's random stream.
|
||||
* Jitter is fixed so plate shapes don't change when the user adjusts irregularity.
|
||||
*/
|
||||
export function generateCoarsePlates(seed, numPlates, numContinents, continentSizeVariety = 0, landCoverage = 0.3) {
|
||||
const coarseRng = makeRng(seed + 137);
|
||||
const { mesh: coarseMesh, r_xyz: coarse_xyz } = buildSphere(N_COARSE, COARSE_JITTER, coarseRng);
|
||||
|
||||
const { r_plate: coarse_r_plate, plateSeeds: coarsePlateSeeds, plateVec: coarsePlateVec } =
|
||||
generatePlates(coarseMesh, coarse_xyz, numPlates, seed);
|
||||
|
||||
const coarsePlateIsOcean = assignOceanLand(
|
||||
coarseMesh, coarse_r_plate, coarsePlateSeeds, coarse_xyz, seed, numContinents, continentSizeVariety, landCoverage
|
||||
);
|
||||
|
||||
return {
|
||||
coarseMesh,
|
||||
coarse_xyz,
|
||||
coarse_r_plate,
|
||||
coarsePlateSeeds,
|
||||
coarsePlateVec,
|
||||
coarsePlateIsOcean,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Project coarse plate assignments onto a high-res mesh via nearest-neighbor
|
||||
* with FBM noise perturbation for fractal plate boundaries.
|
||||
*
|
||||
* Each hi-res point is shifted by multi-octave simplex noise before the
|
||||
* nearest-neighbor lookup, which wobbles the plate boundary by ~2 coarse
|
||||
* cell widths with fractal detail at multiple scales.
|
||||
*
|
||||
* Uses adjacency-walk on the coarse mesh with warm-starting for O(1)
|
||||
* amortized cost per region.
|
||||
*/
|
||||
export function projectCoarsePlates(mesh, r_xyz, coarseMesh, coarse_xyz, coarse_r_plate, seed, numPlates) {
|
||||
const N = mesh.numRegions;
|
||||
const r_plate = new Int32Array(N);
|
||||
const { adjOffset: cOff, adjList: cAdj } = coarseMesh;
|
||||
|
||||
// FBM noise for fractal boundary perturbation
|
||||
const noise = new SimplexNoise(seed + 999);
|
||||
const coarseEdgeRad = Math.PI / Math.sqrt(coarseMesh.numRegions);
|
||||
const lowPlateT = numPlates != null ? Math.max(0, Math.min(1, (PLATE_LOW_PLATE_T_HIGH - numPlates) / PLATE_LOW_PLATE_T_RANGE)) : 0;
|
||||
const perturbAmp = coarseEdgeRad * (COARSE_PERTURB_BASE + COARSE_PERTURB_LOW_T * lowPlateT); // 1.5 → 2.5 coarse cells
|
||||
const BASE_FREQ = COARSE_FBM_BASE_FREQ; // ~8 features per sphere diameter → ~16 around equator
|
||||
|
||||
const NC = coarseMesh.numRegions;
|
||||
const MAX_WALK = Math.ceil(Math.sqrt(NC)); // safety cap for greedy walk
|
||||
let cur = 0; // current best coarse region — warm-started across iterations
|
||||
|
||||
for (let r = 0; r < N; r++) {
|
||||
const ox = r_xyz[3 * r], oy = r_xyz[3 * r + 1], oz = r_xyz[3 * r + 2];
|
||||
|
||||
// FBM perturbation: shift lookup point for fractal boundaries
|
||||
let dx = 0, dy = 0, dz = 0;
|
||||
let amp = perturbAmp, freq = BASE_FREQ;
|
||||
for (let oct = 0; oct < COARSE_FBM_OCTAVES; oct++) {
|
||||
dx += noise.noise3D(ox * freq, oy * freq, oz * freq) * amp;
|
||||
dy += noise.noise3D(ox * freq + 100, oy * freq + 100, oz * freq + 100) * amp;
|
||||
dz += noise.noise3D(ox * freq + 200, oy * freq + 200, oz * freq + 200) * amp;
|
||||
amp *= COARSE_FBM_DECAY;
|
||||
freq *= COARSE_FBM_FREQ_MULT;
|
||||
}
|
||||
|
||||
// Project perturbed point back onto unit sphere
|
||||
let px = ox + dx, py = oy + dy, pz = oz + dz;
|
||||
const len = Math.sqrt(px * px + py * py + pz * pz) || 1;
|
||||
px /= len; py /= len; pz /= len;
|
||||
|
||||
// Greedy walk: find nearest coarse region to the perturbed point
|
||||
let bestDot = px * coarse_xyz[3 * cur] + py * coarse_xyz[3 * cur + 1] + pz * coarse_xyz[3 * cur + 2];
|
||||
|
||||
let improved = true;
|
||||
let steps = 0;
|
||||
while (improved && steps < MAX_WALK) {
|
||||
improved = false;
|
||||
steps++;
|
||||
for (let i = cOff[cur], iEnd = cOff[cur + 1]; i < iEnd; i++) {
|
||||
const nb = cAdj[i];
|
||||
const d = px * coarse_xyz[3 * nb] + py * coarse_xyz[3 * nb + 1] + pz * coarse_xyz[3 * nb + 2];
|
||||
if (d > bestDot) {
|
||||
bestDot = d;
|
||||
cur = nb;
|
||||
improved = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if greedy walk hit the step limit, brute-force search
|
||||
if (steps >= MAX_WALK) {
|
||||
for (let c = 0; c < NC; c++) {
|
||||
const d = px * coarse_xyz[3 * c] + py * coarse_xyz[3 * c + 1] + pz * coarse_xyz[3 * c + 2];
|
||||
if (d > bestDot) { bestDot = d; cur = c; }
|
||||
}
|
||||
}
|
||||
|
||||
r_plate[r] = coarse_r_plate[cur];
|
||||
}
|
||||
|
||||
return r_plate;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// Elevation → RGB colour mapping.
|
||||
|
||||
// Convert raw mesh elevation (nonlinear, 0-~1 for land) to physical height
|
||||
// in kilometres. Hybrid S-curve: quartic start gives extensive flatlands,
|
||||
// steepest rise around t≈0.75, derivative→0 at top so peaks compress.
|
||||
// Ocean (elev < 0) is mapped with a linear scale (~5 km at -0.5).
|
||||
export function elevToHeightKm(elev) {
|
||||
if (elev <= 0) return elev * 10; // ocean: -0.5 → -5 km
|
||||
const t = Math.min(elev, 1);
|
||||
const t2 = t * t;
|
||||
return 6 * t2 * t2 * (5 - 4 * t); // 0→0, 0.25→0.09, 0.5→1.13, 0.75→3.80, 1.0→6
|
||||
}
|
||||
|
||||
// Biome base colors indexed by Köppen class ID (satellite-view palette).
|
||||
// 0=Ocean delegated, 1-30 = land biomes.
|
||||
const BIOME_COLORS = [
|
||||
null, // 0 Ocean — handled separately
|
||||
[0.05, 0.30, 0.05], // 1 Af Tropical rainforest — deep emerald
|
||||
[0.08, 0.33, 0.07], // 2 Am Tropical monsoon — dense green
|
||||
[0.42, 0.50, 0.18], // 3 Aw Tropical savanna — yellow-green
|
||||
[0.82, 0.72, 0.50], // 4 BWh Hot desert — sandy tan
|
||||
[0.60, 0.55, 0.48], // 5 BWk Cold desert — gray-brown
|
||||
[0.72, 0.62, 0.30], // 6 BSh Hot steppe — dry gold
|
||||
[0.55, 0.52, 0.32], // 7 BSk Cold steppe — muted olive-tan
|
||||
[0.18, 0.42, 0.12], // 8 Cfa Humid subtropical — mid green
|
||||
[0.12, 0.38, 0.10], // 9 Cfb Oceanic — rich green
|
||||
[0.10, 0.28, 0.10], // 10 Cfc Subpolar oceanic — dark muted green
|
||||
[0.45, 0.48, 0.22], // 11 Csa Hot-summer Mediterranean — khaki-green
|
||||
[0.40, 0.45, 0.20], // 12 Csb Warm-summer Mediterranean — chaparral
|
||||
[0.35, 0.40, 0.20], // 13 Csc Cold-summer Mediterranean — darker khaki
|
||||
[0.20, 0.44, 0.14], // 14 Cwa Humid subtropical monsoon — mid green
|
||||
[0.15, 0.40, 0.12], // 15 Cwb Subtropical highland — green
|
||||
[0.12, 0.32, 0.10], // 16 Cwc Cold subtropical highland — dark green
|
||||
[0.12, 0.36, 0.08], // 17 Dfa Hot-summer continental — forest green
|
||||
[0.10, 0.32, 0.08], // 18 Dfb Warm-summer continental — forest green
|
||||
[0.06, 0.22, 0.08], // 19 Dfc Subarctic — dark spruce green
|
||||
[0.05, 0.18, 0.07], // 20 Dfd Extremely cold subarctic — very dark
|
||||
[0.38, 0.38, 0.18], // 21 Dsa Hot-summer continental dry — olive-brown
|
||||
[0.35, 0.35, 0.17], // 22 Dsb Warm-summer continental dry — olive-brown
|
||||
[0.08, 0.22, 0.08], // 23 Dsc Subarctic dry summer — dark green
|
||||
[0.06, 0.18, 0.07], // 24 Dsd Extremely cold subarctic dry — very dark
|
||||
[0.14, 0.36, 0.10], // 25 Dwa Hot-summer continental monsoon — forest green
|
||||
[0.12, 0.32, 0.09], // 26 Dwb Warm-summer continental monsoon
|
||||
[0.07, 0.22, 0.08], // 27 Dwc Subarctic monsoon — dark spruce
|
||||
[0.05, 0.18, 0.07], // 28 Dwd Extremely cold subarctic monsoon
|
||||
[0.35, 0.32, 0.22], // 29 ET Tundra — earthy brown (sparse moss/lichen on rock)
|
||||
[0.78, 0.80, 0.84], // 30 EF Ice cap — blue-tinted white
|
||||
];
|
||||
|
||||
// Rocky/alpine mountain color for high-elevation blending.
|
||||
const ROCK_COLOR = [0.42, 0.38, 0.32];
|
||||
|
||||
// Altitude thresholds (km) by Köppen group:
|
||||
// [alpine line, snow line]
|
||||
// Alpine line: vegetation gives way to rocky alpine terrain.
|
||||
// Snow line: permanent snow begins.
|
||||
function altitudeThresholds(classId) {
|
||||
if (classId <= 0) return [0, 0]; // Ocean
|
||||
if (classId <= 3) return [3.5, 5.5]; // Tropical (A)
|
||||
if (classId <= 7) return [3.0, 5.0]; // Arid (B)
|
||||
if (classId <= 16) return [2.0, 3.5]; // Temperate (C)
|
||||
if (classId <= 18 || classId === 21 || classId === 22 ||
|
||||
classId === 25 || classId === 26) return [1.5, 3.0]; // Continental humid (D*a, D*b)
|
||||
if (classId <= 28) return [0.8, 2.0]; // Subarctic (D*c, D*d)
|
||||
if (classId === 29) return [0.4, 1.5]; // Tundra (ET) — rocky higher up, snow only at peaks
|
||||
return [0, 0.5]; // Ice cap (EF)
|
||||
}
|
||||
|
||||
// Satellite-view biome color: realistic land colors based on Köppen class
|
||||
// and elevation, with ocean delegated to the standard ocean palette.
|
||||
export function biomeColor(koppenId, elevation) {
|
||||
// Ocean
|
||||
if (koppenId === 0 || elevation <= 0) return elevationToColor(elevation);
|
||||
|
||||
const base = BIOME_COLORS[koppenId] || [0.30, 0.50, 0.20];
|
||||
const hKm = elevToHeightKm(elevation);
|
||||
const [alpineLine, snowLine] = altitudeThresholds(koppenId);
|
||||
|
||||
let r = base[0], g = base[1], b = base[2];
|
||||
|
||||
// Low-elevation subtle darkening for depth (0-200m)
|
||||
if (hKm < 0.2) {
|
||||
const dark = 0.93 + 0.07 * (hKm / 0.2);
|
||||
r *= dark; g *= dark; b *= dark;
|
||||
}
|
||||
|
||||
// Mid-elevation: gentle darkening to show terrain relief (200m to alpine line)
|
||||
if (alpineLine > 0 && hKm > 0.2 && hKm < alpineLine) {
|
||||
const t = (hKm - 0.2) / (alpineLine - 0.2);
|
||||
const darken = 1.0 - t * 0.15; // up to 15% darker at alpine line
|
||||
r *= darken; g *= darken; b *= darken;
|
||||
}
|
||||
|
||||
// Alpine zone: blend toward rocky brown-gray above the tree/vegetation line
|
||||
if (alpineLine > 0 && hKm > alpineLine) {
|
||||
const rockZone = snowLine > alpineLine ? snowLine - alpineLine : 2.0;
|
||||
const rockT = Math.min(1, (hKm - alpineLine) / rockZone);
|
||||
const s = rockT * rockT; // ease-in for gradual transition
|
||||
r = r + (ROCK_COLOR[0] - r) * s;
|
||||
g = g + (ROCK_COLOR[1] - g) * s;
|
||||
b = b + (ROCK_COLOR[2] - b) * s;
|
||||
}
|
||||
|
||||
// Snow zone: blend toward white above the snow line
|
||||
if (snowLine > 0 && hKm > snowLine) {
|
||||
const snowT = Math.min(1, (hKm - snowLine) / 2.5);
|
||||
const s = snowT * snowT; // ease-in for gradual snow buildup
|
||||
r = r + (0.92 - r) * s;
|
||||
g = g + (0.93 - g) * s;
|
||||
b = b + (0.96 - b) * s;
|
||||
}
|
||||
|
||||
return [r, g, b];
|
||||
}
|
||||
|
||||
export function elevationToColor(e) {
|
||||
if (e < -0.50) return [0.04, 0.06, 0.30];
|
||||
if (e < -0.10) { const t=(e+0.50)/0.40; return [0.04+t*0.07,0.06+t*0.14,0.30+t*0.18]; }
|
||||
if (e < 0.00) { const t=(e+0.10)/0.10; return [0.11+t*0.19,0.20+t*0.22,0.48+t*0.12]; }
|
||||
if (e < 0.03) { const t=e/0.03; return [0.72+t*0.08,0.68-t*0.02,0.46-t*0.10]; }
|
||||
if (e < 0.25) { const t=(e-0.03)/0.22; return [0.20-t*0.06,0.54-t*0.12,0.12+t*0.08]; }
|
||||
if (e < 0.50) { const t=(e-0.25)/0.25; return [0.14+t*0.30,0.42-t*0.14,0.20-t*0.06]; }
|
||||
if (e < 0.75) { const t=(e-0.50)/0.25; return [0.44+t*0.16,0.28+t*0.12,0.14+t*0.18]; }
|
||||
{ const t=Math.min(1,(e-0.75)/0.20); return [0.60+t*0.35,0.40+t*0.50,0.32+t*0.60]; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Non-linear detail slider mapping (power curve, p=5).
|
||||
// Slider position 0–1000 maps to detail 2,000–2,560,000.
|
||||
// Gives generous control in the normal range; the old max (640K) sits at ~76%.
|
||||
|
||||
const MIN = 5000, MAX = 2560000, RANGE = MAX - MIN, STEPS = 1000, P = 5;
|
||||
|
||||
export function detailFromSlider(pos) {
|
||||
const t = pos / STEPS;
|
||||
return Math.round((MIN + RANGE * Math.pow(t, P)) / 1000) * 1000;
|
||||
}
|
||||
|
||||
export function sliderFromDetail(n) {
|
||||
return Math.round(STEPS * Math.pow(Math.max(0, n - MIN) / RANGE, 1 / P));
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
// Plate interaction: hover info + ctrl-click to toggle land/sea.
|
||||
// Uses analytical ray-sphere intersection instead of Three.js mesh raycasting
|
||||
// for O(N) dot-product lookups rather than O(N) triangle intersection tests.
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { canvas, camera, mapCamera } from './scene.js';
|
||||
import { state } from './state.js';
|
||||
import { updateHoverHighlight, updateMapHoverHighlight, updatePendingHighlight, updateMapPendingHighlight } from './planet-mesh.js';
|
||||
import { KOPPEN_CLASSES } from './koppen.js';
|
||||
import { elevToHeightKm } from './color-map.js';
|
||||
|
||||
const raycaster = new THREE.Raycaster();
|
||||
const mouse = new THREE.Vector2();
|
||||
const _inverseMatrix = new THREE.Matrix4();
|
||||
const _localRay = new THREE.Ray();
|
||||
|
||||
/** Find nearest region to a unit-sphere direction (max dot product). */
|
||||
function findNearestRegion(nx, ny, nz) {
|
||||
const { mesh, r_xyz, r_plate } = state.curData;
|
||||
const N = mesh.numRegions;
|
||||
let bestDot = -2, bestR = -1;
|
||||
for (let r = 0; r < N; r++) {
|
||||
const dot = nx * r_xyz[3 * r] + ny * r_xyz[3 * r + 1] + nz * r_xyz[3 * r + 2];
|
||||
if (dot > bestDot) { bestDot = dot; bestR = r; }
|
||||
}
|
||||
if (bestR < 0) return null;
|
||||
return { region: bestR, plate: r_plate[bestR] };
|
||||
}
|
||||
|
||||
/** Globe view: analytical ray-sphere intersection → nearest region.
|
||||
* ~50-100x faster than Three.js mesh raycasting at high detail. */
|
||||
function getHitInfoGlobe(event) {
|
||||
if (!state.planetMesh) return null;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
raycaster.setFromCamera(mouse, camera);
|
||||
|
||||
// Transform ray into planet's local space (handles auto-rotation)
|
||||
_inverseMatrix.copy(state.planetMesh.matrixWorld).invert();
|
||||
_localRay.copy(raycaster.ray).applyMatrix4(_inverseMatrix);
|
||||
|
||||
const ox = _localRay.origin.x, oy = _localRay.origin.y, oz = _localRay.origin.z;
|
||||
const dx = _localRay.direction.x, dy = _localRay.direction.y, dz = _localRay.direction.z;
|
||||
|
||||
// Ray-sphere: |O + tD|² = R² (a=1 since direction is normalised)
|
||||
const R = 1.08; // slightly above max elevation displacement
|
||||
const b = 2 * (ox * dx + oy * dy + oz * dz);
|
||||
const c = ox * ox + oy * oy + oz * oz - R * R;
|
||||
const disc = b * b - 4 * c;
|
||||
if (disc < 0) return null;
|
||||
|
||||
const t = (-b - Math.sqrt(disc)) * 0.5;
|
||||
if (t < 0) return null;
|
||||
|
||||
// Hit point → normalise to unit direction
|
||||
const hx = ox + t * dx, hy = oy + t * dy, hz = oz + t * dz;
|
||||
const len = Math.sqrt(hx * hx + hy * hy + hz * hz) || 1;
|
||||
return findNearestRegion(hx / len, hy / len, hz / len);
|
||||
}
|
||||
|
||||
/** Map view: unproject mouse → map plane → inverse equirect → nearest region. */
|
||||
function getHitInfoMap(event) {
|
||||
if (!state.mapMesh) return null;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
|
||||
// Intersect ray with z=0 plane to get world coords on the map
|
||||
raycaster.setFromCamera(mouse, mapCamera);
|
||||
const o = raycaster.ray.origin, d = raycaster.ray.direction;
|
||||
if (Math.abs(d.z) < 1e-10) return null;
|
||||
const t = -o.z / d.z;
|
||||
const wx = o.x + t * d.x;
|
||||
const wy = o.y + t * d.y;
|
||||
|
||||
// Inverse equirectangular: map coords → lon/lat → unit sphere xyz
|
||||
const PI = Math.PI;
|
||||
const sx = 2 / PI;
|
||||
let lon = wx / sx + (state.mapCenterLon || 0);
|
||||
const lat = wy / sx;
|
||||
if (lat < -PI / 2 || lat > PI / 2) return null;
|
||||
// Wrap lon back to [-PI, PI]
|
||||
if (lon > PI) lon -= 2 * PI;
|
||||
else if (lon < -PI) lon += 2 * PI;
|
||||
|
||||
const cosLat = Math.cos(lat);
|
||||
return findNearestRegion(
|
||||
cosLat * Math.sin(lon),
|
||||
Math.sin(lat),
|
||||
cosLat * Math.cos(lon)
|
||||
);
|
||||
}
|
||||
|
||||
function getHitInfo(event) {
|
||||
if (!state.curData) return null;
|
||||
return state.mapMode ? getHitInfoMap(event) : getHitInfoGlobe(event);
|
||||
}
|
||||
|
||||
/** Build multi-line hover HTML for a region. */
|
||||
function buildHoverHTML(region, plate) {
|
||||
const d = state.curData;
|
||||
const isOcean = d.plateIsOcean.has(plate);
|
||||
const isPending = state.pendingToggles.has(plate);
|
||||
const dot = `<span style="color:${isOcean ? '#4af' : '#6b3'}">●</span>`;
|
||||
const action = state.isTouchDevice ? 'Tap' : 'Ctrl-click';
|
||||
const lines = [];
|
||||
|
||||
// Line 1: plate type + edit hint
|
||||
if (isPending) {
|
||||
const target = isOcean ? 'Land' : 'Ocean';
|
||||
lines.push(`${dot} <b>${isOcean ? 'Ocean' : 'Land'} → ${target}</b> <span style="color:#fa0">(pending)</span> · ${action} to undo`);
|
||||
} else {
|
||||
lines.push(`${dot} <b>${isOcean ? 'Ocean' : 'Land'}</b> plate · ${action} to ${isOcean ? 'raise land' : 'flood'}`);
|
||||
}
|
||||
|
||||
// Elevation
|
||||
const elev = d.r_elevation[region];
|
||||
const elevKm = elevToHeightKm(elev).toFixed(1);
|
||||
lines.push(`<span class="hi-label">Elev</span> ${elevKm} km`);
|
||||
|
||||
// Lat/Lon from r_xyz
|
||||
const x = d.r_xyz[3 * region];
|
||||
const y = d.r_xyz[3 * region + 1];
|
||||
const z = d.r_xyz[3 * region + 2];
|
||||
const lat = Math.asin(Math.max(-1, Math.min(1, y))) * (180 / Math.PI);
|
||||
const lon = Math.atan2(x, z) * (180 / Math.PI);
|
||||
const latStr = Math.abs(lat).toFixed(1) + '°' + (lat >= 0 ? 'N' : 'S');
|
||||
const lonStr = Math.abs(lon).toFixed(1) + '°' + (lon >= 0 ? 'E' : 'W');
|
||||
lines.push(`<span class="hi-label">Coord</span> ${latStr}, ${lonStr}`);
|
||||
|
||||
// Climate data (only if computed)
|
||||
if (state.climateComputed && d.r_temperature_summer) {
|
||||
const tS = -45 + Math.max(0, Math.min(1, d.r_temperature_summer[region])) * 90;
|
||||
const tW = -45 + Math.max(0, Math.min(1, d.r_temperature_winter[region])) * 90;
|
||||
if (elev <= 0) {
|
||||
// Ocean: show as SST
|
||||
lines.push(`<span class="hi-label">SST</span> ${tS.toFixed(0)}°C / ${tW.toFixed(0)}°C`);
|
||||
} else {
|
||||
lines.push(`<span class="hi-label">Temp</span> ${tS.toFixed(0)}°C / ${tW.toFixed(0)}°C`);
|
||||
|
||||
// Precipitation (land only)
|
||||
if (d.r_precip_summer) {
|
||||
const pS = (Math.max(0, Math.min(1, d.r_precip_summer[region])) * 1000).toFixed(0);
|
||||
const pW = (Math.max(0, Math.min(1, d.r_precip_winter[region])) * 1000).toFixed(0);
|
||||
lines.push(`<span class="hi-label">Precip</span> ${pS} / ${pW} mm`);
|
||||
}
|
||||
|
||||
// Köppen (land only)
|
||||
if (d.debugLayers && d.debugLayers.koppen) {
|
||||
const kIdx = d.debugLayers.koppen[region];
|
||||
const kc = KOPPEN_CLASSES[kIdx];
|
||||
if (kc && kc.code !== 'Ocean') {
|
||||
const [r, g, b] = kc.color;
|
||||
const hex = '#' + [r, g, b].map(v => Math.round(v * 255).toString(16).padStart(2, '0')).join('');
|
||||
lines.push(`<span class="hi-label">Clima</span> <span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:${hex};vertical-align:middle;margin-right:4px"></span>${kc.code} — ${kc.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('<br>');
|
||||
}
|
||||
|
||||
/** Set up hover and ctrl-click event listeners. */
|
||||
export function setupEditMode() {
|
||||
let downInfo = null;
|
||||
let orbiting = false;
|
||||
let lastHoverTime = 0;
|
||||
const HOVER_INTERVAL = 50; // ms — cap hover lookups
|
||||
|
||||
canvas.addEventListener('pointerdown', (e) => {
|
||||
if (!state.curData) return;
|
||||
const isEditTap = (e.button === 0 && e.ctrlKey) ||
|
||||
(e.button === 0 && state.isTouchDevice && state.editMode);
|
||||
if (isEditTap) {
|
||||
// Ctrl-click or mobile edit-mode tap: plate editing
|
||||
const hit = getHitInfo(e);
|
||||
if (!hit) return;
|
||||
downInfo = { x: e.clientX, y: e.clientY, plate: hit.plate };
|
||||
} else if (e.button === 0 || e.button === 2) {
|
||||
// Regular click/right-click: orbit or pan — skip hover raycasts
|
||||
orbiting = true;
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener('pointerup', (e) => {
|
||||
orbiting = false;
|
||||
if (!downInfo || !state.curData || e.button !== 0) { downInfo = null; return; }
|
||||
|
||||
const dx = e.clientX - downInfo.x;
|
||||
const dy = e.clientY - downInfo.y;
|
||||
|
||||
if (dx * dx + dy * dy < 36) {
|
||||
const pid = downInfo.plate;
|
||||
// Toggle pending: add if absent, remove if present (undo)
|
||||
if (state.pendingToggles.has(pid)) {
|
||||
state.pendingToggles.delete(pid);
|
||||
} else {
|
||||
state.pendingToggles.add(pid);
|
||||
}
|
||||
// Remove hover highlight first so pending tint applies to base colors.
|
||||
// Hover saves its backup from pre-pending colors; if we don't strip it,
|
||||
// the hover restore in updateHoverHighlight wipes out the pending tint.
|
||||
const savedHover = state.hoveredPlate;
|
||||
state.hoveredPlate = -1;
|
||||
if (state.mapMode) updateMapHoverHighlight();
|
||||
else updateHoverHighlight();
|
||||
state.hoveredPlate = savedHover;
|
||||
// Apply pending tint to the now-clean base colors
|
||||
updatePendingHighlight();
|
||||
updateMapPendingHighlight();
|
||||
// Re-apply hover on top of pending-tinted colors
|
||||
if (state.mapMode) updateMapHoverHighlight();
|
||||
else updateHoverHighlight();
|
||||
// Update hover text to reflect pending state
|
||||
const hoverEl = document.getElementById('hoverInfo');
|
||||
if (state.hoveredRegion >= 0 && state.curData) {
|
||||
hoverEl.innerHTML = buildHoverHTML(state.hoveredRegion, state.hoveredPlate);
|
||||
}
|
||||
// Notify main.js to show/hide rebuild button
|
||||
document.dispatchEvent(new CustomEvent('pending-edits-changed'));
|
||||
}
|
||||
downInfo = null;
|
||||
});
|
||||
|
||||
canvas.addEventListener('pointermove', (e) => {
|
||||
if (!state.curData) {
|
||||
if (state.hoveredPlate >= 0 || state.hoveredRegion >= 0) {
|
||||
state.hoveredPlate = -1;
|
||||
state.hoveredRegion = -1;
|
||||
document.getElementById('hoverInfo').style.display = 'none';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip while orbiting/panning — no hover lookup during drag
|
||||
if (orbiting) return;
|
||||
|
||||
// Throttle hover updates
|
||||
const now = performance.now();
|
||||
if (now - lastHoverTime < HOVER_INTERVAL) return;
|
||||
lastHoverTime = now;
|
||||
|
||||
const hit = getHitInfo(e);
|
||||
const newRegion = hit ? hit.region : -1;
|
||||
// Only highlight the plate when in edit mode (Ctrl held or mobile edit toggle)
|
||||
const inEditMode = e.ctrlKey || (state.isTouchDevice && state.editMode);
|
||||
const newPlate = (hit && inEditMode) ? hit.plate : -1;
|
||||
|
||||
// Update plate highlight only when plate changes
|
||||
if (newPlate !== state.hoveredPlate) {
|
||||
state.hoveredPlate = newPlate;
|
||||
if (state.mapMode) updateMapHoverHighlight();
|
||||
else updateHoverHighlight();
|
||||
}
|
||||
|
||||
// Update info text when region changes
|
||||
if (newRegion !== state.hoveredRegion) {
|
||||
state.hoveredRegion = newRegion;
|
||||
state.hoveredPlate = (hit && inEditMode) ? hit.plate : -1;
|
||||
const hoverEl = document.getElementById('hoverInfo');
|
||||
if (newRegion >= 0) {
|
||||
hoverEl.innerHTML = buildHoverHTML(newRegion, hit.plate);
|
||||
hoverEl.style.display = 'block';
|
||||
} else {
|
||||
hoverEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,991 @@
|
||||
// Planet generation — dispatches work to a Web Worker, falls back to
|
||||
// synchronous main-thread generation if module workers aren't supported.
|
||||
|
||||
import Delaunator from 'delaunator';
|
||||
import { setDelaunator, SphereMesh } from './sphere-mesh.js';
|
||||
import { computePlateColors, buildMesh } from './planet-mesh.js';
|
||||
import { state } from './state.js';
|
||||
import { detailFromSlider } from './detail-scale.js';
|
||||
import { computeOceanCurrents } from './ocean.js';
|
||||
import { computePrecipitation } from './precipitation.js';
|
||||
import { computeTemperature } from './temperature.js';
|
||||
import { classifyKoppen } from './koppen.js';
|
||||
|
||||
// Main thread still needs Delaunator for SphereMesh reconstruction
|
||||
setDelaunator(Delaunator);
|
||||
|
||||
// Read all slider values from the DOM into a params object
|
||||
function readSliders() {
|
||||
return {
|
||||
N: detailFromSlider(+document.getElementById('sN').value),
|
||||
P: +document.getElementById('sP').value,
|
||||
jitter: +document.getElementById('sJ').value,
|
||||
nMag: +document.getElementById('sNs').value,
|
||||
numContinents: +document.getElementById('sCn').value,
|
||||
terrainWarp: +document.getElementById('sTw').value,
|
||||
smoothing: +document.getElementById('sS').value,
|
||||
hydraulicErosion: +document.getElementById('sHEr').value,
|
||||
thermalErosion: +document.getElementById('sTEr').value,
|
||||
ridgeSharpening: +document.getElementById('sRs').value,
|
||||
glacialErosion: +document.getElementById('sGl').value,
|
||||
continentSizeVariety: +document.getElementById('sCsv').value,
|
||||
temperatureOffset: +document.getElementById('sTmp').value,
|
||||
precipitationOffset: +document.getElementById('sPrc').value,
|
||||
landCoverage: +document.getElementById('sLc').value,
|
||||
};
|
||||
}
|
||||
|
||||
// Read sliders with optional chaining (for import page where some sliders may not exist)
|
||||
function readSlidersOptional() {
|
||||
return {
|
||||
N: detailFromSlider(+document.getElementById('sN').value),
|
||||
jitter: +(document.getElementById('sJ')?.value ?? 0.75),
|
||||
terrainWarp: +(document.getElementById('sTw')?.value ?? 0),
|
||||
smoothing: +(document.getElementById('sS')?.value ?? 0),
|
||||
hydraulicErosion: +(document.getElementById('sHEr')?.value ?? 0),
|
||||
thermalErosion: +(document.getElementById('sTEr')?.value ?? 0),
|
||||
ridgeSharpening: +(document.getElementById('sRs')?.value ?? 0),
|
||||
glacialErosion: +(document.getElementById('sGl')?.value ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
// --- Worker setup ---
|
||||
let worker = null;
|
||||
let workerSupported = true;
|
||||
try {
|
||||
worker = new Worker(new URL('./planet-worker.js', import.meta.url), { type: 'module' });
|
||||
} catch (e) {
|
||||
console.warn('[World Orogen] Module workers not supported, falling back to main thread:', e);
|
||||
workerSupported = false;
|
||||
}
|
||||
|
||||
// Active callback state
|
||||
let _onProgress = null;
|
||||
let _onDone = null;
|
||||
let _t0 = 0;
|
||||
|
||||
function resetUI() {
|
||||
const btn = document.getElementById('generate');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Build New World';
|
||||
btn.classList.remove('generating', 'stale');
|
||||
}
|
||||
|
||||
function fail(err) {
|
||||
console.error('[World Orogen] Generation failed:', err);
|
||||
resetUI();
|
||||
if (_onProgress) _onProgress(0, '');
|
||||
}
|
||||
|
||||
// Reconstruct SphereMesh from transferred data
|
||||
function reconstructMesh(triangles, halfedges, numRegions) {
|
||||
return new SphereMesh(triangles, halfedges, numRegions);
|
||||
}
|
||||
|
||||
// Build minimal wind-result-like object for computeOceanCurrents fallback.
|
||||
// Derives geographic data (lat, sinLat, isLand, tangent frames) from r_xyz/r_elevation
|
||||
// and wraps the wind vectors the worker already sent.
|
||||
function buildWindResultForOcean(mesh, r_xyz, r_elevation,
|
||||
r_wind_east_summer, r_wind_north_summer, r_wind_east_winter, r_wind_north_winter,
|
||||
itczLons, itczLatsSummer, itczLatsWinter) {
|
||||
const n = mesh.numRegions;
|
||||
const r_lat = new Float32Array(n);
|
||||
const r_lon = new Float32Array(n);
|
||||
const r_sinLat = new Float32Array(n);
|
||||
const r_isLand = new Uint8Array(n);
|
||||
const r_eastX = new Float32Array(n), r_eastY = new Float32Array(n), r_eastZ = new Float32Array(n);
|
||||
const r_northX = new Float32Array(n), r_northY = new Float32Array(n), r_northZ = new Float32Array(n);
|
||||
|
||||
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];
|
||||
r_sinLat[r] = y;
|
||||
r_lat[r] = Math.asin(Math.max(-1, Math.min(1, y)));
|
||||
r_lon[r] = Math.atan2(x, z);
|
||||
r_isLand[r] = r_elevation[r] > 0 ? 1 : 0;
|
||||
|
||||
// East = cross(up, position) normalized
|
||||
let ex = z, ey = 0, ez = -x;
|
||||
const elen = Math.sqrt(ex * ex + ez * ez);
|
||||
if (elen > 1e-10) { ex /= elen; ez /= elen; }
|
||||
else { ex = 1; ez = 0; } // poles
|
||||
r_eastX[r] = ex; r_eastY[r] = ey; r_eastZ[r] = ez;
|
||||
|
||||
// North = cross(position, east) normalized
|
||||
let nx = y * ez - z * ey;
|
||||
let ny = z * ex - x * ez;
|
||||
let nz = x * ey - y * ex;
|
||||
const nlen = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1;
|
||||
r_northX[r] = nx / nlen; r_northY[r] = ny / nlen; r_northZ[r] = nz / nlen;
|
||||
}
|
||||
|
||||
// BFS coast distance through land (needed by precipitation fallback)
|
||||
const { adjOffset, adjList } = mesh;
|
||||
const r_coastDistLand = new Int32Array(n);
|
||||
r_coastDistLand.fill(-1);
|
||||
const bfsQueue = [];
|
||||
for (let r = 0; r < n; r++) {
|
||||
if (!r_isLand[r]) continue;
|
||||
const end = adjOffset[r + 1];
|
||||
for (let ni = adjOffset[r]; ni < end; ni++) {
|
||||
if (!r_isLand[adjList[ni]]) {
|
||||
r_coastDistLand[r] = 0;
|
||||
bfsQueue.push(r);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let bfsHead = 0;
|
||||
while (bfsHead < bfsQueue.length) {
|
||||
const r = bfsQueue[bfsHead++];
|
||||
const d = r_coastDistLand[r] + 1;
|
||||
const end = adjOffset[r + 1];
|
||||
for (let ni = adjOffset[r]; ni < end; ni++) {
|
||||
const nb = adjList[ni];
|
||||
if (r_isLand[nb] && r_coastDistLand[nb] === -1) {
|
||||
r_coastDistLand[nb] = d;
|
||||
bfsQueue.push(nb);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compute wind speed from components (prevents TypeError if accessed)
|
||||
const r_wind_speed_summer = new Float32Array(n);
|
||||
const r_wind_speed_winter = new Float32Array(n);
|
||||
for (let r = 0; r < n; r++) {
|
||||
const se = r_wind_east_summer[r], sn = r_wind_north_summer[r];
|
||||
r_wind_speed_summer[r] = Math.sqrt(se * se + sn * sn);
|
||||
const we = r_wind_east_winter[r], wn = r_wind_north_winter[r];
|
||||
r_wind_speed_winter[r] = Math.sqrt(we * we + wn * wn);
|
||||
}
|
||||
|
||||
// Zero-filled pressure deviation (neutral: no pressure-driven effects in fallback)
|
||||
const r_pressure_summer = new Float32Array(n);
|
||||
const r_pressure_winter = new Float32Array(n);
|
||||
|
||||
return {
|
||||
r_lat, r_lon, r_sinLat, r_isLand,
|
||||
r_eastX, r_eastY, r_eastZ,
|
||||
r_northX, r_northY, r_northZ,
|
||||
r_coastDistLand,
|
||||
r_wind_east_summer, r_wind_north_summer,
|
||||
r_wind_east_winter, r_wind_north_winter,
|
||||
r_wind_speed_summer, r_wind_speed_winter,
|
||||
r_pressure_summer, r_pressure_winter,
|
||||
itczLons, itczLatsSummer, itczLatsWinter
|
||||
};
|
||||
}
|
||||
|
||||
if (worker) {
|
||||
worker.onmessage = (e) => {
|
||||
const msg = e.data;
|
||||
switch (msg.type) {
|
||||
case 'progress':
|
||||
if (_onProgress) _onProgress(msg.pct, msg.label);
|
||||
break;
|
||||
|
||||
case 'done': {
|
||||
const tMainStart = performance.now();
|
||||
|
||||
const tReconStart = performance.now();
|
||||
const mesh = reconstructMesh(msg.triangles, msg.halfedges, msg.numRegions);
|
||||
const tRecon = performance.now() - tReconStart;
|
||||
|
||||
const tColorsStart = performance.now();
|
||||
computePlateColors(new Set(msg.plateSeeds), new Set(msg.plateIsOcean));
|
||||
const tColors = performance.now() - tColorsStart;
|
||||
|
||||
state.climateComputed = !msg.skipClimate;
|
||||
|
||||
const tStateStart = performance.now();
|
||||
state.curData = {
|
||||
mesh,
|
||||
r_xyz: msg.r_xyz,
|
||||
t_xyz: msg.t_xyz,
|
||||
r_plate: msg.r_plate,
|
||||
plateSeeds: new Set(msg.plateSeeds),
|
||||
plateVec: msg.plateVec,
|
||||
plateIsOcean: new Set(msg.plateIsOcean),
|
||||
originalPlateIsOcean: new Set(msg.originalPlateIsOcean),
|
||||
plateDensity: msg.plateDensity,
|
||||
plateDensityLand: msg.plateDensityLand,
|
||||
plateDensityOcean: msg.plateDensityOcean,
|
||||
prePostElev: msg.prePostElev,
|
||||
r_elevation: msg.r_elevation,
|
||||
t_elevation: msg.t_elevation,
|
||||
mountain_r: new Set(msg.mountain_r),
|
||||
coastline_r: new Set(msg.coastline_r),
|
||||
ocean_r: new Set(msg.ocean_r),
|
||||
r_stress: msg.r_stress,
|
||||
r_wind_east_summer: msg.r_wind_east_summer,
|
||||
r_wind_north_summer: msg.r_wind_north_summer,
|
||||
r_wind_east_winter: msg.r_wind_east_winter,
|
||||
r_wind_north_winter: msg.r_wind_north_winter,
|
||||
itczLons: msg.itczLons,
|
||||
itczLatsSummer: msg.itczLatsSummer,
|
||||
itczLatsWinter: msg.itczLatsWinter,
|
||||
r_ocean_current_east_summer: msg.r_ocean_current_east_summer,
|
||||
r_ocean_current_north_summer: msg.r_ocean_current_north_summer,
|
||||
r_ocean_current_east_winter: msg.r_ocean_current_east_winter,
|
||||
r_ocean_current_north_winter: msg.r_ocean_current_north_winter,
|
||||
r_ocean_speed_summer: msg.r_ocean_speed_summer,
|
||||
r_ocean_speed_winter: msg.r_ocean_speed_winter,
|
||||
r_ocean_warmth_summer: msg.r_ocean_warmth_summer,
|
||||
r_ocean_warmth_winter: msg.r_ocean_warmth_winter,
|
||||
r_precip_summer: msg.r_precip_summer,
|
||||
r_precip_winter: msg.r_precip_winter,
|
||||
r_temperature_summer: msg.r_temperature_summer,
|
||||
r_temperature_winter: msg.r_temperature_winter,
|
||||
seed: msg.seed,
|
||||
nMag: msg.nMag,
|
||||
debugLayers: msg.debugLayers,
|
||||
terrainMetrics: msg.terrainMetrics || null,
|
||||
painted: msg.painted || null
|
||||
};
|
||||
if (msg.terrainMetrics) window.__terrainMetrics = msg.terrainMetrics;
|
||||
const tState = performance.now() - tStateStart;
|
||||
|
||||
// Main-thread fallbacks — only run when climate was requested but partially missing
|
||||
// (e.g. older cached worker). Skip entirely when skipClimate was set.
|
||||
if (!msg.skipClimate) {
|
||||
let tOceanFallback = 0;
|
||||
const d = state.curData;
|
||||
let windResult = null;
|
||||
if (msg.r_wind_east_summer && (!d.r_ocean_speed_summer || !d.r_precip_summer || !d.r_temperature_summer)) {
|
||||
windResult = buildWindResultForOcean(mesh, d.r_xyz, d.r_elevation,
|
||||
d.r_wind_east_summer, d.r_wind_north_summer,
|
||||
d.r_wind_east_winter, d.r_wind_north_winter,
|
||||
d.itczLons, d.itczLatsSummer, d.itczLatsWinter);
|
||||
}
|
||||
|
||||
if (!d.r_ocean_speed_summer && windResult) {
|
||||
console.log('[generate.js] Ocean data missing from worker — computing on main thread');
|
||||
const t0Ocean = performance.now();
|
||||
const oceanResult = computeOceanCurrents(mesh, d.r_xyz, d.r_elevation, windResult);
|
||||
d.r_ocean_current_east_summer = oceanResult.r_ocean_current_east_summer;
|
||||
d.r_ocean_current_north_summer = oceanResult.r_ocean_current_north_summer;
|
||||
d.r_ocean_current_east_winter = oceanResult.r_ocean_current_east_winter;
|
||||
d.r_ocean_current_north_winter = oceanResult.r_ocean_current_north_winter;
|
||||
d.r_ocean_speed_summer = oceanResult.r_ocean_speed_summer;
|
||||
d.r_ocean_speed_winter = oceanResult.r_ocean_speed_winter;
|
||||
d.r_ocean_warmth_summer = oceanResult.r_ocean_warmth_summer;
|
||||
d.r_ocean_warmth_winter = oceanResult.r_ocean_warmth_winter;
|
||||
tOceanFallback = performance.now() - t0Ocean;
|
||||
console.log(`[generate.js] Ocean currents computed on main thread in ${tOceanFallback.toFixed(0)} ms`);
|
||||
}
|
||||
|
||||
if (!d.r_precip_summer && windResult) {
|
||||
console.log('[generate.js] Precipitation data missing from worker — computing on main thread');
|
||||
const t0Precip = performance.now();
|
||||
const precipResult = computePrecipitation(mesh, d.r_xyz, d.r_elevation, windResult, d);
|
||||
d.r_precip_summer = precipResult.r_precip_summer;
|
||||
d.r_precip_winter = precipResult.r_precip_winter;
|
||||
if (d.debugLayers) {
|
||||
d.debugLayers.precipSummer = precipResult.r_precip_summer;
|
||||
d.debugLayers.precipWinter = precipResult.r_precip_winter;
|
||||
d.debugLayers.rainShadowSummer = precipResult.r_rainshadow_summer;
|
||||
d.debugLayers.rainShadowWinter = precipResult.r_rainshadow_winter;
|
||||
}
|
||||
console.log(`[generate.js] Precipitation computed on main thread in ${(performance.now() - t0Precip).toFixed(0)} ms`);
|
||||
}
|
||||
|
||||
if (!d.r_temperature_summer && windResult) {
|
||||
console.log('[generate.js] Temperature data missing from worker — computing on main thread');
|
||||
const t0Temp = performance.now();
|
||||
const tempResult = computeTemperature(mesh, d.r_xyz, d.r_elevation, windResult, d, d);
|
||||
d.r_temperature_summer = tempResult.r_temperature_summer;
|
||||
d.r_temperature_winter = tempResult.r_temperature_winter;
|
||||
if (d.debugLayers) {
|
||||
d.debugLayers.tempSummer = tempResult.r_temperature_summer;
|
||||
d.debugLayers.tempWinter = tempResult.r_temperature_winter;
|
||||
}
|
||||
console.log(`[generate.js] Temperature computed on main thread in ${(performance.now() - t0Temp).toFixed(0)} ms`);
|
||||
}
|
||||
|
||||
if (state.curData.debugLayers && !state.curData.debugLayers.koppen &&
|
||||
state.curData.r_temperature_summer && state.curData.r_precip_summer) {
|
||||
const d = state.curData;
|
||||
d.debugLayers.koppen = classifyKoppen(mesh, d.r_elevation,
|
||||
{ r_temperature_summer: d.r_temperature_summer, r_temperature_winter: d.r_temperature_winter },
|
||||
{ r_precip_summer: d.r_precip_summer, r_precip_winter: d.r_precip_winter });
|
||||
}
|
||||
}
|
||||
|
||||
const tBuildStart = performance.now();
|
||||
buildMesh();
|
||||
const tBuild = performance.now() - tBuildStart;
|
||||
|
||||
const tMainTotal = performance.now() - tMainStart;
|
||||
const tTotal = performance.now() - _t0;
|
||||
|
||||
// Diagnostics
|
||||
{
|
||||
let landCount = 0, nanCount = 0;
|
||||
const plateIsOcean = state.curData.plateIsOcean;
|
||||
const r_plate = state.curData.r_plate;
|
||||
const r_elevation = state.curData.r_elevation;
|
||||
for (let r = 0; r < mesh.numRegions; r++) {
|
||||
if (!plateIsOcean.has(r_plate[r])) landCount++;
|
||||
if (isNaN(r_elevation[r])) nanCount++;
|
||||
}
|
||||
const landPct = (100 * landCount / mesh.numRegions).toFixed(1);
|
||||
if (nanCount > 0) console.error(`[World Orogen] WARNING: ${nanCount} NaN elevation values detected!`);
|
||||
if (landCount / mesh.numRegions < 0.10) console.warn(`[World Orogen] WARNING: Only ${landPct}% land (${landCount} regions). Ocean/land growth may have stalled.`);
|
||||
}
|
||||
|
||||
const f = v => typeof v === 'number' ? v.toFixed(1) : v;
|
||||
|
||||
console.log(`%c[World Orogen] Generation complete`, 'color:#6cf;font-weight:bold');
|
||||
if (msg._params) {
|
||||
console.log(` Params: N=${msg._params.N.toLocaleString()} P=${msg._params.P} jitter=${msg._params.jitter} noise=${msg._params.nMag} continents=${msg._params.numContinents} seed=${msg._params.seed}`);
|
||||
console.log(` Sculpting: warp=${msg._params.terrainWarp} smooth=${msg._params.smoothing} glacial=${msg._params.glacialErosion} hydraulic=${msg._params.hydraulicErosion} thermal=${msg._params.thermalErosion} ridge=${msg._params.ridgeSharpening}`);
|
||||
}
|
||||
console.log(` Regions: ${mesh.numRegions.toLocaleString()} Triangles: ${mesh.numTriangles.toLocaleString()} Sides: ${mesh.numSides.toLocaleString()}`);
|
||||
|
||||
// Worker pipeline stages
|
||||
if (msg._pipelineTiming) {
|
||||
console.groupCollapsed(' %cWorker pipeline stages', 'color:#8cf');
|
||||
console.table(msg._pipelineTiming.map(r => ({ Stage: r.stage, 'ms': f(r.ms) })));
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
// Elevation sub-stages
|
||||
if (msg._timing) {
|
||||
console.groupCollapsed(' %cElevation sub-stages', 'color:#fc8');
|
||||
console.table(msg._timing.map(r => ({ Stage: r.stage, 'ms': f(r.ms) })));
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
// Post-processing sub-stages
|
||||
if (msg._postTiming && msg._postTiming.length > 0) {
|
||||
console.groupCollapsed(' %cPost-processing sub-stages', 'color:#8f8');
|
||||
console.table(msg._postTiming.map(r => ({ Stage: r.stage, 'ms': f(r.ms) })));
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
// Summary
|
||||
const tWorker = msg._workerTotal || 0;
|
||||
const tTransfer = tTotal - tWorker - tMainTotal;
|
||||
console.log(
|
||||
` %cSummary:%c Worker: ${f(tWorker)} ms | Transfer: ${f(tTransfer)} ms | Main thread: ${f(tMainTotal)} ms (reconstruct=${f(tRecon)}, colors=${f(tColors)}, state=${f(tState)}, buildMesh=${f(tBuild)}) | TOTAL: ${f(tTotal)} ms`,
|
||||
'color:#ff6;font-weight:bold', ''
|
||||
);
|
||||
|
||||
const ms = tTotal.toFixed(0);
|
||||
document.getElementById('stats').innerHTML =
|
||||
`Regions: ${mesh.numRegions.toLocaleString()}<br>` +
|
||||
`Triangles: ${mesh.numTriangles.toLocaleString()}<br>` +
|
||||
`Generated in ${ms} ms<br>` +
|
||||
`<span style="color:#445;font-size:10px">worker ${tWorker.toFixed(0)} · render ${tBuild.toFixed(0)}</span>`;
|
||||
|
||||
if (_onProgress) _onProgress(100, 'Done');
|
||||
resetUI();
|
||||
document.getElementById('generate').dispatchEvent(new CustomEvent('generate-done'));
|
||||
if (_onDone) { _onDone(); _onDone = null; }
|
||||
break;
|
||||
}
|
||||
|
||||
case 'reapplyDone': {
|
||||
const tMainStart = performance.now();
|
||||
state.climateComputed = !msg.skipClimate;
|
||||
const d = state.curData;
|
||||
d.r_elevation = msg.r_elevation;
|
||||
d.t_elevation = msg.t_elevation;
|
||||
d.debugLayers.erosionDelta = msg.erosionDelta;
|
||||
if (msg.r_wind_east_summer) {
|
||||
d.r_wind_east_summer = msg.r_wind_east_summer;
|
||||
d.r_wind_north_summer = msg.r_wind_north_summer;
|
||||
d.r_wind_east_winter = msg.r_wind_east_winter;
|
||||
d.r_wind_north_winter = msg.r_wind_north_winter;
|
||||
}
|
||||
if (msg.itczLons) {
|
||||
d.itczLons = msg.itczLons;
|
||||
d.itczLatsSummer = msg.itczLatsSummer;
|
||||
d.itczLatsWinter = msg.itczLatsWinter;
|
||||
}
|
||||
if (msg.r_ocean_current_east_summer) {
|
||||
d.r_ocean_current_east_summer = msg.r_ocean_current_east_summer;
|
||||
d.r_ocean_current_north_summer = msg.r_ocean_current_north_summer;
|
||||
d.r_ocean_current_east_winter = msg.r_ocean_current_east_winter;
|
||||
d.r_ocean_current_north_winter = msg.r_ocean_current_north_winter;
|
||||
d.r_ocean_speed_summer = msg.r_ocean_speed_summer;
|
||||
d.r_ocean_speed_winter = msg.r_ocean_speed_winter;
|
||||
d.r_ocean_warmth_summer = msg.r_ocean_warmth_summer;
|
||||
d.r_ocean_warmth_winter = msg.r_ocean_warmth_winter;
|
||||
}
|
||||
// Fallback: compute ocean currents on main thread if worker didn't
|
||||
if (!d.r_ocean_speed_summer && d.r_wind_east_summer) {
|
||||
const wr = buildWindResultForOcean(d.mesh, d.r_xyz, d.r_elevation,
|
||||
d.r_wind_east_summer, d.r_wind_north_summer,
|
||||
d.r_wind_east_winter, d.r_wind_north_winter,
|
||||
d.itczLons, d.itczLatsSummer, d.itczLatsWinter);
|
||||
const oc = computeOceanCurrents(d.mesh, d.r_xyz, d.r_elevation, wr);
|
||||
Object.keys(oc).filter(k => k.startsWith('r_ocean_')).forEach(k => d[k] = oc[k]);
|
||||
}
|
||||
if (msg.r_precip_summer) {
|
||||
d.r_precip_summer = msg.r_precip_summer;
|
||||
d.r_precip_winter = msg.r_precip_winter;
|
||||
}
|
||||
if (msg.r_temperature_summer) {
|
||||
d.r_temperature_summer = msg.r_temperature_summer;
|
||||
d.r_temperature_winter = msg.r_temperature_winter;
|
||||
}
|
||||
if (msg.windDebugLayers) {
|
||||
Object.assign(d.debugLayers, msg.windDebugLayers);
|
||||
}
|
||||
// Fallback: compute precip/temp on main thread if climate was
|
||||
// requested but data is missing (e.g. partial worker result)
|
||||
if (!msg.skipClimate && d.r_wind_east_summer) {
|
||||
let wr = null;
|
||||
if (!d.r_precip_summer || !d.r_temperature_summer) {
|
||||
wr = buildWindResultForOcean(d.mesh, d.r_xyz, d.r_elevation,
|
||||
d.r_wind_east_summer, d.r_wind_north_summer,
|
||||
d.r_wind_east_winter, d.r_wind_north_winter,
|
||||
d.itczLons, d.itczLatsSummer, d.itczLatsWinter);
|
||||
}
|
||||
if (!d.r_precip_summer && wr) {
|
||||
const pr = computePrecipitation(d.mesh, d.r_xyz, d.r_elevation, wr, d);
|
||||
d.r_precip_summer = pr.r_precip_summer;
|
||||
d.r_precip_winter = pr.r_precip_winter;
|
||||
if (d.debugLayers) {
|
||||
d.debugLayers.precipSummer = pr.r_precip_summer;
|
||||
d.debugLayers.precipWinter = pr.r_precip_winter;
|
||||
d.debugLayers.rainShadowSummer = pr.r_rainshadow_summer;
|
||||
d.debugLayers.rainShadowWinter = pr.r_rainshadow_winter;
|
||||
}
|
||||
}
|
||||
if (!d.r_temperature_summer && wr) {
|
||||
const tr = computeTemperature(d.mesh, d.r_xyz, d.r_elevation, wr, d, d);
|
||||
d.r_temperature_summer = tr.r_temperature_summer;
|
||||
d.r_temperature_winter = tr.r_temperature_winter;
|
||||
if (d.debugLayers) {
|
||||
d.debugLayers.tempSummer = tr.r_temperature_summer;
|
||||
d.debugLayers.tempWinter = tr.r_temperature_winter;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clear stale climate data when climate was skipped so rendering
|
||||
// doesn't show mismatched terrain/climate from a previous run
|
||||
if (msg.skipClimate) {
|
||||
d.r_precip_summer = null;
|
||||
d.r_precip_winter = null;
|
||||
d.r_temperature_summer = null;
|
||||
d.r_temperature_winter = null;
|
||||
if (d.debugLayers) {
|
||||
d.debugLayers.koppen = null;
|
||||
d.debugLayers.tempSummer = null;
|
||||
d.debugLayers.tempWinter = null;
|
||||
d.debugLayers.precipSummer = null;
|
||||
d.debugLayers.precipWinter = null;
|
||||
}
|
||||
}
|
||||
|
||||
const tBuildStart = performance.now();
|
||||
buildMesh();
|
||||
const tBuild = performance.now() - tBuildStart;
|
||||
|
||||
const tMainTotal = performance.now() - tMainStart;
|
||||
|
||||
const f = v => typeof v === 'number' ? v.toFixed(1) : v;
|
||||
const rt = msg._reapplyTiming || {};
|
||||
console.log(`%c[World Orogen] Reapply complete`, 'color:#8f8;font-weight:bold');
|
||||
if (msg._postTiming && msg._postTiming.length > 0) {
|
||||
console.groupCollapsed(' %cPost-processing sub-stages', 'color:#8f8');
|
||||
console.table(msg._postTiming.map(r => ({ Stage: r.stage, 'ms': f(r.ms) })));
|
||||
console.groupEnd();
|
||||
}
|
||||
console.log(
|
||||
` %cSummary:%c Worker: ${f(rt.workerTotal || 0)} ms (clone=${f(rt.clone || 0)}, postProcess=${f(rt.postProcessing || 0)}, triElev=${f(rt.triangleElevations || 0)}) | Main: ${f(tMainTotal)} ms (buildMesh=${f(tBuild)})`,
|
||||
'color:#ff6;font-weight:bold', ''
|
||||
);
|
||||
|
||||
if (_onProgress) _onProgress(100, 'Done');
|
||||
if (_onDone) { _onDone(); _onDone = null; }
|
||||
break;
|
||||
}
|
||||
|
||||
case 'editDone': {
|
||||
const tMainStart = performance.now();
|
||||
state.climateComputed = !msg.skipClimate;
|
||||
const d = state.curData;
|
||||
d.prePostElev = msg.prePostElev;
|
||||
d.r_elevation = msg.r_elevation;
|
||||
d.t_elevation = msg.t_elevation;
|
||||
d.mountain_r = new Set(msg.mountain_r);
|
||||
d.coastline_r = new Set(msg.coastline_r);
|
||||
d.ocean_r = new Set(msg.ocean_r);
|
||||
d.r_stress = msg.r_stress;
|
||||
if (msg.r_wind_east_summer) {
|
||||
d.r_wind_east_summer = msg.r_wind_east_summer;
|
||||
d.r_wind_north_summer = msg.r_wind_north_summer;
|
||||
d.r_wind_east_winter = msg.r_wind_east_winter;
|
||||
d.r_wind_north_winter = msg.r_wind_north_winter;
|
||||
}
|
||||
if (msg.itczLons) {
|
||||
d.itczLons = msg.itczLons;
|
||||
d.itczLatsSummer = msg.itczLatsSummer;
|
||||
d.itczLatsWinter = msg.itczLatsWinter;
|
||||
}
|
||||
if (msg.r_ocean_current_east_summer) {
|
||||
d.r_ocean_current_east_summer = msg.r_ocean_current_east_summer;
|
||||
d.r_ocean_current_north_summer = msg.r_ocean_current_north_summer;
|
||||
d.r_ocean_current_east_winter = msg.r_ocean_current_east_winter;
|
||||
d.r_ocean_current_north_winter = msg.r_ocean_current_north_winter;
|
||||
d.r_ocean_speed_summer = msg.r_ocean_speed_summer;
|
||||
d.r_ocean_speed_winter = msg.r_ocean_speed_winter;
|
||||
d.r_ocean_warmth_summer = msg.r_ocean_warmth_summer;
|
||||
d.r_ocean_warmth_winter = msg.r_ocean_warmth_winter;
|
||||
}
|
||||
// Fallback: compute ocean currents on main thread if worker didn't
|
||||
if (!d.r_ocean_speed_summer && d.r_wind_east_summer) {
|
||||
const wr = buildWindResultForOcean(d.mesh, d.r_xyz, d.r_elevation,
|
||||
d.r_wind_east_summer, d.r_wind_north_summer,
|
||||
d.r_wind_east_winter, d.r_wind_north_winter,
|
||||
d.itczLons, d.itczLatsSummer, d.itczLatsWinter);
|
||||
const oc = computeOceanCurrents(d.mesh, d.r_xyz, d.r_elevation, wr);
|
||||
Object.keys(oc).filter(k => k.startsWith('r_ocean_')).forEach(k => d[k] = oc[k]);
|
||||
}
|
||||
if (msg.r_precip_summer) {
|
||||
d.r_precip_summer = msg.r_precip_summer;
|
||||
d.r_precip_winter = msg.r_precip_winter;
|
||||
}
|
||||
if (msg.r_temperature_summer) {
|
||||
d.r_temperature_summer = msg.r_temperature_summer;
|
||||
d.r_temperature_winter = msg.r_temperature_winter;
|
||||
}
|
||||
d.debugLayers = msg.debugLayers;
|
||||
// Fallback: compute precip/temp on main thread if climate was
|
||||
// requested but data is missing (e.g. partial worker result)
|
||||
if (!msg.skipClimate && d.r_wind_east_summer) {
|
||||
let wr = null;
|
||||
if (!d.r_precip_summer || !d.r_temperature_summer) {
|
||||
wr = buildWindResultForOcean(d.mesh, d.r_xyz, d.r_elevation,
|
||||
d.r_wind_east_summer, d.r_wind_north_summer,
|
||||
d.r_wind_east_winter, d.r_wind_north_winter,
|
||||
d.itczLons, d.itczLatsSummer, d.itczLatsWinter);
|
||||
}
|
||||
if (!d.r_precip_summer && wr) {
|
||||
const pr = computePrecipitation(d.mesh, d.r_xyz, d.r_elevation, wr, d);
|
||||
d.r_precip_summer = pr.r_precip_summer;
|
||||
d.r_precip_winter = pr.r_precip_winter;
|
||||
if (d.debugLayers) {
|
||||
d.debugLayers.precipSummer = pr.r_precip_summer;
|
||||
d.debugLayers.precipWinter = pr.r_precip_winter;
|
||||
d.debugLayers.rainShadowSummer = pr.r_rainshadow_summer;
|
||||
d.debugLayers.rainShadowWinter = pr.r_rainshadow_winter;
|
||||
}
|
||||
}
|
||||
if (!d.r_temperature_summer && wr) {
|
||||
const tr = computeTemperature(d.mesh, d.r_xyz, d.r_elevation, wr, d, d);
|
||||
d.r_temperature_summer = tr.r_temperature_summer;
|
||||
d.r_temperature_winter = tr.r_temperature_winter;
|
||||
if (d.debugLayers) {
|
||||
d.debugLayers.tempSummer = tr.r_temperature_summer;
|
||||
d.debugLayers.tempWinter = tr.r_temperature_winter;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clear stale climate data when climate was skipped
|
||||
if (msg.skipClimate) {
|
||||
d.r_precip_summer = null;
|
||||
d.r_precip_winter = null;
|
||||
d.r_temperature_summer = null;
|
||||
d.r_temperature_winter = null;
|
||||
if (d.debugLayers) {
|
||||
d.debugLayers.koppen = null;
|
||||
d.debugLayers.tempSummer = null;
|
||||
d.debugLayers.tempWinter = null;
|
||||
d.debugLayers.precipSummer = null;
|
||||
d.debugLayers.precipWinter = null;
|
||||
}
|
||||
}
|
||||
|
||||
const tColorsStart = performance.now();
|
||||
computePlateColors(d.plateSeeds, d.plateIsOcean);
|
||||
const tColors = performance.now() - tColorsStart;
|
||||
|
||||
const tBuildStart = performance.now();
|
||||
buildMesh();
|
||||
const tBuild = performance.now() - tBuildStart;
|
||||
|
||||
const tMainTotal = performance.now() - tMainStart;
|
||||
|
||||
const f = v => typeof v === 'number' ? v.toFixed(1) : v;
|
||||
const et = msg._editTiming || {};
|
||||
console.log(`%c[World Orogen] Edit recompute complete`, 'color:#fc8;font-weight:bold');
|
||||
|
||||
if (msg._timing) {
|
||||
console.groupCollapsed(' %cElevation sub-stages', 'color:#fc8');
|
||||
console.table(msg._timing.map(r => ({ Stage: r.stage, 'ms': f(r.ms) })));
|
||||
console.groupEnd();
|
||||
}
|
||||
if (msg._postTiming && msg._postTiming.length > 0) {
|
||||
console.groupCollapsed(' %cPost-processing sub-stages', 'color:#8f8');
|
||||
console.table(msg._postTiming.map(r => ({ Stage: r.stage, 'ms': f(r.ms) })));
|
||||
console.groupEnd();
|
||||
}
|
||||
console.log(
|
||||
` %cSummary:%c Worker: ${f(et.workerTotal || 0)} ms (elevation=${f(et.elevation || 0)}, postProcess=${f(et.postProcessing || 0)}, triElev=${f(et.triangleElevations || 0)}, retain=${f(et.retainState || 0)}) | Main: ${f(tMainTotal)} ms (colors=${f(tColors)}, buildMesh=${f(tBuild)})`,
|
||||
'color:#ff6;font-weight:bold', ''
|
||||
);
|
||||
|
||||
if (_onProgress) _onProgress(100, 'Done');
|
||||
if (_onDone) { _onDone(); _onDone = null; }
|
||||
break;
|
||||
}
|
||||
|
||||
case 'climateDone': {
|
||||
const d = state.curData;
|
||||
if (d) {
|
||||
// Copy all climate arrays
|
||||
d.r_wind_east_summer = msg.r_wind_east_summer;
|
||||
d.r_wind_north_summer = msg.r_wind_north_summer;
|
||||
d.r_wind_east_winter = msg.r_wind_east_winter;
|
||||
d.r_wind_north_winter = msg.r_wind_north_winter;
|
||||
d.itczLons = msg.itczLons;
|
||||
d.itczLatsSummer = msg.itczLatsSummer;
|
||||
d.itczLatsWinter = msg.itczLatsWinter;
|
||||
d.r_ocean_current_east_summer = msg.r_ocean_current_east_summer;
|
||||
d.r_ocean_current_north_summer = msg.r_ocean_current_north_summer;
|
||||
d.r_ocean_current_east_winter = msg.r_ocean_current_east_winter;
|
||||
d.r_ocean_current_north_winter = msg.r_ocean_current_north_winter;
|
||||
d.r_ocean_speed_summer = msg.r_ocean_speed_summer;
|
||||
d.r_ocean_speed_winter = msg.r_ocean_speed_winter;
|
||||
d.r_ocean_warmth_summer = msg.r_ocean_warmth_summer;
|
||||
d.r_ocean_warmth_winter = msg.r_ocean_warmth_winter;
|
||||
d.r_precip_summer = msg.r_precip_summer;
|
||||
d.r_precip_winter = msg.r_precip_winter;
|
||||
d.r_temperature_summer = msg.r_temperature_summer;
|
||||
d.r_temperature_winter = msg.r_temperature_winter;
|
||||
// Merge climate debug layers
|
||||
if (msg.climateDebugLayers && d.debugLayers) {
|
||||
Object.assign(d.debugLayers, msg.climateDebugLayers);
|
||||
}
|
||||
}
|
||||
state.climateComputed = true;
|
||||
buildMesh();
|
||||
|
||||
const f = v => typeof v === 'number' ? v.toFixed(1) : v;
|
||||
const ct = msg._climateTiming || {};
|
||||
console.log(`%c[World Orogen] Climate computed on demand`, 'color:#f8a;font-weight:bold');
|
||||
console.log(
|
||||
` %cSummary:%c Worker: ${f(ct.workerTotal || 0)} ms (wind=${f(ct.wind || 0)}, ocean=${f(ct.ocean || 0)}, precip=${f(ct.precipitation || 0)}, temp=${f(ct.temperature || 0)}, koppen=${f(ct.koppen || 0)})`,
|
||||
'color:#ff6;font-weight:bold', ''
|
||||
);
|
||||
|
||||
if (_onProgress) _onProgress(100, 'Done');
|
||||
if (_onDone) { _onDone(); _onDone = null; }
|
||||
break;
|
||||
}
|
||||
|
||||
case 'error':
|
||||
fail(msg.message);
|
||||
if (_onDone) { _onDone(); _onDone = null; }
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
worker.onerror = (e) => {
|
||||
fail(e.message || 'Worker crashed');
|
||||
if (_onDone) { _onDone(); _onDone = null; }
|
||||
};
|
||||
}
|
||||
|
||||
// --- Synchronous fallback (imported lazily to avoid loading when worker works) ---
|
||||
let _fallbackModules = null;
|
||||
async function loadFallback() {
|
||||
if (_fallbackModules) return _fallbackModules;
|
||||
const [rng, simplex, sphere, plates, ocean, elev, post, wind, oceanCurrents, precip, temp, coarsePlates] = await Promise.all([
|
||||
import('./rng.js'),
|
||||
import('./simplex-noise.js'),
|
||||
import('./sphere-mesh.js'),
|
||||
import('./plates.js'),
|
||||
import('./ocean-land.js'),
|
||||
import('./elevation.js'),
|
||||
import('./terrain-post.js'),
|
||||
import('./wind.js'),
|
||||
import('./ocean.js'),
|
||||
import('./precipitation.js'),
|
||||
import('./temperature.js'),
|
||||
import('./coarse-plates.js')
|
||||
]);
|
||||
_fallbackModules = { rng, simplex, sphere, plates, ocean, elev, post, wind, oceanCurrents, precip, temp, coarsePlates };
|
||||
return _fallbackModules;
|
||||
}
|
||||
|
||||
function generateFallback(overrideSeed, toggledIndices, onProgress, skipClimate) {
|
||||
// Dynamic import already resolved — run synchronously via rAF stages
|
||||
const m = _fallbackModules;
|
||||
const btn = document.getElementById('generate');
|
||||
const { N, P, jitter, nMag, numContinents, terrainWarp, smoothing, hydraulicErosion, thermalErosion, ridgeSharpening, glacialErosion, continentSizeVariety, temperatureOffset, precipitationOffset, landCoverage } = readSliders();
|
||||
const progress = onProgress || (() => {});
|
||||
const ctx = {};
|
||||
|
||||
const stages = [
|
||||
{ pct: 0, label: 'Shaping the world\u2026', work() {
|
||||
ctx.seed = overrideSeed ?? Math.floor(Math.random() * 16777216);
|
||||
ctx.rng = m.rng.makeRng(ctx.seed);
|
||||
const { mesh, r_xyz } = m.sphere.buildSphere(N, jitter, ctx.rng);
|
||||
ctx.mesh = mesh; ctx.r_xyz = r_xyz;
|
||||
ctx.t_xyz = m.sphere.generateTriangleCenters(mesh, r_xyz);
|
||||
}},
|
||||
{ pct: 10, label: 'Generating coarse plates\u2026', work() {
|
||||
const { coarseMesh, coarse_xyz, coarse_r_plate, coarsePlateSeeds, coarsePlateVec, coarsePlateIsOcean } =
|
||||
m.coarsePlates.generateCoarsePlates(ctx.seed, P, numContinents, continentSizeVariety, landCoverage);
|
||||
ctx.coarseMesh = coarseMesh; ctx.coarse_xyz = coarse_xyz;
|
||||
ctx.coarse_r_plate = coarse_r_plate;
|
||||
ctx.plateSeeds = coarsePlateSeeds; ctx.plateVec = coarsePlateVec;
|
||||
ctx.coarsePlateIsOcean = coarsePlateIsOcean;
|
||||
}},
|
||||
{ pct: 18, label: 'Projecting plates\u2026', work() {
|
||||
ctx.r_plate = m.coarsePlates.projectCoarsePlates(ctx.mesh, ctx.r_xyz, ctx.coarseMesh, ctx.coarse_xyz, ctx.coarse_r_plate, ctx.seed, P);
|
||||
m.plates.smoothAndReconnectPlates(ctx.mesh, ctx.r_plate, ctx.plateSeeds, 3);
|
||||
}},
|
||||
{ pct: 25, label: 'Carving oceans\u2026', work() {
|
||||
const plateIsOcean = ctx.coarsePlateIsOcean;
|
||||
ctx.originalPlateIsOcean = new Set(plateIsOcean);
|
||||
if (toggledIndices.length > 0) {
|
||||
const seedArr = Array.from(ctx.plateSeeds);
|
||||
for (const i of toggledIndices) {
|
||||
if (i < seedArr.length) {
|
||||
const r = seedArr[i];
|
||||
if (plateIsOcean.has(r)) plateIsOcean.delete(r);
|
||||
else plateIsOcean.add(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
computePlateColors(ctx.plateSeeds, plateIsOcean);
|
||||
const plateDensity = {}, plateDensityLand = {}, plateDensityOcean = {};
|
||||
for (const r of ctx.plateSeeds) {
|
||||
const drng = m.rng.makeRng(r + 777);
|
||||
plateDensityOcean[r] = 3.0 + drng() * 0.5;
|
||||
plateDensityLand[r] = 2.4 + drng() * 0.5;
|
||||
plateDensity[r] = plateIsOcean.has(r) ? plateDensityOcean[r] : plateDensityLand[r];
|
||||
}
|
||||
ctx.plateIsOcean = plateIsOcean; ctx.plateDensity = plateDensity;
|
||||
ctx.plateDensityLand = plateDensityLand; ctx.plateDensityOcean = plateDensityOcean;
|
||||
ctx.noise = new m.simplex.SimplexNoise(ctx.seed);
|
||||
}},
|
||||
{ pct: 35, label: 'Raising mountains\u2026', work() {
|
||||
const { r_elevation, mountain_r, coastline_r, ocean_r, r_stress, debugLayers, _timing } =
|
||||
m.elev.assignElevation(ctx.mesh, ctx.r_xyz, ctx.plateIsOcean, ctx.r_plate, ctx.plateVec, ctx.plateSeeds, ctx.noise, nMag, ctx.seed, 5, ctx.plateDensity);
|
||||
ctx.r_elevation = r_elevation; ctx.mountain_r = mountain_r; ctx.coastline_r = coastline_r;
|
||||
ctx.ocean_r = ocean_r; ctx.r_stress = r_stress; ctx.debugLayers = debugLayers;
|
||||
ctx.prePostElev = new Float32Array(r_elevation);
|
||||
if (terrainWarp > 0) m.post.warpTerrain(ctx.mesh, r_elevation, ctx.r_xyz, ctx.seed, terrainWarp, debugLayers.hotspot);
|
||||
const r_isOcean = new Uint8Array(ctx.mesh.numRegions);
|
||||
for (let r = 0; r < ctx.mesh.numRegions; r++) { if (r_elevation[r] <= 0) r_isOcean[r] = 1; }
|
||||
const preErosion = new Float32Array(r_elevation);
|
||||
if (smoothing > 0) m.post.smoothElevation(ctx.mesh, r_elevation, r_isOcean, Math.round(1 + smoothing * 4), 0.2 + smoothing * 0.5);
|
||||
if (glacialErosion > 0 || hydraulicErosion > 0 || thermalErosion > 0)
|
||||
m.post.erodeComposite(ctx.mesh, r_elevation, ctx.r_xyz, r_isOcean, Math.round(hydraulicErosion * 20), hydraulicErosion * 0.0006, 0.5, 1.0, Math.round(thermalErosion * 10), 1.2 - thermalErosion * 0.4, thermalErosion * 0.15, Math.round(glacialErosion * 10), glacialErosion);
|
||||
if (ridgeSharpening > 0) m.post.sharpenRidges(ctx.mesh, r_elevation, r_isOcean, Math.round(1 + ridgeSharpening * 3), ridgeSharpening * 0.08);
|
||||
m.post.applySoilCreep(ctx.mesh, r_elevation, r_isOcean, 3, 0.1125);
|
||||
const dl_erosionDelta = new Float32Array(ctx.mesh.numRegions);
|
||||
for (let r = 0; r < ctx.mesh.numRegions; r++) dl_erosionDelta[r] = r_elevation[r] - preErosion[r];
|
||||
debugLayers.erosionDelta = dl_erosionDelta;
|
||||
if (!skipClimate) {
|
||||
const windResult = m.wind.computeWind(ctx.mesh, ctx.r_xyz, r_elevation, ctx.plateIsOcean, ctx.r_plate, ctx.noise);
|
||||
debugLayers.pressureSummer = windResult.r_pressure_summer;
|
||||
debugLayers.pressureWinter = windResult.r_pressure_winter;
|
||||
debugLayers.windSpeedSummer = windResult.r_wind_speed_summer;
|
||||
debugLayers.windSpeedWinter = windResult.r_wind_speed_winter;
|
||||
ctx.windResult = windResult;
|
||||
const oceanResult = m.oceanCurrents.computeOceanCurrents(ctx.mesh, ctx.r_xyz, r_elevation, windResult);
|
||||
ctx.oceanResult = oceanResult;
|
||||
const precipResult = m.precip.computePrecipitation(ctx.mesh, ctx.r_xyz, r_elevation, windResult, oceanResult, precipitationOffset, landCoverage);
|
||||
ctx.precipResult = precipResult;
|
||||
debugLayers.precipSummer = precipResult.r_precip_summer;
|
||||
debugLayers.precipWinter = precipResult.r_precip_winter;
|
||||
debugLayers.rainShadowSummer = precipResult.r_rainshadow_summer;
|
||||
debugLayers.rainShadowWinter = precipResult.r_rainshadow_winter;
|
||||
const tempResult = m.temp.computeTemperature(ctx.mesh, ctx.r_xyz, r_elevation, windResult, oceanResult, precipResult, temperatureOffset);
|
||||
ctx.tempResult = tempResult;
|
||||
debugLayers.tempSummer = tempResult.r_temperature_summer;
|
||||
debugLayers.tempWinter = tempResult.r_temperature_winter;
|
||||
debugLayers.koppen = classifyKoppen(ctx.mesh, r_elevation, tempResult, precipResult);
|
||||
}
|
||||
const t_elevation = new Float32Array(ctx.mesh.numTriangles);
|
||||
for (let t = 0; t < ctx.mesh.numTriangles; t++) {
|
||||
const s0 = 3 * t;
|
||||
const a = ctx.mesh.s_begin_r(s0), b = ctx.mesh.s_begin_r(s0+1), c = ctx.mesh.s_begin_r(s0+2);
|
||||
t_elevation[t] = (r_elevation[a] + r_elevation[b] + r_elevation[c]) / 3;
|
||||
}
|
||||
ctx.t_elevation = t_elevation;
|
||||
}},
|
||||
{ pct: 85, label: 'Painting the surface\u2026', work() {
|
||||
state.curData = {
|
||||
mesh: ctx.mesh, r_xyz: ctx.r_xyz, t_xyz: ctx.t_xyz,
|
||||
r_plate: ctx.r_plate, plateSeeds: ctx.plateSeeds, plateVec: ctx.plateVec,
|
||||
plateIsOcean: ctx.plateIsOcean, originalPlateIsOcean: ctx.originalPlateIsOcean,
|
||||
plateDensity: ctx.plateDensity, plateDensityLand: ctx.plateDensityLand,
|
||||
plateDensityOcean: ctx.plateDensityOcean, prePostElev: ctx.prePostElev,
|
||||
r_elevation: ctx.r_elevation, t_elevation: ctx.t_elevation,
|
||||
mountain_r: ctx.mountain_r, coastline_r: ctx.coastline_r, ocean_r: ctx.ocean_r,
|
||||
r_stress: ctx.r_stress, noise: ctx.noise, seed: ctx.seed, debugLayers: ctx.debugLayers,
|
||||
r_wind_east_summer: ctx.windResult ? ctx.windResult.r_wind_east_summer : null,
|
||||
r_wind_north_summer: ctx.windResult ? ctx.windResult.r_wind_north_summer : null,
|
||||
r_wind_east_winter: ctx.windResult ? ctx.windResult.r_wind_east_winter : null,
|
||||
r_wind_north_winter: ctx.windResult ? ctx.windResult.r_wind_north_winter : null,
|
||||
itczLons: ctx.windResult ? ctx.windResult.itczLons : null,
|
||||
itczLatsSummer: ctx.windResult ? ctx.windResult.itczLatsSummer : null,
|
||||
itczLatsWinter: ctx.windResult ? ctx.windResult.itczLatsWinter : null,
|
||||
r_ocean_current_east_summer: ctx.oceanResult ? ctx.oceanResult.r_ocean_current_east_summer : null,
|
||||
r_ocean_current_north_summer: ctx.oceanResult ? ctx.oceanResult.r_ocean_current_north_summer : null,
|
||||
r_ocean_current_east_winter: ctx.oceanResult ? ctx.oceanResult.r_ocean_current_east_winter : null,
|
||||
r_ocean_current_north_winter: ctx.oceanResult ? ctx.oceanResult.r_ocean_current_north_winter : null,
|
||||
r_ocean_speed_summer: ctx.oceanResult ? ctx.oceanResult.r_ocean_speed_summer : null,
|
||||
r_ocean_speed_winter: ctx.oceanResult ? ctx.oceanResult.r_ocean_speed_winter : null,
|
||||
r_ocean_warmth_summer: ctx.oceanResult ? ctx.oceanResult.r_ocean_warmth_summer : null,
|
||||
r_ocean_warmth_winter: ctx.oceanResult ? ctx.oceanResult.r_ocean_warmth_winter : null,
|
||||
r_precip_summer: ctx.precipResult ? ctx.precipResult.r_precip_summer : null,
|
||||
r_precip_winter: ctx.precipResult ? ctx.precipResult.r_precip_winter : null,
|
||||
r_temperature_summer: ctx.tempResult ? ctx.tempResult.r_temperature_summer : null,
|
||||
r_temperature_winter: ctx.tempResult ? ctx.tempResult.r_temperature_winter : null
|
||||
};
|
||||
state.climateComputed = !skipClimate;
|
||||
buildMesh();
|
||||
progress(100, 'Done');
|
||||
resetUI();
|
||||
btn.dispatchEvent(new CustomEvent('generate-done'));
|
||||
}}
|
||||
];
|
||||
|
||||
function runStage(idx) {
|
||||
if (idx >= stages.length) return;
|
||||
const s = stages[idx];
|
||||
try { progress(s.pct, s.label); } catch (e) { fail(e); return; }
|
||||
requestAnimationFrame(() => setTimeout(() => {
|
||||
try { s.work(); runStage(idx + 1); } catch (e) { fail(e); }
|
||||
}, 0));
|
||||
}
|
||||
setTimeout(() => runStage(0), 0);
|
||||
}
|
||||
|
||||
// --- Public API ---
|
||||
|
||||
export function generate(overrideSeed, toggledIndices = [], onProgress, skipClimate = false) {
|
||||
const btn = document.getElementById('generate');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Building\u2026';
|
||||
btn.classList.add('generating');
|
||||
|
||||
_onProgress = onProgress || (() => {});
|
||||
_t0 = performance.now();
|
||||
|
||||
if (!worker) {
|
||||
// Fallback: load modules then run synchronously
|
||||
loadFallback().then(() => generateFallback(overrideSeed, toggledIndices, onProgress, skipClimate));
|
||||
return;
|
||||
}
|
||||
|
||||
const s = readSliders();
|
||||
|
||||
worker.postMessage({
|
||||
cmd: 'generate',
|
||||
...s,
|
||||
seed: overrideSeed,
|
||||
toggledIndices,
|
||||
skipClimate
|
||||
});
|
||||
}
|
||||
|
||||
export function reapplyViaWorker(onDone, skipClimate = false) {
|
||||
if (!worker || !state.curData) return;
|
||||
|
||||
_onProgress = (pct, label) => {
|
||||
// Progress updates during reapply (used by build overlay if shown)
|
||||
};
|
||||
_onDone = onDone || null;
|
||||
_t0 = performance.now();
|
||||
|
||||
const s = readSlidersOptional();
|
||||
const temperatureOffset = +(document.getElementById('sTmp')?.value ?? 0);
|
||||
const precipitationOffset = +(document.getElementById('sPrc')?.value ?? 0);
|
||||
const landCoverage = +(document.getElementById('sLc')?.value ?? 0.3);
|
||||
|
||||
worker.postMessage({
|
||||
cmd: 'reapply',
|
||||
...s, temperatureOffset, precipitationOffset, landCoverage,
|
||||
skipClimate
|
||||
});
|
||||
}
|
||||
|
||||
export function editRecomputeViaWorker(onDone, skipClimate = false) {
|
||||
if (!worker || !state.curData) return;
|
||||
|
||||
const d = state.curData;
|
||||
_onProgress = () => {};
|
||||
_onDone = onDone || null;
|
||||
_t0 = performance.now();
|
||||
|
||||
const { nMag, terrainWarp, smoothing, glacialErosion, hydraulicErosion, thermalErosion, ridgeSharpening, temperatureOffset, precipitationOffset, landCoverage } = readSliders();
|
||||
|
||||
worker.postMessage({
|
||||
cmd: 'editRecompute',
|
||||
plateIsOcean: Array.from(d.plateIsOcean),
|
||||
plateDensity: d.plateDensity,
|
||||
nMag, terrainWarp, smoothing, glacialErosion, hydraulicErosion, thermalErosion, ridgeSharpening,
|
||||
temperatureOffset, precipitationOffset, landCoverage,
|
||||
skipClimate
|
||||
});
|
||||
}
|
||||
|
||||
export function computeClimateViaWorker(onProgress, onDone) {
|
||||
if (!worker || !state.curData) return;
|
||||
_onProgress = onProgress || (() => {});
|
||||
_onDone = onDone || null;
|
||||
_t0 = performance.now();
|
||||
const temperatureOffset = +(document.getElementById('sTmp')?.value ?? 0);
|
||||
const precipitationOffset = +(document.getElementById('sPrc')?.value ?? 0);
|
||||
const landCoverage = +(document.getElementById('sLc')?.value ?? 0.3);
|
||||
|
||||
worker.postMessage({
|
||||
cmd: 'computeClimate',
|
||||
temperatureOffset, precipitationOffset, landCoverage
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a painted class map: `classRaster` is one legend index per pixel (from
|
||||
* painted.js classifyImage on the main thread), `legend` the raw legend JSON object, and
|
||||
* `paintedParams` the planet block plus the solve controls (peak height, ocean depth, steps,
|
||||
* coast detail, uplift variation, seed).
|
||||
*/
|
||||
export function importPainted(classRaster, imageWidth, imageHeight, legend, paintedParams, onProgress, skipClimate = false, overlay = null) {
|
||||
if (!worker) return;
|
||||
|
||||
_onProgress = onProgress || (() => {});
|
||||
_t0 = performance.now();
|
||||
|
||||
const { N, jitter, terrainWarp, smoothing, hydraulicErosion, thermalErosion, ridgeSharpening, glacialErosion } = readSlidersOptional();
|
||||
const temperatureOffset = +(document.getElementById('sTmp')?.value ?? 0);
|
||||
const precipitationOffset = +(document.getElementById('sPrc')?.value ?? 0);
|
||||
|
||||
worker.postMessage({
|
||||
cmd: 'importPainted',
|
||||
N, jitter,
|
||||
classRaster, imageWidth, imageHeight,
|
||||
legend, painted: paintedParams,
|
||||
overlay,
|
||||
terrainWarp, smoothing, hydraulicErosion, thermalErosion, ridgeSharpening, glacialErosion,
|
||||
temperatureOffset, precipitationOffset,
|
||||
skipClimate
|
||||
}, [classRaster.buffer, ...(overlay && overlay.marks ? [overlay.marks.buffer] : [])]);
|
||||
}
|
||||
|
||||
export function importHeightmap(grayscale, imageWidth, imageHeight, onProgress, skipClimate = false) {
|
||||
if (!worker) return;
|
||||
|
||||
_onProgress = onProgress || (() => {});
|
||||
_t0 = performance.now();
|
||||
|
||||
const { N, jitter, terrainWarp, smoothing, hydraulicErosion, thermalErosion, ridgeSharpening, glacialErosion } = readSlidersOptional();
|
||||
|
||||
worker.postMessage({
|
||||
cmd: 'importHeightmap',
|
||||
N, jitter,
|
||||
grayscale, imageWidth, imageHeight,
|
||||
terrainWarp, smoothing, hydraulicErosion, thermalErosion, ridgeSharpening, glacialErosion,
|
||||
skipClimate
|
||||
}, [grayscale.buffer]);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
// Heuristic precipitation model: smooth zonal patterns blended with the
|
||||
// complex advection model to reduce splotchiness and strengthen deserts.
|
||||
// Computes precipitation from four multiplicative factors: zonal base curve
|
||||
// (distance from ITCZ), seasonal modifier, continental dryness, and
|
||||
// orographic rain shadow.
|
||||
|
||||
import { smoothstep } from './wind.js';
|
||||
import { elevToHeightKm } from './color-map.js';
|
||||
import { smoothField, makeItczLookup } from './climate-util.js';
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
|
||||
// ── Zonal base curve ────────────────────────────────────────────────────────
|
||||
// Returns a value in [0.03, 1.0] based on distance from the ITCZ in degrees.
|
||||
|
||||
function zonalBase(distDeg) {
|
||||
if (distDeg < 5) {
|
||||
// ITCZ core: 1.0
|
||||
return 1.0;
|
||||
} else if (distDeg < 10) {
|
||||
// Outer ITCZ / trades: 1.0 → 0.35 (faster falloff)
|
||||
return 1.0 - 0.65 * smoothstep(5, 10, distDeg);
|
||||
} else if (distDeg < 33) {
|
||||
// Subtropical highs (desert factory): 0.35 → 0.02
|
||||
// Very aggressive minimum — core of the desert belt.
|
||||
return 0.35 - 0.33 * smoothstep(10, 28, distDeg);
|
||||
} else if (distDeg < 55) {
|
||||
// Mid-lat westerlies recovery: 0.02 → 0.5
|
||||
return 0.02 + 0.48 * smoothstep(33, 55, distDeg);
|
||||
} else if (distDeg < 70) {
|
||||
// Subpolar: 0.5 → 0.3
|
||||
return 0.5 - 0.2 * smoothstep(55, 70, distDeg);
|
||||
} else {
|
||||
// Polar: 0.3 → 0.1
|
||||
return 0.3 - 0.2 * smoothstep(70, 90, distDeg);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Heuristic zonal wind ────────────────────────────────────────────────────
|
||||
// Idealized wind direction based on latitude relative to the ITCZ.
|
||||
// Returns local east/north components (positive east = blowing eastward,
|
||||
// positive north = blowing poleward in NH).
|
||||
//
|
||||
// Zonal wind belts (Earth-like):
|
||||
// ITCZ (0-5°): light/convergent
|
||||
// Trades (5-30°): strong easterlies, deflected equatorward by Coriolis
|
||||
// Subtropical (25-35°): weak/variable (transition)
|
||||
// Westerlies (35-60°): west→east, deflected poleward
|
||||
// Polar easterlies (60-90°): east→west, deflected equatorward
|
||||
|
||||
function heuristicWind(distFromItczDeg, isNorthOfItcz) {
|
||||
// Sign for hemisphere: +1 if north of ITCZ, -1 if south
|
||||
const hemiSign = isNorthOfItcz ? 1 : -1;
|
||||
let we, wn;
|
||||
|
||||
if (distFromItczDeg < 5) {
|
||||
// ITCZ: light convergent winds — slight equatorward component
|
||||
we = 0;
|
||||
wn = -hemiSign * 0.1;
|
||||
} else if (distFromItczDeg < 30) {
|
||||
// Trade winds: easterlies (blowing westward) with equatorward component
|
||||
// Strength ramps up from ITCZ edge, peaks ~15-20°, fades toward subtropics
|
||||
const tradeStrength = smoothstep(5, 15, distFromItczDeg)
|
||||
* (1 - smoothstep(25, 32, distFromItczDeg));
|
||||
we = -tradeStrength * 0.8; // strong westward
|
||||
wn = -hemiSign * tradeStrength * 0.3; // equatorward (toward ITCZ)
|
||||
} else if (distFromItczDeg < 60) {
|
||||
// Westerlies: blowing eastward with poleward component
|
||||
const westStrength = smoothstep(30, 40, distFromItczDeg)
|
||||
* (1 - smoothstep(55, 65, distFromItczDeg));
|
||||
we = westStrength * 0.9; // strong eastward
|
||||
wn = hemiSign * westStrength * 0.25; // poleward
|
||||
} else {
|
||||
// Polar easterlies: blowing westward with equatorward component
|
||||
const polarStrength = smoothstep(60, 70, distFromItczDeg);
|
||||
we = -polarStrength * 0.4; // moderate westward
|
||||
wn = -hemiSign * polarStrength * 0.15; // equatorward
|
||||
}
|
||||
|
||||
return { we, wn };
|
||||
}
|
||||
|
||||
// ── Heuristic wind field for a full season ──────────────────────────────────
|
||||
// Computes idealized zonal wind E/N arrays for all regions.
|
||||
|
||||
export function computeHeuristicWindField(numRegions, r_lat, r_lon, itczLookup) {
|
||||
const hWindE = new Float32Array(numRegions);
|
||||
const hWindN = new Float32Array(numRegions);
|
||||
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
const lat = r_lat[r];
|
||||
const itczLat = itczLookup(r_lon[r]) * 0.3; // dampened ITCZ, same as precip
|
||||
const signedDist = lat - itczLat;
|
||||
const distDeg = Math.abs(signedDist) / DEG;
|
||||
const northOfItcz = signedDist > 0;
|
||||
const { we, wn } = heuristicWind(distDeg, northOfItcz);
|
||||
hWindE[r] = we;
|
||||
hWindN[r] = wn;
|
||||
}
|
||||
|
||||
return { hWindE, hWindN };
|
||||
}
|
||||
|
||||
// ── Main entry point ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute heuristic precipitation for both seasons.
|
||||
* Returns raw (un-normalized) Float32Arrays.
|
||||
*
|
||||
* @param {SphereMesh} mesh
|
||||
* @param {Float32Array} r_xyz
|
||||
* @param {Float32Array} r_elevation
|
||||
* @param {object} windResult - output from computeWind()
|
||||
* @param {Float32Array} r_elevGradE - pre-computed east elevation gradient
|
||||
* @param {Float32Array} r_elevGradN - pre-computed north elevation gradient
|
||||
* @param {Int32Array} r_coastDistLand - BFS hop distance from coast through land
|
||||
* @returns {{ r_precip_summer, r_precip_winter }}
|
||||
*/
|
||||
export function computeHeuristicPrecipitation(mesh, r_xyz, r_elevation, windResult, r_elevGradE, r_elevGradN, r_coastDistLand) {
|
||||
const numRegions = mesh.numRegions;
|
||||
const { r_lat, r_lon, r_isLand, r_continentality } = windResult;
|
||||
|
||||
const avgEdgeKm = (Math.PI * 6371) / Math.sqrt(numRegions);
|
||||
|
||||
// Precompute west-coast proximity: positive = west coast, negative = east coast.
|
||||
// Coastal land cells check which side ocean is on relative to the local east
|
||||
// direction, then the signal is smoothed ~300 km inland through land only.
|
||||
const { r_eastX, r_eastY, r_eastZ } = windResult;
|
||||
const { adjOffset, adjList } = mesh;
|
||||
const r_westCoast = new Float32Array(numRegions);
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
if (!r_isLand[r] || r_coastDistLand[r] !== 0) continue;
|
||||
let oceanDotEast = 0;
|
||||
let count = 0;
|
||||
const end = adjOffset[r + 1];
|
||||
for (let ni = adjOffset[r]; ni < end; ni++) {
|
||||
const nb = adjList[ni];
|
||||
if (!r_isLand[nb]) {
|
||||
const dx = r_xyz[3 * nb] - r_xyz[3 * r];
|
||||
const dy = r_xyz[3 * nb + 1] - r_xyz[3 * r + 1];
|
||||
const dz = r_xyz[3 * nb + 2] - r_xyz[3 * r + 2];
|
||||
oceanDotEast += dx * r_eastX[r] + dy * r_eastY[r] + dz * r_eastZ[r];
|
||||
count++;
|
||||
}
|
||||
}
|
||||
if (count > 0) {
|
||||
// Negative dot = ocean is to the west = west coast
|
||||
r_westCoast[r] = oceanDotEast < 0 ? 1 : -1;
|
||||
}
|
||||
}
|
||||
// Smooth through land only (~300 km) so the signal bleeds inland
|
||||
const wcPasses = Math.max(2, Math.round(300 / avgEdgeKm));
|
||||
const wcTmp = new Float32Array(numRegions);
|
||||
for (let pass = 0; pass < wcPasses; pass++) {
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
if (!r_isLand[r]) { wcTmp[r] = 0; continue; }
|
||||
let sum = r_westCoast[r], count = 1;
|
||||
const end = adjOffset[r + 1];
|
||||
for (let ni = adjOffset[r]; ni < end; ni++) {
|
||||
const nb = adjList[ni];
|
||||
if (r_isLand[nb]) { sum += r_westCoast[nb]; count++; }
|
||||
}
|
||||
wcTmp[r] = sum / count;
|
||||
}
|
||||
r_westCoast.set(wcTmp);
|
||||
}
|
||||
|
||||
const result = {};
|
||||
|
||||
const seasons = [
|
||||
{ name: 'summer', shift: 5 },
|
||||
{ name: 'winter', shift: -5 }
|
||||
];
|
||||
|
||||
for (const { name } of seasons) {
|
||||
const isSummer = name === 'summer';
|
||||
|
||||
const itczLookup = makeItczLookup(windResult.itczLons,
|
||||
isSummer ? windResult.itczLatsSummer : windResult.itczLatsWinter);
|
||||
|
||||
const precip = new Float32Array(numRegions);
|
||||
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
const lat = r_lat[r];
|
||||
const lon = r_lon[r];
|
||||
|
||||
// ── A. Zonal base curve (distance from ITCZ) ──
|
||||
// Dampen ITCZ shift: use only 30% of the complex model's ITCZ
|
||||
// displacement so the zonal bands stay close to the geographic
|
||||
// equator. The full ITCZ swing (up to 15-20°) would drag the
|
||||
// subtropical desert belt too far, drying the true equator and
|
||||
// wetting the mid-latitudes in the shifted season.
|
||||
const itczLat = itczLookup(lon) * 0.3;
|
||||
const signedDist = lat - itczLat;
|
||||
const distFromItczDeg = Math.abs(signedDist) / DEG;
|
||||
const isNorthOfItcz = signedDist > 0;
|
||||
const zonal = zonalBase(distFromItczDeg);
|
||||
|
||||
// ── B. Seasonal modifier + Mediterranean subtropical suppression ──
|
||||
const absLatDeg = Math.abs(lat) / DEG;
|
||||
const inSummerHemi = isSummer ? (lat >= 0) : (lat < 0);
|
||||
let seasonMod = inSummerHemi ? 1.1 : 0.9;
|
||||
|
||||
// Mediterranean suppression: subtropical highs expand poleward in
|
||||
// local summer, strongly suppressing rainfall at 25-42° latitude.
|
||||
// In local winter the highs retreat equatorward and westerlies
|
||||
// bring rain to these latitudes. This seasonal contrast is the
|
||||
// primary driver of Mediterranean (Cs) climates.
|
||||
// Stronger on west coasts (subtropical highs sit over eastern ocean
|
||||
// basins, drying the adjacent western continental margins) and
|
||||
// weaker on east coasts (onshore tropical moisture counters drying).
|
||||
if (inSummerHemi && absLatDeg > 22 && absLatDeg < 45) {
|
||||
const medSuppress = smoothstep(22, 30, absLatDeg)
|
||||
* (1 - smoothstep(38, 45, absLatDeg));
|
||||
const wc = r_westCoast[r]; // +1 west coast, -1 east coast, 0 inland
|
||||
const strength = 0.15 + wc * 0.20; // 0.35 west coast, 0.15 inland, ~0 east coast
|
||||
seasonMod *= (1 - medSuppress * Math.max(0, strength));
|
||||
}
|
||||
|
||||
// ── C. Continental dryness ──
|
||||
let contMod = 1.0;
|
||||
const cont = (r_isLand[r] && r_continentality) ? r_continentality[r] : 0;
|
||||
if (cont > 0) {
|
||||
contMod = 1.0 - cont * cont * 0.65;
|
||||
}
|
||||
|
||||
// ── D. Orographic rain shadow (using heuristic zonal wind) ──
|
||||
let oroMod = 1.0;
|
||||
if (r_isLand[r] && r_elevation[r] > 0) {
|
||||
const { we, wn } = heuristicWind(distFromItczDeg, isNorthOfItcz);
|
||||
// Wind dot elevation gradient: positive = windward, negative = leeward
|
||||
const windDotGrad = we * r_elevGradE[r] + wn * r_elevGradN[r];
|
||||
|
||||
if (windDotGrad > 0) {
|
||||
// Windward: up to +60% boost
|
||||
const uplift = Math.min(1, windDotGrad * 15);
|
||||
oroMod = 1.0 + uplift * 0.6;
|
||||
} else {
|
||||
// Leeward: up to -70% suppression, scaled by mountain height
|
||||
const heightKm = elevToHeightKm(Math.max(0, r_elevation[r]));
|
||||
const heightScale = Math.min(1, heightKm / 3); // 3km+ = full shadow
|
||||
const shadow = Math.min(1, -windDotGrad * 18);
|
||||
oroMod = Math.max(0.3, 1.0 - shadow * 0.7 * heightScale);
|
||||
}
|
||||
}
|
||||
|
||||
// ── E. Hard distance-from-coast cutoff ──
|
||||
// Fixed 2000-3000km cutoff regardless of latitude.
|
||||
let distMod = 1.0;
|
||||
if (r_isLand[r] && r_coastDistLand[r] > 0) {
|
||||
const distKm = r_coastDistLand[r] * avgEdgeKm;
|
||||
if (distKm > 2000) {
|
||||
distMod = Math.max(0.03, 1 - smoothstep(2000, 3000, distKm));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Final ──
|
||||
precip[r] = Math.max(0.05, zonal * seasonMod * contMod * oroMod * distMod);
|
||||
}
|
||||
|
||||
// Light smoothing ~100km
|
||||
const smoothPasses = Math.max(1, Math.round(100 / avgEdgeKm));
|
||||
smoothField(mesh, precip, smoothPasses);
|
||||
|
||||
result[`r_precip_${name}`] = precip;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,301 @@
|
||||
// Köppen climate classification using the "worldbuilding pasta" band-based
|
||||
// methodology. Two-season (summer/winter) data is used as a proxy for
|
||||
// warmest/coldest month values.
|
||||
//
|
||||
// Approach:
|
||||
// Step 1 – Temperature bands (tropical → temperate → continental → tundra → ice cap)
|
||||
// Step 2 – Arid zones (B) dry in both seasons → desert core + steppe fringe
|
||||
// Step 3 – Precipitation subtypes within each band (A / C / D details)
|
||||
//
|
||||
// IMPORTANT: The simulation labels "summer" and "winter" are NH-centric
|
||||
// (NH summer = June-Aug, NH winter = Dec-Feb). For each cell we determine
|
||||
// the LOCAL warm/cold season from temperature and use that to assign the
|
||||
// correct precipitation pattern (s/w/f). Without this, Mediterranean (Cs)
|
||||
// and monsoon (Cw/Dw) climates are hemisphere-flipped.
|
||||
|
||||
import { smoothstep } from './wind.js';
|
||||
|
||||
/**
|
||||
* Köppen class definitions: ID → { code, name, color [r,g,b] 0-1 }.
|
||||
*/
|
||||
export const KOPPEN_CLASSES = [
|
||||
{ code: 'Ocean', name: 'Ocean', color: [0.29, 0.44, 0.65] }, // #4a6fa5
|
||||
{ code: 'Af', name: 'Tropical rainforest', color: [0.00, 0.00, 1.00] }, // #0000FF
|
||||
{ code: 'Am', name: 'Tropical monsoon', color: [0.00, 0.47, 1.00] }, // #0077FF
|
||||
{ code: 'Aw', name: 'Tropical savanna', color: [0.27, 0.67, 0.98] }, // #46AAFA
|
||||
{ code: 'BWh', name: 'Hot desert', color: [1.00, 0.00, 0.00] }, // #FF0000
|
||||
{ code: 'BWk', name: 'Cold desert', color: [1.00, 0.59, 0.59] }, // #FF9696
|
||||
{ code: 'BSh', name: 'Hot steppe', color: [0.96, 0.65, 0.00] }, // #F5A500
|
||||
{ code: 'BSk', name: 'Cold steppe', color: [1.00, 0.86, 0.39] }, // #FFDB63
|
||||
{ code: 'Cfa', name: 'Humid subtropical', color: [0.78, 1.00, 0.31] }, // #C8FF50
|
||||
{ code: 'Cfb', name: 'Oceanic', color: [0.39, 1.00, 0.31] }, // #64FF50
|
||||
{ code: 'Cfc', name: 'Subpolar oceanic', color: [0.20, 0.78, 0.00] }, // #32C800
|
||||
{ code: 'Csa', name: 'Hot-summer Mediterranean', color: [1.00, 1.00, 0.00] }, // #FFFF00
|
||||
{ code: 'Csb', name: 'Warm-summer Mediterranean', color: [0.78, 0.78, 0.00] }, // #C8C800
|
||||
{ code: 'Csc', name: 'Cold-summer Mediterranean', color: [0.59, 0.59, 0.00] }, // #969600
|
||||
{ code: 'Cwa', name: 'Humid subtropical (monsoon)', color: [0.59, 1.00, 0.59] }, // #96FF96
|
||||
{ code: 'Cwb', name: 'Subtropical highland', color: [0.39, 0.78, 0.39] }, // #63C764
|
||||
{ code: 'Cwc', name: 'Cold subtropical highland', color: [0.20, 0.59, 0.20] }, // #329633
|
||||
{ code: 'Dfa', name: 'Hot-summer continental', color: [0.00, 1.00, 1.00] }, // #00FFFF
|
||||
{ code: 'Dfb', name: 'Warm-summer continental', color: [0.22, 0.78, 1.00] }, // #37C8FF
|
||||
{ code: 'Dfc', name: 'Subarctic', color: [0.00, 0.49, 0.49] }, // #007D7D
|
||||
{ code: 'Dfd', name: 'Extremely cold subarctic', color: [0.00, 0.27, 0.37] }, // #00465F
|
||||
{ code: 'Dsa', name: 'Hot-summer continental (dry summer)', color: [0.90, 0.50, 1.00] }, // #E680FF
|
||||
{ code: 'Dsb', name: 'Warm-summer continental (dry summer)', color: [0.70, 0.35, 0.85] }, // #B359D9
|
||||
{ code: 'Dsc', name: 'Subarctic (dry summer)', color: [0.50, 0.20, 0.65] }, // #8033A6
|
||||
{ code: 'Dsd', name: 'Extremely cold subarctic (dry summer)', color: [0.35, 0.10, 0.45] }, // #591A73
|
||||
{ code: 'Dwa', name: 'Hot-summer continental (monsoon)', color: [0.67, 0.69, 1.00] }, // #ABB1FF
|
||||
{ code: 'Dwb', name: 'Warm-summer continental (monsoon)', color: [0.43, 0.47, 0.78] }, // #6E77C8
|
||||
{ code: 'Dwc', name: 'Subarctic (monsoon)', color: [0.29, 0.31, 0.78] }, // #4A50C8
|
||||
{ code: 'Dwd', name: 'Extremely cold subarctic (monsoon)', color: [0.20, 0.00, 0.53] }, // #320087
|
||||
{ code: 'ET', name: 'Tundra', color: [0.70, 0.70, 0.70] }, // #B2B2B2
|
||||
{ code: 'EF', name: 'Ice cap', color: [0.41, 0.41, 0.41] }, // #686868
|
||||
];
|
||||
|
||||
// Lookup table: KOPPEN_CLASSES code → ID (built once at import time)
|
||||
const CODE_TO_ID = {};
|
||||
KOPPEN_CLASSES.forEach((c, i) => { CODE_TO_ID[c.code] = i; });
|
||||
|
||||
/**
|
||||
* Classify each region into a Köppen climate type using the worldbuilding-
|
||||
* pasta band-based methodology.
|
||||
*
|
||||
* @param {object} mesh - SphereMesh
|
||||
* @param {Float32Array} r_elevation - per-region elevation (<=0 = ocean)
|
||||
* @param {object} tempResult - { r_temperature_summer, r_temperature_winter } (0-1 → -45..+45 C)
|
||||
* @param {object} precipResult - { r_precip_summer, r_precip_winter } (0-1 p95-normalized)
|
||||
* @returns {Uint8Array} r_koppen - per-region class ID (index into KOPPEN_CLASSES)
|
||||
*/
|
||||
export function classifyKoppen(mesh, r_elevation, tempResult, precipResult) {
|
||||
const n = mesh.numRegions;
|
||||
const r_koppen = new Uint8Array(n);
|
||||
|
||||
const tSummer = tempResult.r_temperature_summer;
|
||||
const tWinter = tempResult.r_temperature_winter;
|
||||
const pSummer = precipResult.r_precip_summer;
|
||||
const pWinter = precipResult.r_precip_winter;
|
||||
|
||||
for (let r = 0; r < n; r++) {
|
||||
// ── Ocean ──
|
||||
if (r_elevation[r] <= 0) {
|
||||
r_koppen[r] = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Convert normalised values to physical units ──
|
||||
// Ts/Tw are NH summer/winter proxies — NOT necessarily local warm/cold
|
||||
const Ts = -45 + Math.max(0, Math.min(1, tSummer[r])) * 90;
|
||||
const Tw = -45 + Math.max(0, Math.min(1, tWinter[r])) * 90;
|
||||
const Thot = Math.max(Ts, Tw); // warmest month proxy (°C)
|
||||
const Tcold = Math.min(Ts, Tw); // coldest month proxy (°C)
|
||||
const Tann = (Ts + Tw) / 2;
|
||||
|
||||
// "Shoulder-month" temperature: approximate the temp 2 months before
|
||||
// peak summer. With only 2 seasons we interpolate 2/6 of the way from
|
||||
// peak toward cold. Used for the humid-continental / subarctic split
|
||||
// and for the tempLetter 'b' criterion (4+ months >= 10°C).
|
||||
const Tshoulder = Thot - (Thot - Tcold) * (2 / 6);
|
||||
|
||||
// ── Hemisphere-aware local seasons ──
|
||||
// Determine which simulation season is this cell's LOCAL warm season.
|
||||
// NH cells: sim summer = local summer. SH cells: sim winter = local summer.
|
||||
const localSummerIsSim = Ts >= Tw;
|
||||
|
||||
// Precipitation: each season value ∈ [0,1] represents ~6 months.
|
||||
// Scale to approximate mm for that half-year.
|
||||
const Ps = Math.max(0, pSummer[r]) * 1000; // NH summer half-year mm
|
||||
const Pw = Math.max(0, pWinter[r]) * 1000; // NH winter half-year mm
|
||||
const Pann = Ps + Pw; // annual mm
|
||||
|
||||
// Local summer/winter precipitation (hemisphere-corrected)
|
||||
const PsummerLocal = localSummerIsSim ? Ps : Pw;
|
||||
const PwinterLocal = localSummerIsSim ? Pw : Ps;
|
||||
const PsMonthLocal = PsummerLocal / 6; // avg monthly precip in local summer
|
||||
const PwMonthLocal = PwinterLocal / 6; // avg monthly precip in local winter
|
||||
|
||||
// Estimate driest individual month from the 6-month average.
|
||||
// A 6-month dry-season average of 40mm might contain months ranging from
|
||||
// 10mm to 70mm. The stronger the seasonal contrast (wet vs dry half-year),
|
||||
// the more peaked the distribution within each half-year, so the driest
|
||||
// month is further below the half-year average.
|
||||
// Factor: at equal seasons (ratio=1) → driest ≈ 0.7× average
|
||||
// at strong monsoon (ratio=5+) → driest ≈ 0.35× average
|
||||
const seasonRatio = Math.max(PsMonthLocal, PwMonthLocal) / (Math.min(PsMonthLocal, PwMonthLocal) || 1);
|
||||
const driestFraction = 0.60 - 0.35 * smoothstep(1, 4, seasonRatio);
|
||||
const Pdry = Math.min(PsMonthLocal, PwMonthLocal) * driestFraction;
|
||||
|
||||
// ================================================================
|
||||
// STEP 1 – TEMPERATURE BANDS
|
||||
// ================================================================
|
||||
// Band codes: 'A' tropical, 'C' temperate, 'D' continental,
|
||||
// 'ET' tundra, 'EF' ice cap
|
||||
// Sub-bands for temperate: 'hotSummer' (>=22°C) vs 'coolSummer'
|
||||
// Sub-bands for continental: 'humidCont' (Tshoulder>=10) vs 'subarctic'
|
||||
|
||||
let band;
|
||||
let tempSubBand = ''; // 'hotSummer'|'coolSummer' for C; 'humidCont'|'subarctic' for D
|
||||
|
||||
if (Thot < 0) {
|
||||
// Ice cap: warmest month < 0°C
|
||||
band = 'EF';
|
||||
} else if (Thot < 10) {
|
||||
// Tundra: warmest month 0-10°C
|
||||
band = 'ET';
|
||||
} else if (Tcold >= 18) {
|
||||
// Tropical: coldest month >= 18°C
|
||||
band = 'A';
|
||||
} else if (Tcold >= 0) {
|
||||
// Temperate: coldest month 0-18°C AND warmest >= 10°C
|
||||
band = 'C';
|
||||
tempSubBand = Thot >= 22 ? 'hotSummer' : 'coolSummer';
|
||||
} else {
|
||||
// Continental: coldest month < 0°C AND warmest >= 10°C
|
||||
band = 'D';
|
||||
tempSubBand = Tshoulder >= 10 ? 'humidCont' : 'subarctic';
|
||||
}
|
||||
|
||||
// ── Short-circuit polar types ──
|
||||
if (band === 'EF') { r_koppen[r] = CODE_TO_ID['EF']; continue; }
|
||||
if (band === 'ET') { r_koppen[r] = CODE_TO_ID['ET']; continue; }
|
||||
|
||||
// ================================================================
|
||||
// STEP 2 – ARID ZONES (B)
|
||||
// ================================================================
|
||||
// The blog approach: areas "dry in both seasons" become desert by
|
||||
// default, with steppe as a transition on the edges.
|
||||
//
|
||||
// We use the standard Köppen aridity threshold (which encodes the
|
||||
// idea of evapotranspiration exceeding precipitation) to decide B,
|
||||
// then split desert vs steppe.
|
||||
//
|
||||
// h/k is determined by mean annual temperature (standard Köppen):
|
||||
// Tann >= 18°C → hot (h)
|
||||
// Tann < 18°C → cold (k)
|
||||
//
|
||||
// summerFrac uses LOCAL warm-season precipitation (hemisphere-corrected)
|
||||
// because the threshold encodes evapotranspiration which peaks in
|
||||
// the warm season regardless of hemisphere.
|
||||
|
||||
let Pthresh;
|
||||
const summerFrac = Pann > 0 ? PsummerLocal / Pann : 0.5;
|
||||
if (summerFrac >= 0.7) {
|
||||
Pthresh = 20 * Tann + 280;
|
||||
} else if (summerFrac <= 0.3) {
|
||||
Pthresh = 20 * Tann;
|
||||
} else {
|
||||
Pthresh = 20 * Tann + 140;
|
||||
}
|
||||
Pthresh = Math.max(0, Pthresh);
|
||||
|
||||
if (Pann < Pthresh) {
|
||||
const isHot = Tann >= 18; // standard Köppen: h if mean annual temp >= 18°C
|
||||
if (Pann < Pthresh * 0.5) {
|
||||
// Desert
|
||||
r_koppen[r] = isHot ? CODE_TO_ID['BWh'] : CODE_TO_ID['BWk'];
|
||||
} else {
|
||||
// Steppe (transition fringe)
|
||||
r_koppen[r] = isHot ? CODE_TO_ID['BSh'] : CODE_TO_ID['BSk'];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// STEP 3 – PRECIPITATION SUBTYPES WITHIN EACH BAND
|
||||
// ================================================================
|
||||
|
||||
// ── Determine s / w / f precipitation pattern ──
|
||||
// All comparisons use LOCAL summer/winter so the pattern is correct
|
||||
// in both hemispheres.
|
||||
// Our "monthly" values are 6-month averages, not individual months —
|
||||
// this smooths the driest/wettest month contrast, so thresholds are
|
||||
// relaxed vs. standard Köppen (which uses actual monthly extremes).
|
||||
// s = dry local summer: summer month < 50mm AND < 1/2 winter month
|
||||
// w = dry local winter: winter month < 1/4 summer month
|
||||
// (relaxed from standard 1/10 because 6-month averages compress contrast)
|
||||
// f = no dry season
|
||||
let precipPattern;
|
||||
const localSummerDrier = PsummerLocal < PwinterLocal;
|
||||
if (localSummerDrier && PsMonthLocal < 50 && PsMonthLocal < PwMonthLocal / 2) {
|
||||
precipPattern = 's';
|
||||
} else if (!localSummerDrier && PwMonthLocal < PsMonthLocal / 3) {
|
||||
precipPattern = 'w';
|
||||
} else {
|
||||
precipPattern = 'f';
|
||||
}
|
||||
|
||||
// ── Determine temperature sub-letter (a / b / c / d) ──
|
||||
// a: warmest month >= 22°C
|
||||
// b: warmest < 22°C but 4+ months >= 10°C (proxy: Tshoulder >= 10°C)
|
||||
// c: fewer than 4 months >= 10°C, coldest >= −38°C
|
||||
// d: coldest < −38°C (extreme continental, only for D)
|
||||
let tempLetter;
|
||||
if (Thot >= 22) {
|
||||
tempLetter = 'a';
|
||||
} else if (Tshoulder >= 10) {
|
||||
tempLetter = 'b';
|
||||
} else if (Tcold >= -38) {
|
||||
tempLetter = 'c';
|
||||
} else {
|
||||
tempLetter = 'd';
|
||||
}
|
||||
|
||||
// ── Band A: Tropical ──
|
||||
if (band === 'A') {
|
||||
// Blog approach:
|
||||
// very wet both seasons → Af (tropical rainforest)
|
||||
// wet both seasons → Am (tropical monsoon)
|
||||
// wet one season, dry other → Aw (tropical savanna)
|
||||
//
|
||||
// Translated with thresholds:
|
||||
// Af: driest month >= 60 mm
|
||||
// Am: Pann >= 25*(100 - Pdry) (i.e. enough total rain to sustain forest
|
||||
// despite a short dry spell)
|
||||
// Aw: everything else
|
||||
if (Pdry >= 60) {
|
||||
r_koppen[r] = CODE_TO_ID['Af'];
|
||||
} else if (Pann >= 25 * (100 - Pdry)) {
|
||||
r_koppen[r] = CODE_TO_ID['Am'];
|
||||
} else {
|
||||
r_koppen[r] = CODE_TO_ID['Aw'];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Band C: Temperate ──
|
||||
if (band === 'C') {
|
||||
// Blog approach:
|
||||
// dry local summer → Mediterranean (Cs)
|
||||
// remaining hot-summer → humid subtropical (Cfa / Cwa)
|
||||
// remaining cool-summer → oceanic (Cfb / Cwb / Cfc / Cwc)
|
||||
const code = 'C' + precipPattern + tempLetter;
|
||||
const id = CODE_TO_ID[code];
|
||||
if (id !== undefined) {
|
||||
r_koppen[r] = id;
|
||||
} else {
|
||||
r_koppen[r] = CODE_TO_ID['Cfb'];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Band D: Continental ──
|
||||
if (band === 'D') {
|
||||
// Blog approach:
|
||||
// humid continental (Tshoulder >= 10°C) = Dfa/Dfb/Dsa/Dsb/Dwa/Dwb
|
||||
// subarctic (Tshoulder < 10°C) = Dfc/Dfd/Dsc/Dsd/Dwc/Dwd
|
||||
//
|
||||
// Ds zones appear near Mediterranean regions; Dw zones appear
|
||||
// near regions with strong monsoon effect (far ITCZ excursion).
|
||||
const code = 'D' + precipPattern + tempLetter;
|
||||
const id = CODE_TO_ID[code];
|
||||
if (id !== undefined) {
|
||||
r_koppen[r] = id;
|
||||
} else {
|
||||
const fallback = 'Df' + tempLetter;
|
||||
r_koppen[r] = CODE_TO_ID[fallback] || CODE_TO_ID['Dfc'];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return r_koppen;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,238 @@
|
||||
// Ocean / land assignment.
|
||||
// Targets ~30% land by surface area. numContinents controls how many
|
||||
// separate landmasses to create. Small trapped interior seas are absorbed.
|
||||
|
||||
import { makeRng } from './rng.js';
|
||||
|
||||
export function assignOceanLand(mesh, r_plate, plateSeeds, r_xyz, seed, numContinents, continentSizeVariety = 0, landCoverage = 0.3) {
|
||||
const rng = makeRng(seed + 42);
|
||||
const numRegions = mesh.numRegions;
|
||||
const plateIds = Array.from(plateSeeds);
|
||||
const numPlates = plateIds.length;
|
||||
const { adjOffset, adjList } = mesh;
|
||||
|
||||
// 1. Plate areas and centroids
|
||||
const plateArea = {};
|
||||
const plateCentroid = {};
|
||||
for (const pid of plateIds) {
|
||||
plateArea[pid] = 0;
|
||||
plateCentroid[pid] = [0, 0, 0];
|
||||
}
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
const p = r_plate[r];
|
||||
if (!plateCentroid[p]) { plateArea[p] = 0; plateCentroid[p] = [0, 0, 0]; }
|
||||
plateArea[p]++;
|
||||
plateCentroid[p][0] += r_xyz[3*r];
|
||||
plateCentroid[p][1] += r_xyz[3*r+1];
|
||||
plateCentroid[p][2] += r_xyz[3*r+2];
|
||||
}
|
||||
for (const pid of plateIds) {
|
||||
const a = plateArea[pid] || 1;
|
||||
plateCentroid[pid][0] /= a;
|
||||
plateCentroid[pid][1] /= a;
|
||||
plateCentroid[pid][2] /= a;
|
||||
}
|
||||
|
||||
// 2. Plate adjacency graph + perimeter
|
||||
const plateAdj = {};
|
||||
const platePerim = {};
|
||||
for (const pid of plateIds) { plateAdj[pid] = new Set(); platePerim[pid] = 0; }
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
const myPlate = r_plate[r];
|
||||
let isBoundary = false;
|
||||
for (let ni = adjOffset[r], niEnd = adjOffset[r + 1]; ni < niEnd; ni++) {
|
||||
const nbPlate = r_plate[adjList[ni]];
|
||||
if (myPlate !== nbPlate) {
|
||||
plateAdj[myPlate].add(nbPlate);
|
||||
isBoundary = true;
|
||||
}
|
||||
}
|
||||
if (isBoundary) platePerim[myPlate]++;
|
||||
}
|
||||
|
||||
// Plate compactness
|
||||
const plateCompact = {};
|
||||
let maxCompact = 0;
|
||||
for (const pid of plateIds) {
|
||||
const c = Math.sqrt(plateArea[pid] || 1) / (platePerim[pid] || 1);
|
||||
plateCompact[pid] = c;
|
||||
if (c > maxCompact) maxCompact = c;
|
||||
}
|
||||
if (maxCompact > 0) {
|
||||
for (const pid of plateIds) plateCompact[pid] /= maxCompact;
|
||||
}
|
||||
|
||||
const targetLandArea = landCoverage * numRegions;
|
||||
|
||||
// 3. Pick continent seeds via farthest-point sampling
|
||||
const effectiveNum = Math.min(numContinents, numPlates);
|
||||
const continentSeeds = [];
|
||||
const chosen = new Set();
|
||||
|
||||
const first = plateIds[Math.floor(rng() * numPlates)];
|
||||
continentSeeds.push(first);
|
||||
chosen.add(first);
|
||||
|
||||
for (let s = 1; s < effectiveNum; s++) {
|
||||
const candidates = [];
|
||||
for (const pid of plateIds) {
|
||||
if (chosen.has(pid)) continue;
|
||||
const cx = plateCentroid[pid];
|
||||
let minDist = Infinity;
|
||||
for (const existing of continentSeeds) {
|
||||
const ex = plateCentroid[existing];
|
||||
const dx = cx[0]-ex[0], dy = cx[1]-ex[1], dz = cx[2]-ex[2];
|
||||
const d = dx*dx + dy*dy + dz*dz;
|
||||
if (d < minDist) minDist = d;
|
||||
}
|
||||
const rawAreaFactor = Math.sqrt(numRegions / numPlates) / Math.sqrt(plateArea[pid] || 1);
|
||||
const areaFactor = 1 + (rawAreaFactor - 1) * (1 - continentSizeVariety * 0.5);
|
||||
const compact = 0.3 + 0.7 * plateCompact[pid];
|
||||
candidates.push({ pid, score: minDist * areaFactor * compact });
|
||||
}
|
||||
if (candidates.length === 0) break;
|
||||
candidates.sort((a, b) => b.score - a.score);
|
||||
const topK = Math.min(candidates.length, 3);
|
||||
const pick = candidates[Math.floor(rng() * topK)];
|
||||
continentSeeds.push(pick.pid);
|
||||
chosen.add(pick.pid);
|
||||
}
|
||||
|
||||
// If seeds alone exceed the land budget, trim the largest seeds
|
||||
let seedArea = 0;
|
||||
for (const pid of continentSeeds) seedArea += plateArea[pid];
|
||||
while (continentSeeds.length > 1 && seedArea > targetLandArea) {
|
||||
let maxIdx = 0;
|
||||
for (let i = 1; i < continentSeeds.length; i++) {
|
||||
if (plateArea[continentSeeds[i]] > plateArea[continentSeeds[maxIdx]]) maxIdx = i;
|
||||
}
|
||||
seedArea -= plateArea[continentSeeds[maxIdx]];
|
||||
chosen.delete(continentSeeds[maxIdx]);
|
||||
continentSeeds.splice(maxIdx, 1);
|
||||
}
|
||||
|
||||
// 4. Initialize continent assignment
|
||||
const plateContinent = {};
|
||||
for (let c = 0; c < continentSeeds.length; c++) {
|
||||
plateContinent[continentSeeds[c]] = c;
|
||||
}
|
||||
let landArea = seedArea;
|
||||
|
||||
// 5. Round-robin growth with per-continent targets
|
||||
const growTarget = targetLandArea * 0.9;
|
||||
const numC = continentSeeds.length;
|
||||
|
||||
// Per-continent growth targets: at variety=0 all equal, at variety=1 highly skewed
|
||||
const continentTarget = new Float64Array(numC);
|
||||
const continentArea = new Float64Array(numC);
|
||||
for (let c = 0; c < numC; c++) {
|
||||
continentArea[c] = plateArea[continentSeeds[c]];
|
||||
}
|
||||
|
||||
if (continentSizeVariety > 0 && numC > 1) {
|
||||
const weights = [];
|
||||
for (let c = 0; c < numC; c++) {
|
||||
// Log-normal-ish: at variety=1, weights span ~0.3x to ~3.5x (12:1 ratio)
|
||||
const logWeight = (rng() - 0.5) * continentSizeVariety * 2.5;
|
||||
weights.push(Math.exp(logWeight));
|
||||
}
|
||||
const totalWeight = weights.reduce((a, b) => a + b, 0);
|
||||
for (let c = 0; c < numC; c++) {
|
||||
continentTarget[c] = growTarget * weights[c] / totalWeight;
|
||||
}
|
||||
} else {
|
||||
const equal = growTarget / Math.max(numC, 1);
|
||||
for (let c = 0; c < numC; c++) continentTarget[c] = equal;
|
||||
}
|
||||
|
||||
let progress = true;
|
||||
while (progress && landArea < growTarget) {
|
||||
progress = false;
|
||||
for (let c = 0; c < numC && landArea < growTarget; c++) {
|
||||
// Skip continents that have reached their individual target
|
||||
if (continentArea[c] >= continentTarget[c]) continue;
|
||||
|
||||
const candidates = [];
|
||||
for (const pid of plateIds) {
|
||||
if (plateContinent[pid] !== undefined) continue;
|
||||
let touchesSelf = false, touchesOther = false;
|
||||
let sameCount = 0;
|
||||
for (const adj of plateAdj[pid]) {
|
||||
const ac = plateContinent[adj];
|
||||
if (ac === c) { touchesSelf = true; sameCount++; }
|
||||
else if (ac !== undefined) { touchesOther = true; break; }
|
||||
}
|
||||
if (touchesSelf && !touchesOther) {
|
||||
candidates.push({ pid, score: sameCount + plateCompact[pid] * 3 + rng() * 0.5 });
|
||||
}
|
||||
}
|
||||
if (candidates.length === 0) continue;
|
||||
|
||||
candidates.sort((a, b) => b.score - a.score);
|
||||
const topK = Math.min(candidates.length, 3);
|
||||
const pick = candidates[Math.floor(rng() * topK)];
|
||||
|
||||
plateContinent[pick.pid] = c;
|
||||
continentArea[c] += plateArea[pick.pid];
|
||||
landArea += plateArea[pick.pid];
|
||||
progress = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Absorb trapped interior seas
|
||||
const oceanComponents = [];
|
||||
const visited = new Set();
|
||||
for (const pid of plateIds) {
|
||||
if (plateContinent[pid] !== undefined || visited.has(pid)) continue;
|
||||
const component = [pid];
|
||||
visited.add(pid);
|
||||
for (let qi = 0; qi < component.length; qi++) {
|
||||
for (const adj of plateAdj[component[qi]]) {
|
||||
if (plateContinent[adj] === undefined && !visited.has(adj)) {
|
||||
visited.add(adj);
|
||||
component.push(adj);
|
||||
}
|
||||
}
|
||||
}
|
||||
oceanComponents.push(component);
|
||||
}
|
||||
|
||||
let mainIdx = 0;
|
||||
for (let i = 1; i < oceanComponents.length; i++) {
|
||||
let areaI = 0, areaM = 0;
|
||||
for (const p of oceanComponents[i]) areaI += plateArea[p];
|
||||
for (const p of oceanComponents[mainIdx]) areaM += plateArea[p];
|
||||
if (areaI > areaM) mainIdx = i;
|
||||
}
|
||||
|
||||
const absorbCap = targetLandArea * 1.1;
|
||||
for (let i = 0; i < oceanComponents.length; i++) {
|
||||
if (i === mainIdx) continue;
|
||||
const component = oceanComponents[i];
|
||||
|
||||
const bordering = new Set();
|
||||
for (const op of component) {
|
||||
for (const adj of plateAdj[op]) {
|
||||
if (plateContinent[adj] !== undefined) bordering.add(plateContinent[adj]);
|
||||
}
|
||||
if (bordering.size > 1) break;
|
||||
}
|
||||
|
||||
if (bordering.size === 1) {
|
||||
let compArea = 0;
|
||||
for (const op of component) compArea += plateArea[op];
|
||||
if (landArea + compArea <= absorbCap) {
|
||||
const c = bordering.values().next().value;
|
||||
for (const op of component) plateContinent[op] = c;
|
||||
landArea += compArea;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Build plateIsOcean set
|
||||
const plateIsOcean = new Set();
|
||||
for (const pid of plateIds) {
|
||||
if (plateContinent[pid] === undefined) plateIsOcean.add(pid);
|
||||
}
|
||||
return plateIsOcean;
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
// Ocean current simulation: rule-based geographic approach with wind-belt-driven gyres.
|
||||
// Wind belts drive zonal currents; continental shelves deflect them into gyres.
|
||||
// Warmth is classified geographically: western coasts = warm, eastern coasts = cold.
|
||||
|
||||
console.log('[ocean.js] Module loaded');
|
||||
import { smoothstep } from './wind.js';
|
||||
import { makeItczLookup, percentile } from './climate-util.js';
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
|
||||
// ── Coast distance & classification via BFS ─────────────────────────────────
|
||||
|
||||
function computeCoastFields(mesh, r_xyz, r_isOcean,
|
||||
r_eastX, r_eastY, r_eastZ) {
|
||||
const { adjOffset, adjList, numRegions } = mesh;
|
||||
|
||||
const westSeeds = [];
|
||||
const eastSeeds = [];
|
||||
const allCoastSeeds = [];
|
||||
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
if (!r_isOcean[r]) continue;
|
||||
|
||||
let landDirX = 0, landDirY = 0, landDirZ = 0;
|
||||
let hasLandNeighbor = false;
|
||||
|
||||
const end = adjOffset[r + 1];
|
||||
for (let ni = adjOffset[r]; ni < end; ni++) {
|
||||
const nb = adjList[ni];
|
||||
if (!r_isOcean[nb]) {
|
||||
hasLandNeighbor = true;
|
||||
landDirX += r_xyz[3 * nb] - r_xyz[3 * r];
|
||||
landDirY += r_xyz[3 * nb + 1] - r_xyz[3 * r + 1];
|
||||
landDirZ += r_xyz[3 * nb + 2] - r_xyz[3 * r + 2];
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasLandNeighbor) continue;
|
||||
|
||||
allCoastSeeds.push(r);
|
||||
|
||||
// Project land direction into tangent frame east component
|
||||
const normalE = landDirX * r_eastX[r] + landDirY * r_eastY[r] + landDirZ * r_eastZ[r];
|
||||
|
||||
// normalE < -0.2 → land is to the west → western coast seed
|
||||
// normalE > +0.2 → land is to the east → eastern coast seed
|
||||
if (normalE < -0.2) {
|
||||
westSeeds.push(r);
|
||||
} else if (normalE > 0.2) {
|
||||
eastSeeds.push(r);
|
||||
} else {
|
||||
if (normalE <= 0) westSeeds.push(r);
|
||||
else eastSeeds.push(r);
|
||||
}
|
||||
}
|
||||
|
||||
// BFS: compute hop distance from seed set through ocean cells.
|
||||
// Reuses a single queue array (capacity allocated once) across all three passes.
|
||||
const bfsQueue = new Int32Array(numRegions);
|
||||
|
||||
function bfsDistance(seeds) {
|
||||
const dist = new Int32Array(numRegions);
|
||||
dist.fill(-1);
|
||||
let qLen = 0;
|
||||
for (const s of seeds) {
|
||||
dist[s] = 0;
|
||||
bfsQueue[qLen++] = s;
|
||||
}
|
||||
let head = 0;
|
||||
while (head < qLen) {
|
||||
const r = bfsQueue[head++];
|
||||
const d = dist[r] + 1;
|
||||
const end = adjOffset[r + 1];
|
||||
for (let ni = adjOffset[r]; ni < end; ni++) {
|
||||
const nb = adjList[ni];
|
||||
if (r_isOcean[nb] && dist[nb] === -1) {
|
||||
dist[nb] = d;
|
||||
bfsQueue[qLen++] = nb;
|
||||
}
|
||||
}
|
||||
}
|
||||
return dist;
|
||||
}
|
||||
|
||||
const r_coastDist = bfsDistance(allCoastSeeds);
|
||||
const r_westCoastDist = bfsDistance(westSeeds);
|
||||
const r_eastCoastDist = bfsDistance(eastSeeds);
|
||||
|
||||
return { r_coastDist, r_westCoastDist, r_eastCoastDist };
|
||||
}
|
||||
|
||||
// ── Circumpolar channel detection ───────────────────────────────────────────
|
||||
|
||||
function hasCircumpolarChannel(r_lat, r_lon, r_isOcean, numRegions, targetLat, bandWidth) {
|
||||
const NUM_BINS = 72;
|
||||
const binHasOcean = new Uint8Array(NUM_BINS);
|
||||
const latMin = targetLat - bandWidth;
|
||||
const latMax = targetLat + bandWidth;
|
||||
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
if (!r_isOcean[r]) continue;
|
||||
const lat = r_lat[r];
|
||||
if (lat < latMin || lat > latMax) continue;
|
||||
|
||||
let bin = Math.floor(((r_lon[r] + Math.PI) / (2 * Math.PI)) * NUM_BINS);
|
||||
bin = ((bin % NUM_BINS) + NUM_BINS) % NUM_BINS;
|
||||
binHasOcean[bin] = 1;
|
||||
}
|
||||
|
||||
for (let i = 0; i < NUM_BINS; i++) {
|
||||
if (!binHasOcean[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Geographic heat classification ──────────────────────────────────────────
|
||||
// Warmth is determined by coast type and wind cell. The prevailing wind
|
||||
// direction determines which side of a basin accumulates warm water:
|
||||
// Hadley cell (trades westward): western=warm, eastern=cold
|
||||
// Ferrel cell (westerlies eastward): western=cold, eastern=warm (flipped)
|
||||
// Polar cell (easterlies westward): western=warm, eastern=cold (flipped back)
|
||||
|
||||
function classifyWarmth(r_isOcean, r_lat, numRegions,
|
||||
r_westCoastDist, r_eastCoastDist, fadeRange, seasonalShiftDeg) {
|
||||
const r_warmth = new Float32Array(numRegions);
|
||||
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
if (!r_isOcean[r]) continue;
|
||||
|
||||
// Shifted latitude for cell boundaries (matches wind band shift)
|
||||
const bandLatDeg = Math.abs(r_lat[r] / DEG - seasonalShiftDeg);
|
||||
|
||||
// Wind cell sign: trades/polar push water west (western=warm → +1),
|
||||
// westerlies push water east (western=cold → -1)
|
||||
let cellSign;
|
||||
if (bandLatDeg < 28) {
|
||||
cellSign = 1;
|
||||
} else if (bandLatDeg < 35) {
|
||||
cellSign = 1 - 2 * smoothstep(28, 35, bandLatDeg);
|
||||
} else if (bandLatDeg < 55) {
|
||||
cellSign = -1;
|
||||
} else if (bandLatDeg < 65) {
|
||||
cellSign = -1 + 2 * smoothstep(55, 65, bandLatDeg);
|
||||
} else {
|
||||
cellSign = 1;
|
||||
}
|
||||
|
||||
const wDist = r_westCoastDist[r];
|
||||
const eDist = r_eastCoastDist[r];
|
||||
|
||||
let warm = 0;
|
||||
|
||||
if (wDist >= 0 && wDist < fadeRange) {
|
||||
const t = 1 - wDist / fadeRange;
|
||||
warm += cellSign * t * t;
|
||||
}
|
||||
|
||||
if (eDist >= 0 && eDist < fadeRange) {
|
||||
const t = 1 - eDist / fadeRange;
|
||||
warm -= cellSign * t * t;
|
||||
}
|
||||
|
||||
r_warmth[r] = Math.max(-1, Math.min(1, warm));
|
||||
}
|
||||
|
||||
return r_warmth;
|
||||
}
|
||||
|
||||
// ── Laplacian smoothing (ocean only) ────────────────────────────────────────
|
||||
|
||||
function smoothOcean(mesh, field, r_isOcean, passes) {
|
||||
const { adjOffset, adjList, numRegions } = mesh;
|
||||
const tmp = new Float32Array(numRegions);
|
||||
|
||||
for (let pass = 0; pass < passes; pass++) {
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
if (!r_isOcean[r]) { tmp[r] = field[r]; continue; }
|
||||
|
||||
let sum = field[r], count = 1;
|
||||
const end = adjOffset[r + 1];
|
||||
for (let ni = adjOffset[r]; ni < end; ni++) {
|
||||
const nb = adjList[ni];
|
||||
if (r_isOcean[nb]) {
|
||||
sum += field[nb];
|
||||
count++;
|
||||
}
|
||||
}
|
||||
tmp[r] = sum / count;
|
||||
}
|
||||
field.set(tmp);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main entry point ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute ocean surface currents using rule-based geographic approach.
|
||||
* Wind belts drive zonal currents, continental shelves deflect them into
|
||||
* gyres. Warmth is classified geographically by coast type.
|
||||
*
|
||||
* @param {SphereMesh} mesh
|
||||
* @param {Float32Array} r_xyz - per-region 3D positions
|
||||
* @param {Float32Array} r_elevation - per-region elevation
|
||||
* @param {object} windResult - output from computeWind() (includes lat, lon, sinLat, isLand, tangent frames, ITCZ arrays)
|
||||
* @returns {object} current vectors, warmth, and speed arrays for both seasons
|
||||
*/
|
||||
export function computeOceanCurrents(mesh, r_xyz, r_elevation, windResult) {
|
||||
console.log('[ocean.js] computeOceanCurrents called, numRegions:', mesh.numRegions);
|
||||
const numRegions = mesh.numRegions;
|
||||
const avgEdgeKm = (Math.PI * 6371) / Math.sqrt(numRegions);
|
||||
const timing = [];
|
||||
|
||||
const { r_lat, r_sinLat, r_isLand,
|
||||
r_eastX, r_eastY, r_eastZ,
|
||||
r_northX, r_northY, r_northZ } = windResult;
|
||||
|
||||
// Ocean mask
|
||||
const r_isOcean = new Uint8Array(numRegions);
|
||||
for (let r = 0; r < numRegions; r++) r_isOcean[r] = r_isLand[r] ? 0 : 1;
|
||||
|
||||
// Step 0: Setup — r_lon and ITCZ lookups
|
||||
let t0 = performance.now();
|
||||
let r_lon = windResult.r_lon;
|
||||
if (!r_lon) {
|
||||
r_lon = new Float32Array(numRegions);
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
r_lon[r] = Math.atan2(r_xyz[3 * r], r_xyz[3 * r + 2]);
|
||||
}
|
||||
}
|
||||
|
||||
const itczLookupSummer = makeItczLookup(windResult.itczLons, windResult.itczLatsSummer);
|
||||
const itczLookupWinter = makeItczLookup(windResult.itczLons, windResult.itczLatsWinter);
|
||||
timing.push({ stage: 'Ocean: setup (ITCZ lookup + lon)', ms: performance.now() - t0 });
|
||||
|
||||
// Step 1: Coast distance & classification (shared between seasons)
|
||||
t0 = performance.now();
|
||||
const { r_coastDist, r_westCoastDist, r_eastCoastDist } =
|
||||
computeCoastFields(mesh, r_xyz, r_isOcean,
|
||||
r_eastX, r_eastY, r_eastZ);
|
||||
timing.push({ stage: 'Ocean: coast BFS (3 passes)', ms: performance.now() - t0 });
|
||||
|
||||
// Step 2: Circumpolar channel detection
|
||||
t0 = performance.now();
|
||||
const circumpolarNH = hasCircumpolarChannel(r_lat, r_lon, r_isOcean, numRegions, 60 * DEG, 5 * DEG);
|
||||
const circumpolarSH = hasCircumpolarChannel(r_lat, r_lon, r_isOcean, numRegions, -60 * DEG, 5 * DEG);
|
||||
console.log(`[ocean.js] Circumpolar: NH=${circumpolarNH}, SH=${circumpolarSH}`);
|
||||
timing.push({ stage: 'Ocean: circumpolar detection', ms: performance.now() - t0 });
|
||||
|
||||
// Coast influence threshold
|
||||
const coastThreshold = Math.max(5, Math.round(Math.sqrt(numRegions) * 0.035));
|
||||
// Warmth fade range — extends beyond coast deflection zone
|
||||
const warmthRange = coastThreshold * 2;
|
||||
|
||||
const result = {};
|
||||
const seasons = [
|
||||
{ name: 'summer', itczLookup: itczLookupSummer },
|
||||
{ name: 'winter', itczLookup: itczLookupWinter }
|
||||
];
|
||||
|
||||
for (const { name, itczLookup } of seasons) {
|
||||
// Seasonal shift: wind cells migrate ~5° toward summer hemisphere
|
||||
const seasonalShiftDeg = name === 'summer' ? 5 : -5;
|
||||
|
||||
// Steps 3–4: Wind band classification + current vectors
|
||||
t0 = performance.now();
|
||||
const currentE = new Float32Array(numRegions);
|
||||
const currentN = new Float32Array(numRegions);
|
||||
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
if (!r_isOcean[r]) continue;
|
||||
|
||||
const lat = r_lat[r];
|
||||
const absLatDeg = Math.abs(lat) / DEG;
|
||||
const lon = r_lon[r];
|
||||
const hemisphereSign = lat >= 0 ? 1 : -1;
|
||||
|
||||
// Shifted latitude for wind band boundaries (cells migrate with season)
|
||||
const bandLatDeg = Math.abs(lat / DEG - seasonalShiftDeg);
|
||||
|
||||
// ITCZ latitude at this longitude
|
||||
const itczLat = itczLookup(lon);
|
||||
const distFromItcz = Math.abs(lat - itczLat) / DEG;
|
||||
|
||||
// Step 3: Base zonal flow from wind band (using shifted boundaries)
|
||||
let baseE;
|
||||
if (distFromItcz < 3) {
|
||||
// ITCZ zone: eastward countercurrent at center, blends to westward at edges
|
||||
baseE = 1 - 2 * smoothstep(0, 3, distFromItcz);
|
||||
} else if (bandLatDeg < 30) {
|
||||
// Trade winds: westward
|
||||
baseE = -1;
|
||||
} else if (bandLatDeg < 35) {
|
||||
// Subtropical transition: blend trades → westerlies
|
||||
baseE = -1 + 2 * smoothstep(30, 35, bandLatDeg);
|
||||
} else if (bandLatDeg < 58) {
|
||||
// Ferrel cell / westerlies: eastward
|
||||
baseE = 1;
|
||||
} else if (bandLatDeg < 65) {
|
||||
// Subpolar transition: blend westerlies → polar easterlies
|
||||
baseE = 1 - 1.5 * smoothstep(58, 65, bandLatDeg);
|
||||
} else {
|
||||
// Polar easterlies: weak westward
|
||||
baseE = -0.5;
|
||||
}
|
||||
|
||||
currentE[r] = baseE;
|
||||
currentN[r] = 0;
|
||||
|
||||
// Step 4: Coast deflection
|
||||
const wDist = r_westCoastDist[r];
|
||||
const eDist = r_eastCoastDist[r];
|
||||
|
||||
// Near western coast: strong poleward deflection (warm current)
|
||||
if (wDist >= 0 && wDist < coastThreshold) {
|
||||
const t = 1 - wDist / coastThreshold;
|
||||
const strength = t * t * 2.0; // western intensification ×2
|
||||
currentN[r] += hemisphereSign * strength; // poleward
|
||||
currentE[r] *= (1 - t * t * 0.7);
|
||||
}
|
||||
|
||||
// Near eastern coast: moderate equatorward deflection (cold current)
|
||||
if (eDist >= 0 && eDist < coastThreshold) {
|
||||
const t = 1 - eDist / coastThreshold;
|
||||
const strength = t * t * 0.8; // eastern weaker ×0.8
|
||||
currentN[r] -= hemisphereSign * strength; // equatorward
|
||||
currentE[r] *= (1 - t * t * 0.5);
|
||||
}
|
||||
|
||||
// Circumpolar override (55–75° with open channel)
|
||||
const isCircumpolar = (lat > 0 && circumpolarNH) || (lat < 0 && circumpolarSH);
|
||||
if (isCircumpolar && absLatDeg >= 55 && absLatDeg <= 75) {
|
||||
const cStrength = 1 - Math.abs(absLatDeg - 65) / 10;
|
||||
currentE[r] = currentE[r] * (1 - cStrength) + 1.5 * cStrength;
|
||||
currentN[r] *= (1 - cStrength * 0.8);
|
||||
}
|
||||
}
|
||||
timing.push({ stage: `Ocean: wind bands + vectors (${name})`, ms: performance.now() - t0 });
|
||||
|
||||
// Step 5: Smooth ~125 km (scale-invariant)
|
||||
t0 = performance.now();
|
||||
const oceanSmoothPasses = Math.max(2, Math.round(125 / avgEdgeKm));
|
||||
smoothOcean(mesh, currentE, r_isOcean, oceanSmoothPasses);
|
||||
smoothOcean(mesh, currentN, r_isOcean, oceanSmoothPasses);
|
||||
|
||||
// Zero out land
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
if (!r_isOcean[r]) { currentE[r] = 0; currentN[r] = 0; }
|
||||
}
|
||||
timing.push({ stage: `Ocean: smoothing (${name})`, ms: performance.now() - t0 });
|
||||
|
||||
// Step 6: Geographic warmth classification (coast type, not flow direction)
|
||||
// Smoothed heavily to blend out jagged coastline noise and dilute
|
||||
// small island contributions (few coast cells → weak signal after smoothing).
|
||||
t0 = performance.now();
|
||||
const r_warmth = classifyWarmth(r_isOcean, r_lat, numRegions,
|
||||
r_westCoastDist, r_eastCoastDist, warmthRange, seasonalShiftDeg);
|
||||
const warmthSmoothPasses = Math.max(3, Math.round(900 / avgEdgeKm));
|
||||
smoothOcean(mesh, r_warmth, r_isOcean, warmthSmoothPasses);
|
||||
|
||||
// Step 7: Normalize speed (95th percentile)
|
||||
// Use speed-squared to avoid sqrt in the hot loop; sqrt is monotonic
|
||||
// so percentile on squared values gives the same ranking.
|
||||
const r_speed = new Float32Array(numRegions);
|
||||
const oceanSpeedsSq = new Float32Array(numRegions);
|
||||
let oceanCount = 0;
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
const spdSq = currentE[r] * currentE[r] + currentN[r] * currentN[r];
|
||||
r_speed[r] = spdSq;
|
||||
if (r_isOcean[r] && spdSq > 0) oceanSpeedsSq[oceanCount++] = spdSq;
|
||||
}
|
||||
const p95Sq = percentile(oceanSpeedsSq.subarray(0, oceanCount), 0.95);
|
||||
// Now convert to linear 0-1: speed/p95 = sqrt(spdSq)/sqrt(p95Sq) = sqrt(spdSq/p95Sq)
|
||||
const invP95Sq = 1 / p95Sq;
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
r_speed[r] = Math.min(1, Math.sqrt(r_speed[r] * invP95Sq));
|
||||
}
|
||||
|
||||
console.log(`[Ocean ${name}] coastThreshold=${coastThreshold}, warmthRange=${warmthRange}, p95Sq=${p95Sq.toExponential(3)}, oceanCells=${oceanCount}`);
|
||||
timing.push({ stage: `Ocean: warmth + normalize (${name})`, ms: performance.now() - t0 });
|
||||
|
||||
result[`r_ocean_current_east_${name}`] = currentE;
|
||||
result[`r_ocean_current_north_${name}`] = currentN;
|
||||
result[`r_ocean_speed_${name}`] = r_speed;
|
||||
result[`r_ocean_warmth_${name}`] = r_warmth;
|
||||
}
|
||||
|
||||
result._oceanTiming = timing;
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// Colours and legends for the painted-map layers: the class map, the uplift rate, the
|
||||
// erodibility, the drainage (log area), the slope and the drainage basins.
|
||||
//
|
||||
// Shared by planet-mesh.js (globe, map and export colouring) and import-main.js (the sidebar
|
||||
// legend), so the picture on the globe and the one in the exported PNG are the same picture.
|
||||
|
||||
export const PAINTED_LAYERS = new Set(['paintClass', 'paintUplift', 'paintK', 'flow', 'slope', 'basins', 'paintOverlay']);
|
||||
|
||||
// Export type → debug layer it draws.
|
||||
export const PAINTED_EXPORT_TYPES = {
|
||||
paintclass: 'paintClass',
|
||||
uplift: 'paintUplift',
|
||||
erodibility: 'paintK',
|
||||
flow: 'flow',
|
||||
slope: 'slope',
|
||||
basins: 'basins',
|
||||
overlay: 'paintOverlay',
|
||||
};
|
||||
|
||||
export const PAINTED_EXPORT_LABELS = {
|
||||
paintclass: 'Class Map',
|
||||
uplift: 'Uplift Rate',
|
||||
erodibility: 'Erodibility',
|
||||
flow: 'Drainage',
|
||||
slope: 'Slope',
|
||||
basins: 'Basins',
|
||||
overlay: 'Overlay',
|
||||
};
|
||||
|
||||
const SEA_DARK = [0.05, 0.07, 0.12];
|
||||
const SEA_GREY = [0.16, 0.18, 0.24];
|
||||
|
||||
function lerp3(a, b, t) {
|
||||
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
|
||||
}
|
||||
|
||||
function ramp(stops, t) {
|
||||
if (t <= 0) return stops[0];
|
||||
if (t >= 1) return stops[stops.length - 1];
|
||||
const x = t * (stops.length - 1);
|
||||
const i = Math.floor(x);
|
||||
return lerp3(stops[i], stops[Math.min(stops.length - 1, i + 1)], x - i);
|
||||
}
|
||||
|
||||
// Uplift: dark violet → wine → orange → pale yellow. Sea is dark.
|
||||
const UPLIFT_STOPS = [[0.09, 0.04, 0.20], [0.42, 0.08, 0.36], [0.78, 0.25, 0.22], [0.98, 0.62, 0.12], [1.00, 0.95, 0.65]];
|
||||
// Erodibility: hard rock blue → neutral grey → soft rock orange.
|
||||
const K_STOPS = [[0.20, 0.35, 0.80], [0.55, 0.57, 0.62], [0.95, 0.55, 0.15]];
|
||||
// Drainage: dry ground olive → light blue → white at the trunk rivers.
|
||||
const FLOW_STOPS = [[0.26, 0.28, 0.16], [0.30, 0.40, 0.30], [0.35, 0.62, 0.85], [0.75, 0.90, 1.00], [1.00, 1.00, 1.00]];
|
||||
// Slope: white → yellow → red → near-black.
|
||||
const SLOPE_STOPS = [[0.96, 0.96, 0.94], [0.98, 0.85, 0.30], [0.90, 0.30, 0.10], [0.25, 0.05, 0.05]];
|
||||
|
||||
function basinColor(id) {
|
||||
// Golden-ratio hue per basin, moderate saturation so neighbours differ without shouting.
|
||||
const hue = ((id * 0.6180339887) % 1 + 1) % 1;
|
||||
const s = 0.55, l = 0.55;
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
const f = (t) => {
|
||||
t = ((t % 1) + 1) % 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
return [f(hue + 1 / 3), f(hue), f(hue - 1 / 3)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare a colour function for one painted layer. Returns null when the data is missing.
|
||||
* The returned object has `color(regionIndex)` and the numbers the sidebar legend prints.
|
||||
*/
|
||||
export function preparePaintedLayer(layer, arr, curData) {
|
||||
if (!arr) return null;
|
||||
const painted = curData && curData.painted;
|
||||
const N = arr.length;
|
||||
switch (layer) {
|
||||
case 'paintClass': {
|
||||
const cls = painted && painted.legend && painted.legend.classes;
|
||||
if (!cls) return null;
|
||||
const table = cls.map(c => [c.rgb[0] / 255, c.rgb[1] / 255, c.rgb[2] / 255]);
|
||||
return { color: r => table[arr[r]] || SEA_GREY, classes: cls };
|
||||
}
|
||||
case 'paintUplift': {
|
||||
let max = 0;
|
||||
for (let r = 0; r < N; r++) if (arr[r] > max) max = arr[r];
|
||||
const inv = max > 0 ? 1 / max : 0;
|
||||
return { color: r => arr[r] < 0 ? SEA_DARK : ramp(UPLIFT_STOPS, arr[r] * inv), max };
|
||||
}
|
||||
case 'paintK': {
|
||||
let lo = Infinity, hi = -Infinity;
|
||||
for (let r = 0; r < N; r++) { const v = arr[r]; if (v < 0) continue; if (v < lo) lo = v; if (v > hi) hi = v; }
|
||||
if (!(hi > lo)) { lo = 0.5; hi = 1.5; }
|
||||
// Centre the ramp on k = 1 so "harder than average" and "softer" read as colours.
|
||||
const span = Math.max(hi - 1, 1 - lo, 1e-6);
|
||||
return { color: r => arr[r] < 0 ? SEA_GREY : ramp(K_STOPS, 0.5 + (arr[r] - 1) / (2 * span)), lo, hi };
|
||||
}
|
||||
case 'flow': {
|
||||
let max = 0;
|
||||
for (let r = 0; r < N; r++) if (arr[r] > max) max = arr[r];
|
||||
const inv = max > 0 ? 1 / max : 0;
|
||||
return { color: r => { const v = arr[r]; if (v < 0) return SEA_DARK; const t = v * inv; return ramp(FLOW_STOPS, t * t); }, max };
|
||||
}
|
||||
case 'slope': {
|
||||
const cap = 30;
|
||||
return { color: r => arr[r] < 0 ? SEA_GREY : ramp(SLOPE_STOPS, Math.min(1, arr[r] / cap)), cap };
|
||||
}
|
||||
case 'basins':
|
||||
return { color: r => arr[r] < 0 ? SEA_DARK : basinColor(arr[r]) };
|
||||
case 'paintOverlay': {
|
||||
// The class map halved towards black, so a full-strength mark drawn over it cannot be mistaken
|
||||
// for the ground - the Go tool's map_overlay.png, drawn the same way. The sheet itself is a
|
||||
// texture (painted-overlay-view.js), not a region colour.
|
||||
const cls = painted && painted.legend && painted.legend.classes;
|
||||
if (!cls) return null;
|
||||
const table = cls.map(c => [c.rgb[0] / 510, c.rgb[1] / 510, c.rgb[2] / 510]);
|
||||
return { color: r => table[arr[r]] || SEA_DARK, classes: cls };
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function css(c) { return `rgb(${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)})`; }
|
||||
|
||||
function gradientHTML(stops, labels) {
|
||||
const pcts = stops.map((_, i) => Math.round(i / (stops.length - 1) * 100));
|
||||
const grad = stops.map((c, i) => `${css(c)} ${pcts[i]}%`).join(', ');
|
||||
return `<div class="legend-gradient" style="background:linear-gradient(to right,${grad})"></div>` +
|
||||
`<div class="legend-labels">${labels.map(l => `<span>${l}</span>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
/** Sidebar legend HTML for a painted layer, or '' when the layer has no data. `overlay` is state.overlay. */
|
||||
export function paintedLegendHTML(layer, curData, overlay) {
|
||||
const d = curData;
|
||||
if (!d || !d.debugLayers) return '';
|
||||
const prep = preparePaintedLayer(layer, d.debugLayers[layer], d);
|
||||
if (!prep) return '';
|
||||
switch (layer) {
|
||||
case 'paintClass': {
|
||||
let html = '<div class="legend-classes">';
|
||||
for (const c of prep.classes) {
|
||||
if (c.derived && !c.used) continue;
|
||||
const what = c.sea ? `${c.depthM} m deep` : `${c.upliftMmYr} mm/yr`;
|
||||
html += `<div class="legend-class"><span class="legend-koppen-swatch" style="background:rgb(${c.rgb.join(',')})"></span>${c.name} <span class="legend-class-num">${what}</span></div>`;
|
||||
}
|
||||
return html + '</div>';
|
||||
}
|
||||
case 'paintUplift':
|
||||
return gradientHTML(UPLIFT_STOPS, ['0', 'Uplift, mm/yr', prep.max.toFixed(2)]);
|
||||
case 'paintK':
|
||||
return gradientHTML(K_STOPS, ['Hard rock', 'k = 1', 'Soft rock']);
|
||||
case 'flow':
|
||||
return gradientHTML(FLOW_STOPS, ['Hillslope', 'Drainage area', 'Trunk river']);
|
||||
case 'slope':
|
||||
return gradientHTML(SLOPE_STOPS, ['0°', 'Slope', `${prep.cap}°+`]);
|
||||
case 'basins':
|
||||
return '<div class="legend-labels"><span>One colour per drainage basin, by river mouth</span></div>';
|
||||
case 'paintOverlay': {
|
||||
if (!overlay || !overlay.legend) return '<div class="legend-labels"><span>No overlay loaded</span></div>';
|
||||
const rep = overlay.report;
|
||||
let html = '<div class="legend-classes">';
|
||||
for (const m of overlay.legend.marks) {
|
||||
const share = rep ? 100 * rep.counts[m.index] / rep.total : 0;
|
||||
let what = share > 0 ? share.toFixed(2) + ' %' : 'none';
|
||||
if (m.coastJitter !== null) what += m.coastJitter === 0 ? ', coast pinned' : `, coast \u00d7${m.coastJitter}`;
|
||||
else if (m.kind === 'path') what += ', path';
|
||||
html += `<div class="legend-class"><span class="legend-koppen-swatch" style="background:rgb(${m.rgb.join(',')})"></span>${m.name} <span class="legend-class-num">${what}</span></div>`;
|
||||
}
|
||||
return html + '</div><div class="legend-labels"><span>Marks over the dimmed class map</span></div>';
|
||||
}
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// Showing the overlay: the sheet as a texture draped over the globe and laid over the map.
|
||||
//
|
||||
// A mark is not voted onto the mesh for display, deliberately. The sheet is painted at the template's
|
||||
// resolution - a road is eight pixels wide, a village a few hundred - and a region on this mesh covers
|
||||
// roughly eight pixels each way, so a per-region colour would turn every road into a chain of blobs and lose
|
||||
// the thin strokes entirely. A texture keeps every stroke exactly as painted, which is what an author wants
|
||||
// to check: does the road follow the valley the solve made, is the town on the coast it was drawn against.
|
||||
//
|
||||
// On the globe the texture rides on the planet mesh's own triangles, with longitude and latitude as UVs, so
|
||||
// it follows the relief and is never a shell floating over the mountains. On the map it is one flat quad
|
||||
// over the map mesh, shifted with the centre-longitude slider through the texture offset rather than by
|
||||
// moving the quad, so the seam wraps for free. The class map underneath is dimmed by the Overlay layer
|
||||
// (painted-layers.js) for the same reason the Go tool's map_overlay.png halves the class colours: a
|
||||
// full-strength mark on top of it cannot be mistaken for the ground.
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { scene } from './scene.js';
|
||||
import { state } from './state.js';
|
||||
|
||||
// The largest sheet uploaded to the GPU. A 7738-wide template is 120 MB as RGBA; halved it is 30 and every
|
||||
// stroke survives, because a block keeps the most common mark in it rather than the top-left pixel.
|
||||
const SHEET_MAX_W = 4096;
|
||||
|
||||
const MAP_CLIP_PLANES = [
|
||||
new THREE.Plane(new THREE.Vector3(1, 0, 0), 2),
|
||||
new THREE.Plane(new THREE.Vector3(-1, 0, 0), 2),
|
||||
];
|
||||
|
||||
/**
|
||||
* Draw the mark raster as an RGBA canvas no wider than maxW: each mark in its legend colour, blank
|
||||
* transparent. Downsampling is by block vote over the non-blank marks, so a stroke thinner than the block
|
||||
* still shows.
|
||||
*/
|
||||
export function buildSheetCanvas(overlay, maxW = SHEET_MAX_W) {
|
||||
const { raster, w, h, legend } = overlay;
|
||||
const f = Math.max(1, Math.ceil(w / maxW));
|
||||
const ow = Math.ceil(w / f), oh = Math.ceil(h / f);
|
||||
const cvs = document.createElement('canvas');
|
||||
cvs.width = ow; cvs.height = oh;
|
||||
const ctx = cvs.getContext('2d');
|
||||
const img = ctx.createImageData(ow, oh);
|
||||
const px = img.data;
|
||||
const marks = legend.marks;
|
||||
const counts = new Int32Array(marks.length + 1);
|
||||
for (let oy = 0; oy < oh; oy++) {
|
||||
const y0 = oy * f, y1 = Math.min(h, y0 + f);
|
||||
for (let ox = 0; ox < ow; ox++) {
|
||||
let best = 0;
|
||||
if (f === 1) {
|
||||
best = raster[y0 * w + ox];
|
||||
} else {
|
||||
const x0 = ox * f, x1 = Math.min(w, x0 + f);
|
||||
counts.fill(0);
|
||||
for (let y = y0; y < y1; y++) {
|
||||
const row = y * w;
|
||||
for (let x = x0; x < x1; x++) { const m = raster[row + x]; if (m) counts[m]++; }
|
||||
}
|
||||
let bc = 0;
|
||||
for (let m = 1; m < counts.length; m++) if (counts[m] > bc) { bc = counts[m]; best = m; }
|
||||
}
|
||||
if (!best) continue;
|
||||
const rgb = marks[best - 1].rgb;
|
||||
const o = (oy * ow + ox) * 4;
|
||||
px[o] = rgb[0]; px[o + 1] = rgb[1]; px[o + 2] = rgb[2]; px[o + 3] = 255;
|
||||
}
|
||||
}
|
||||
ctx.putImageData(img, 0, 0);
|
||||
return cvs;
|
||||
}
|
||||
|
||||
function makeTexture(cvs) {
|
||||
const tex = new THREE.CanvasTexture(cvs);
|
||||
tex.wrapS = THREE.RepeatWrapping;
|
||||
tex.wrapT = THREE.ClampToEdgeWrapping;
|
||||
tex.magFilter = THREE.NearestFilter;
|
||||
tex.minFilter = THREE.LinearMipmapLinearFilter;
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
return tex;
|
||||
}
|
||||
|
||||
function disposeOverlayMeshes() {
|
||||
for (const key of ['overlayGlobeMesh', 'overlayMapMesh']) {
|
||||
const m = state[key];
|
||||
if (!m) continue;
|
||||
scene.remove(m);
|
||||
m.geometry.dispose();
|
||||
if (m.material.map && m.material.map !== (state.overlay && state.overlay.texture)) m.material.map.dispose();
|
||||
m.material.dispose();
|
||||
state[key] = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a classified overlay ({ legend, raster, w, h, report, name }) as the current sheet, or null to
|
||||
* remove it. Builds the texture once; the meshes follow the terrain meshes and are rebuilt with them.
|
||||
*/
|
||||
export function setOverlaySheet(overlay) {
|
||||
disposeOverlayMeshes();
|
||||
if (state.overlay && state.overlay.texture) state.overlay.texture.dispose();
|
||||
if (!overlay) { state.overlay = null; return; }
|
||||
overlay.sheet = buildSheetCanvas(overlay);
|
||||
overlay.texture = makeTexture(overlay.sheet);
|
||||
state.overlay = overlay;
|
||||
updateOverlayMeshes();
|
||||
}
|
||||
|
||||
/** Whether the sheet should be on screen: the toggle, or the Overlay layer, which always shows it. */
|
||||
export function overlayWanted() {
|
||||
return !!(state.overlay && state.overlay.texture) && (!!state.overlayVisible || state.debugLayer === 'paintOverlay');
|
||||
}
|
||||
|
||||
/** Show or hide the sheet meshes for the current view without rebuilding them. */
|
||||
export function setOverlayVisible() {
|
||||
const on = overlayWanted();
|
||||
if (state.overlayGlobeMesh) state.overlayGlobeMesh.visible = on && !state.mapMode;
|
||||
if (state.overlayMapMesh) state.overlayMapMesh.visible = on && state.mapMode;
|
||||
}
|
||||
|
||||
/** Follow the centre-longitude slider while it is being dragged: the map mesh moves, the sheet's UVs shift. */
|
||||
export function syncOverlayMapCenter() {
|
||||
const m = state.overlayMapMesh;
|
||||
if (!m || !m.material.map) return;
|
||||
m.material.map.offset.x = (state.mapCenterLon || 0) / (2 * Math.PI);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the sheet meshes over whatever terrain meshes exist now. Called at the end of buildMesh and
|
||||
* buildMapMesh in planet-mesh.js, so a rebuilt globe never keeps a stale sheet.
|
||||
*/
|
||||
export function updateOverlayMeshes() {
|
||||
disposeOverlayMeshes();
|
||||
const ov = state.overlay;
|
||||
if (!ov || !ov.texture) return;
|
||||
|
||||
if (state.planetMesh) {
|
||||
// The planet mesh's own triangles, unindexed, so each vertex is one triangle's and a triangle across
|
||||
// the seam can have its U unwrapped past 1 without touching its neighbours.
|
||||
const pos = state.planetMesh.geometry.getAttribute('position');
|
||||
const n = pos.count;
|
||||
const uv = new Float32Array(n * 2);
|
||||
for (let i = 0; i < n; i += 3) {
|
||||
let umin = 2, umax = -1;
|
||||
for (let k = 0; k < 3; k++) {
|
||||
const x = pos.getX(i + k), y = pos.getY(i + k), z = pos.getZ(i + k);
|
||||
const len = Math.hypot(x, y, z) || 1;
|
||||
const lon = Math.atan2(x, z);
|
||||
const lat = Math.asin(Math.max(-1, Math.min(1, y / len)));
|
||||
const u = (lon / Math.PI + 1) * 0.5;
|
||||
uv[(i + k) * 2] = u;
|
||||
uv[(i + k) * 2 + 1] = 0.5 + lat / Math.PI;
|
||||
if (u < umin) umin = u;
|
||||
if (u > umax) umax = u;
|
||||
}
|
||||
if (umax - umin > 0.5) {
|
||||
for (let k = 0; k < 3; k++) { const j = (i + k) * 2; if (uv[j] < 0.5) uv[j] += 1; }
|
||||
}
|
||||
}
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', pos);
|
||||
geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
|
||||
const mat = new THREE.MeshBasicMaterial({
|
||||
map: ov.texture, transparent: true, depthWrite: false,
|
||||
polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2,
|
||||
});
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.renderOrder = 2;
|
||||
scene.add(mesh);
|
||||
state.overlayGlobeMesh = mesh;
|
||||
}
|
||||
|
||||
if (state.mapMesh) {
|
||||
const geo = new THREE.PlaneGeometry(4, 2);
|
||||
// Its own texture object over the same canvas, because the offset is per texture and the globe's must
|
||||
// stay at zero.
|
||||
const tex = makeTexture(ov.sheet);
|
||||
const mat = new THREE.MeshBasicMaterial({
|
||||
map: tex, transparent: true, depthWrite: false, side: THREE.DoubleSide,
|
||||
clippingPlanes: MAP_CLIP_PLANES,
|
||||
});
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.position.z = 0.0035;
|
||||
mesh.renderOrder = 2;
|
||||
scene.add(mesh);
|
||||
state.overlayMapMesh = mesh;
|
||||
syncOverlayMapCenter();
|
||||
}
|
||||
|
||||
setOverlayVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Composite the sheet over an exported map canvas of width x height, 1:1 where the sheet is at least that
|
||||
* wide and by nearest upscaling where it is not, so marks stay crisp.
|
||||
*/
|
||||
export function compositeOverlaySheet(ctx, width, height) {
|
||||
const ov = state.overlay;
|
||||
if (!ov || !ov.raster) return;
|
||||
const sheet = width >= ov.w ? buildSheetCanvas(ov, ov.w) : buildSheetCanvas(ov, width);
|
||||
const prev = ctx.imageSmoothingEnabled;
|
||||
ctx.imageSmoothingEnabled = sheet.width === width;
|
||||
ctx.drawImage(sheet, 0, 0, width, height);
|
||||
ctx.imageSmoothingEnabled = prev;
|
||||
sheet.width = 0; sheet.height = 0;
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
// The overlay: the second painting beside a class template, ported from Tools/Terrain internal/overlay.
|
||||
//
|
||||
// The class legend answers "what is the rock doing here" and every colour on it changes the terrain. That is
|
||||
// the wrong place to say "a forest grows here", "this is the village", "a road follows this valley" or "leave
|
||||
// this stretch of coast exactly as I drew it". So there is a second image, the same size as the template and
|
||||
// registered to it, whose colours are *marks* rather than classes, with a legend of its own. Two rules are
|
||||
// the whole design, and both are kept here exactly as the Go tool has them:
|
||||
//
|
||||
// - **A mark that no pass reads still travels.** Forests, settlements and roads change no height anywhere;
|
||||
// they are shown over the terrain and reported, and that is all.
|
||||
// - **A mark that a pass does read changes one number.** `coast_jitter` scales how far the waterline
|
||||
// roughening may move the shore inside the mark: 0 pins a hand-drawn coastline exactly as painted, above
|
||||
// 1 chews it harder than the rest of the world. It is the only mark property any pass reads.
|
||||
//
|
||||
// Blank is decided by alpha, never by a colour: an unpainted pixel is transparent, so no colour is spent on
|
||||
// emptiness and an export with a white matte behind it does not turn the world into whatever mark white is
|
||||
// nearest. An opaque pixel further than `match_distance` from every mark is dropped and counted - the rule
|
||||
// the class legend has inverted, because there every pixel must become something and here most of the sheet
|
||||
// is nothing.
|
||||
//
|
||||
// Pure computation, no DOM: the classifier runs on the main thread so the report is on screen before a
|
||||
// solve, and the region sampling runs inside planet-worker.js.
|
||||
|
||||
export const OVERLAY_DEFAULT_MATCH_DISTANCE = 40;
|
||||
export const OVERLAY_DEFAULT_MIN_AREA_PX = 24;
|
||||
export const KIND_AREA = 'area';
|
||||
export const KIND_PATH = 'path';
|
||||
|
||||
/** Parse an overlay legend JSON object (the same schema Tools/Terrain reads). Throws with a readable message. */
|
||||
export function parseOverlayLegend(obj) {
|
||||
if (!obj || typeof obj !== 'object') throw new Error('The overlay legend is not a JSON object');
|
||||
if (!Array.isArray(obj.marks)) throw new Error('The overlay legend has no "marks" array');
|
||||
if (obj.marks.length > 254) throw new Error(`The overlay has ${obj.marks.length} marks; the raster holds 254 plus blank`);
|
||||
|
||||
const seenName = new Map(), seenRGB = new Map();
|
||||
const marks = obj.marks.map((m, i) => {
|
||||
if (!m || typeof m !== 'object') throw new Error(`Mark ${i} is not an object`);
|
||||
if (typeof m.name !== 'string' || !m.name) throw new Error(`Mark ${i} has no name`);
|
||||
if (seenName.has(m.name)) throw new Error(`Marks ${seenName.get(m.name)} and ${i} are both named "${m.name}"`);
|
||||
seenName.set(m.name, i);
|
||||
if (!Array.isArray(m.rgb) || m.rgb.length !== 3) throw new Error(`Mark "${m.name}" has no rgb`);
|
||||
const rgb = m.rgb.map(v => {
|
||||
const n = Math.round(+v);
|
||||
if (!(n >= 0 && n <= 255)) throw new Error(`Mark "${m.name}": rgb ${v} is outside 0..255`);
|
||||
return n;
|
||||
});
|
||||
const key = rgb.join(',');
|
||||
if (seenRGB.has(key)) throw new Error(`Marks "${seenRGB.get(key)}" and "${m.name}" share the colour ${key}; nothing could tell them apart`);
|
||||
seenRGB.set(key, m.name);
|
||||
let kind = m.kind || KIND_AREA;
|
||||
if (kind !== KIND_AREA && kind !== KIND_PATH) throw new Error(`Mark "${m.name}": kind "${kind}" is neither "area" nor "path"`);
|
||||
let coastJitter = null;
|
||||
if (m.coast_jitter !== undefined && m.coast_jitter !== null) {
|
||||
coastJitter = +m.coast_jitter;
|
||||
if (!(coastJitter >= 0)) throw new Error(`Mark "${m.name}": coast_jitter is ${m.coast_jitter}; it is a multiplier on how far the waterline may move, so it is never negative`);
|
||||
}
|
||||
const widthM = +m.width_m || 0;
|
||||
if (widthM < 0) throw new Error(`Mark "${m.name}": width_m is ${m.width_m}`);
|
||||
return {
|
||||
index: i + 1, // raster index; 0 is blank
|
||||
name: m.name, rgb, kind, coastJitter, widthM,
|
||||
minAreaPx: +m.min_area_px > 0 ? +m.min_area_px : 0,
|
||||
note: typeof m.note === 'string' ? m.note : '',
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
image: typeof obj.image === 'string' ? obj.image : '',
|
||||
matchDistance: +obj.match_distance > 0 ? +obj.match_distance : OVERLAY_DEFAULT_MATCH_DISTANCE,
|
||||
minAreaPx: +obj.min_area_px > 0 ? +obj.min_area_px : OVERLAY_DEFAULT_MIN_AREA_PX,
|
||||
marks,
|
||||
source: obj,
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether any mark asks anything of the coast, which is the only reason a solve has to know about the sheet. */
|
||||
export function overlayTouchesCoast(legend) {
|
||||
return legend.marks.some(m => m.coastJitter !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign every pixel of an RGBA sheet to a mark, or to blank (0). Same rule as the Go classifier: alpha below
|
||||
* half is unpainted; otherwise the nearest mark wins if it is within the tolerance, and a colour further than
|
||||
* that from everything is dropped and counted as `far`.
|
||||
*/
|
||||
export function classifyOverlay(rgba, w, h, legend) {
|
||||
const marks = legend.marks;
|
||||
const n = marks.length;
|
||||
const pr = new Int32Array(n), pg = new Int32Array(n), pb = new Int32Array(n);
|
||||
for (let k = 0; k < n; k++) { pr[k] = marks[k].rgb[0]; pg[k] = marks[k].rgb[1]; pb[k] = marks[k].rgb[2]; }
|
||||
const tol2 = legend.matchDistance * legend.matchDistance;
|
||||
|
||||
const total = w * h;
|
||||
const out = new Uint8Array(total);
|
||||
const counts = new Int32Array(n + 1);
|
||||
let blank = 0, far = 0, maxD2 = -1, maxAt = [-1, -1];
|
||||
|
||||
// A flat stroke repeats its colour millions of times, so the last answer is cached: one compare before
|
||||
// any distance is computed. Exact, because the cache is keyed on the full 24-bit colour.
|
||||
let lastKey = -1, lastBest = 0, lastD2 = 0;
|
||||
|
||||
for (let p = 0, o = 0; p < total; p++, o += 4) {
|
||||
if (rgba[o + 3] < 128) { blank++; counts[0]++; continue; }
|
||||
const r = rgba[o], g = rgba[o + 1], b = rgba[o + 2];
|
||||
const key = (r << 16) | (g << 8) | b;
|
||||
if (key !== lastKey) {
|
||||
let best = -1, bestD = 1 << 30;
|
||||
for (let k = 0; k < n; k++) {
|
||||
const dr = r - pr[k], dg = g - pg[k], db = b - pb[k];
|
||||
const d = dr * dr + dg * dg + db * db;
|
||||
if (d < bestD) { bestD = d; best = k; }
|
||||
}
|
||||
lastKey = key; lastBest = best; lastD2 = bestD;
|
||||
}
|
||||
if (lastBest < 0 || lastD2 > tol2) {
|
||||
blank++; counts[0]++; far++;
|
||||
if (lastD2 > maxD2) { maxD2 = lastD2; maxAt = [p % w, (p / w) | 0]; }
|
||||
continue;
|
||||
}
|
||||
out[p] = lastBest + 1;
|
||||
counts[lastBest + 1]++;
|
||||
}
|
||||
|
||||
return {
|
||||
marks: out, w, h, counts, total, blank, far,
|
||||
maxDist: maxD2 >= 0 ? Math.sqrt(maxD2) : 0, maxAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** The classifier's report as one line, the way `terrain plan` prints it. */
|
||||
export function overlayReportText(rep) {
|
||||
if (!rep || rep.total === 0) return 'no overlay';
|
||||
const painted = rep.total - rep.blank;
|
||||
let s = `${painted.toLocaleString()} px painted of ${(rep.total / 1e6).toFixed(1)} MP (${(100 * painted / rep.total).toFixed(1)} %)`;
|
||||
if (rep.far > 0) s += `; ${rep.far.toLocaleString()} px match no mark and were dropped (worst ${rep.maxDist.toFixed(0)} at ${rep.maxAt[0]}, ${rep.maxAt[1]})`;
|
||||
return s + '.';
|
||||
}
|
||||
|
||||
const clamp1 = v => Math.max(-1, Math.min(1, v));
|
||||
|
||||
/**
|
||||
* Vote the mark raster onto the mesh. Unlike a class, a mark is sparse - a stroke along a coast is a few
|
||||
* pixels wide - so a plain majority would hand almost every region to blank. A region takes the most common
|
||||
* non-blank mark under it when marks cover at least a third of its footprint, else 0.
|
||||
*/
|
||||
export function sampleMarksToMesh(mesh, r_xyz, markRaster, w, h, numMarks) {
|
||||
const N = mesh.numRegions;
|
||||
const r_mark = new Uint8Array(N);
|
||||
const spacing = Math.sqrt(4 * Math.PI / Math.max(1, N - 1));
|
||||
const pxPerRadX = w / (2 * Math.PI);
|
||||
const pxPerRadY = h / Math.PI;
|
||||
const counts = new Int32Array(numMarks + 1);
|
||||
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);
|
||||
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);
|
||||
let marked = 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;
|
||||
const m = markRaster[row + px];
|
||||
if (m) { counts[m]++; marked++; }
|
||||
}
|
||||
}
|
||||
if (marked * 3 < nx * ny) continue;
|
||||
let best = 0;
|
||||
for (let m = 1; m <= numMarks; m++) if (counts[m] > counts[best]) best = m;
|
||||
r_mark[r] = best;
|
||||
}
|
||||
return r_mark;
|
||||
}
|
||||
|
||||
/**
|
||||
* The coast-jitter multiplier per region, from the marks under it. `markJitter[i]` is the multiplier mark i
|
||||
* asks for, or null when it says nothing; regions with no such mark get 1.
|
||||
*
|
||||
* Painting either side of the waterline is enough: the Go pass reads the mark on the far side of the shore
|
||||
* too, so here a set factor spreads two hops over the mesh, and where two spread factors meet the smaller
|
||||
* wins, because pinning is the deliberate act. Returns the factors and how many regions each way.
|
||||
*/
|
||||
export function jitterPerRegion(mesh, r_mark, markJitter, hops = 2) {
|
||||
const N = mesh.numRegions;
|
||||
const { adjOffset, adjList } = mesh;
|
||||
const f = new Float32Array(N).fill(-1); // -1 is "unset"
|
||||
let queue = [];
|
||||
for (let r = 0; r < N; r++) {
|
||||
const m = r_mark[r];
|
||||
if (!m) continue;
|
||||
const j = markJitter[m];
|
||||
if (j === null || j === undefined) continue;
|
||||
f[r] = j;
|
||||
queue.push(r);
|
||||
}
|
||||
for (let hop = 0; hop < hops && queue.length; hop++) {
|
||||
const next = [];
|
||||
for (const c of queue) {
|
||||
const fc = f[c];
|
||||
for (let k = adjOffset[c], kEnd = adjOffset[c + 1]; k < kEnd; k++) {
|
||||
const nb = adjList[k];
|
||||
if (f[nb] < 0) { f[nb] = fc; next.push(nb); }
|
||||
else if (fc < f[nb] && r_mark[nb] === 0) f[nb] = fc;
|
||||
}
|
||||
}
|
||||
queue = next;
|
||||
}
|
||||
let pinned = 0, marked = 0;
|
||||
for (let r = 0; r < N; r++) {
|
||||
if (f[r] < 0) { f[r] = 1; continue; }
|
||||
marked++;
|
||||
if (f[r] === 0) pinned++;
|
||||
}
|
||||
return { r_jitter: f, pinned, marked };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,924 @@
|
||||
// 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 };
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// Planet code encode/decode — packs seed + slider values into a compact base36 string.
|
||||
// Pure functions, no DOM access.
|
||||
|
||||
// Slider quantization tables
|
||||
const SLIDERS = [
|
||||
{ min: 5000, step: 1000, count: 2556 }, // Detail (N)
|
||||
{ min: 0, step: 0.05, count: 21 }, // Irregularity (jitter)
|
||||
{ min: 4, step: 1, count: 117 }, // Plates (P)
|
||||
{ min: 1, step: 1, count: 10 }, // Continents
|
||||
{ min: 0, step: 0.01, count: 51 }, // Roughness
|
||||
{ min: 0, step: 0.05, count: 21 }, // Smoothing
|
||||
{ min: 0, step: 0.05, count: 21 }, // Glacial Erosion
|
||||
{ min: 0, step: 0.05, count: 21 }, // Hydraulic Erosion
|
||||
{ min: 0, step: 0.05, count: 21 }, // Thermal Erosion
|
||||
{ min: 0, step: 0.05, count: 21 }, // Ridge Sharpening
|
||||
{ min: 0, step: 0.05, count: 21 }, // Soil Creep
|
||||
{ min: 0, step: 0.05, count: 21 }, // Terrain Warp
|
||||
{ min: 0, step: 0.05, count: 21 }, // 12: Continent Size Variety
|
||||
{ min: -15, step: 1, count: 31 }, // 13: Temperature
|
||||
{ min: -1, step: 0.1, count: 21 }, // 14: Precipitation
|
||||
{ min: 0, step: 0.01, count: 101 }, // 15: Land Coverage
|
||||
];
|
||||
|
||||
// Mixed-radix bases (right-to-left): lcIdx, prcIdx, tmpIdx, csvIdx, twIdx, scIdx, rsIdx, teIdx, heIdx, glIdx, smIdx, nsIdx, cnIdx, pIdx, jIdx, nIdx, seed
|
||||
const RADICES = [101, 21, 31, 21, 21, 21, 21, 21, 21, 21, 21, 51, 10, 117, 21, 2556];
|
||||
const SEED_MAX = 16777216; // 2^24
|
||||
const BASE_LEN = 22; // base code length (no toggles)
|
||||
const PREV5_LEN = 21; // previous 21-char codes (before land coverage)
|
||||
const PREV4_LEN = 18; // previous 18-char codes (before continent variety/temp/precip)
|
||||
const PREV3_LEN = 17; // previous 17-char codes (before terrain warp)
|
||||
const PREV2_LEN = 16; // previous 16-char codes (before glacial erosion)
|
||||
const PREV_LEN = 14; // previous 14-char codes (before ridge/creep)
|
||||
const LEGACY_LEN = 13; // legacy 13-char codes (single erosion slider)
|
||||
const IDX_CHARS = 2; // base36 chars per plate index (max index 119 = "3b")
|
||||
|
||||
// Legacy radices for decoding old 13-char codes (single erosion slider)
|
||||
const LEGACY_RADICES = [21, 21, 51, 10, 117, 21, 2559];
|
||||
|
||||
// Previous-gen radices for decoding 14-char codes (two erosion sliders, no ridge/creep)
|
||||
const PREV_RADICES = [21, 21, 21, 51, 10, 117, 21, 2559];
|
||||
|
||||
// Previous2-gen radices for decoding 16-char codes (no glacial erosion)
|
||||
const PREV2_RADICES = [21, 21, 21, 21, 21, 51, 10, 117, 21, 2559];
|
||||
|
||||
// Previous3-gen radices for decoding 17-char codes (no terrain warp)
|
||||
const PREV3_RADICES = [21, 21, 21, 21, 21, 21, 51, 10, 117, 21, 2559];
|
||||
|
||||
// Previous5-gen radices for decoding 21-char codes (before land coverage)
|
||||
const PREV5_RADICES = [21, 31, 21, 21, 21, 21, 21, 21, 21, 21, 51, 10, 117, 21, 2556];
|
||||
|
||||
// Previous4-gen radices for decoding 18-char codes (before continent variety/temp/precip)
|
||||
const PREV4_RADICES = [21, 21, 21, 21, 21, 21, 21, 51, 10, 117, 21, 2556];
|
||||
|
||||
function toIndex(value, slider) {
|
||||
return Math.round((value - slider.min) / slider.step);
|
||||
}
|
||||
|
||||
function fromIndex(idx, slider) {
|
||||
// Round to step precision to avoid floating-point drift
|
||||
const raw = slider.min + idx * slider.step;
|
||||
const decimals = slider.step < 1 ? String(slider.step).split('.')[1].length : 0;
|
||||
return decimals > 0 ? parseFloat(raw.toFixed(decimals)) : raw;
|
||||
}
|
||||
|
||||
/** Parse a base36 string into a BigInt (char-by-char for full precision). */
|
||||
function parseBase36(str) {
|
||||
return [...str].reduce((acc, ch) => {
|
||||
const d = parseInt(ch, 36);
|
||||
if (isNaN(d)) throw new Error('bad char');
|
||||
return acc * 36n + BigInt(d);
|
||||
}, 0n);
|
||||
}
|
||||
|
||||
// Decode format configs: one entry per code length.
|
||||
// fields: [fieldName, SLIDERS_index] in LSB-first extraction order.
|
||||
// defaults: field values not encoded in this format.
|
||||
const DECODE_FORMATS = {
|
||||
[LEGACY_LEN]: {
|
||||
radices: LEGACY_RADICES,
|
||||
fields: [
|
||||
['hydraulicErosion', 7], ['smoothing', 5], ['roughness', 4],
|
||||
['numContinents', 3], ['P', 2], ['jitter', 1], ['N', 0],
|
||||
],
|
||||
defaults: { terrainWarp: 0.5, glacialErosion: 0, thermalErosion: 0.1,
|
||||
ridgeSharpening: 0.35, soilCreep: 0.05, continentSizeVariety: 0,
|
||||
temperatureOffset: 0, precipitationOffset: 0, landCoverage: 0.3 }
|
||||
},
|
||||
[PREV_LEN]: {
|
||||
radices: PREV_RADICES,
|
||||
fields: [
|
||||
['thermalErosion', 8], ['hydraulicErosion', 7], ['smoothing', 5], ['roughness', 4],
|
||||
['numContinents', 3], ['P', 2], ['jitter', 1], ['N', 0],
|
||||
],
|
||||
defaults: { terrainWarp: 0.5, glacialErosion: 0, ridgeSharpening: 0.35,
|
||||
soilCreep: 0.05, continentSizeVariety: 0,
|
||||
temperatureOffset: 0, precipitationOffset: 0, landCoverage: 0.3 }
|
||||
},
|
||||
[PREV2_LEN]: {
|
||||
radices: PREV2_RADICES,
|
||||
fields: [
|
||||
['soilCreep', 10], ['ridgeSharpening', 9], ['thermalErosion', 8], ['hydraulicErosion', 7],
|
||||
['smoothing', 5], ['roughness', 4], ['numContinents', 3], ['P', 2], ['jitter', 1], ['N', 0],
|
||||
],
|
||||
defaults: { terrainWarp: 0.5, glacialErosion: 0, continentSizeVariety: 0,
|
||||
temperatureOffset: 0, precipitationOffset: 0, landCoverage: 0.3 }
|
||||
},
|
||||
[PREV3_LEN]: {
|
||||
radices: PREV3_RADICES,
|
||||
fields: [
|
||||
['soilCreep', 10], ['ridgeSharpening', 9], ['thermalErosion', 8], ['hydraulicErosion', 7],
|
||||
['glacialErosion', 6], ['smoothing', 5], ['roughness', 4],
|
||||
['numContinents', 3], ['P', 2], ['jitter', 1], ['N', 0],
|
||||
],
|
||||
defaults: { terrainWarp: 0.5, continentSizeVariety: 0,
|
||||
temperatureOffset: 0, precipitationOffset: 0, landCoverage: 0.3 }
|
||||
},
|
||||
[PREV4_LEN]: {
|
||||
radices: PREV4_RADICES,
|
||||
fields: [
|
||||
['terrainWarp', 11], ['soilCreep', 10], ['ridgeSharpening', 9],
|
||||
['thermalErosion', 8], ['hydraulicErosion', 7], ['glacialErosion', 6],
|
||||
['smoothing', 5], ['roughness', 4], ['numContinents', 3], ['P', 2], ['jitter', 1], ['N', 0],
|
||||
],
|
||||
defaults: { continentSizeVariety: 0, temperatureOffset: 0, precipitationOffset: 0, landCoverage: 0.3 }
|
||||
},
|
||||
[PREV5_LEN]: {
|
||||
radices: PREV5_RADICES,
|
||||
fields: [
|
||||
['precipitationOffset', 14], ['temperatureOffset', 13], ['continentSizeVariety', 12],
|
||||
['terrainWarp', 11], ['soilCreep', 10], ['ridgeSharpening', 9],
|
||||
['thermalErosion', 8], ['hydraulicErosion', 7], ['glacialErosion', 6],
|
||||
['smoothing', 5], ['roughness', 4], ['numContinents', 3], ['P', 2], ['jitter', 1], ['N', 0],
|
||||
],
|
||||
defaults: { landCoverage: 0.3 }
|
||||
},
|
||||
[BASE_LEN]: {
|
||||
radices: RADICES,
|
||||
fields: [
|
||||
['landCoverage', 15], ['precipitationOffset', 14], ['temperatureOffset', 13],
|
||||
['continentSizeVariety', 12], ['terrainWarp', 11], ['soilCreep', 10], ['ridgeSharpening', 9],
|
||||
['thermalErosion', 8], ['hydraulicErosion', 7], ['glacialErosion', 6],
|
||||
['smoothing', 5], ['roughness', 4], ['numContinents', 3], ['P', 2], ['jitter', 1], ['N', 0],
|
||||
],
|
||||
defaults: {}
|
||||
},
|
||||
};
|
||||
|
||||
/** Generic mixed-radix decode: extract fields LSB-first, validate, convert, apply defaults. */
|
||||
function decodeFormat(packed, config, toggleStr) {
|
||||
const { radices, fields, defaults } = config;
|
||||
const result = {};
|
||||
for (let i = 0; i < radices.length; i++) {
|
||||
const [name, si] = fields[i];
|
||||
const idx = Number(packed % BigInt(radices[i]));
|
||||
packed = packed / BigInt(radices[i]);
|
||||
if (idx >= SLIDERS[si].count) return null;
|
||||
result[name] = fromIndex(idx, SLIDERS[si]);
|
||||
}
|
||||
result.seed = Number(packed);
|
||||
if (result.seed < 0 || result.seed >= SEED_MAX) return null;
|
||||
Object.assign(result, defaults);
|
||||
|
||||
const toggledIndices = [];
|
||||
if (toggleStr) {
|
||||
for (let i = 0; i < toggleStr.length; i += IDX_CHARS) {
|
||||
const idx = parseInt(toggleStr.slice(i, i + IDX_CHARS), 36);
|
||||
if (isNaN(idx) || idx >= result.P) return null;
|
||||
toggledIndices.push(idx);
|
||||
}
|
||||
}
|
||||
result.toggledIndices = toggledIndices;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode planet parameters into a base36 planet code.
|
||||
* @param {number} seed - Integer seed 0–16777215
|
||||
* @param {number} N - Detail (5000–2560000, step 1000)
|
||||
* @param {number} jitter - Irregularity (0–1, step 0.05)
|
||||
* @param {number} P - Plates (4–120, step 1)
|
||||
* @param {number} numContinents - Continents (1–10, step 1)
|
||||
* @param {number} roughness - Roughness (0–0.5, step 0.01)
|
||||
* @param {number} terrainWarp - Terrain Warp (0–1, step 0.05)
|
||||
* @param {number} smoothing - Smoothing (0–1, step 0.05)
|
||||
* @param {number} glacialErosion - Glacial Erosion (0–1, step 0.05)
|
||||
* @param {number} hydraulicErosion - Hydraulic Erosion (0–1, step 0.05)
|
||||
* @param {number} thermalErosion - Thermal Erosion (0–1, step 0.05)
|
||||
* @param {number} ridgeSharpening - Ridge Sharpening (0–1, step 0.05)
|
||||
* @param {number} soilCreep - Soil Creep (0–1, step 0.05)
|
||||
* @param {number} continentSizeVariety - Continent Size Variety (0–1, step 0.05)
|
||||
* @param {number} temperatureOffset - Temperature offset (-15–15, step 1)
|
||||
* @param {number} precipitationOffset - Precipitation offset (-1–1, step 0.1)
|
||||
* @param {number} landCoverage - Land Coverage (0–1, step 0.05)
|
||||
* @param {number[]} [toggledIndices=[]] - Sorted array of toggled plate indices
|
||||
* @returns {string} base36 code (22 chars without edits, 22 + '-' + 2*k with k edits)
|
||||
*/
|
||||
export function encodePlanetCode(seed, N, jitter, P, numContinents, roughness, terrainWarp, smoothing, glacialErosion, hydraulicErosion, thermalErosion, ridgeSharpening, soilCreep, continentSizeVariety, temperatureOffset, precipitationOffset, landCoverage, toggledIndices = []) {
|
||||
const nIdx = toIndex(N, SLIDERS[0]);
|
||||
const jIdx = toIndex(jitter, SLIDERS[1]);
|
||||
const pIdx = toIndex(P, SLIDERS[2]);
|
||||
const cnIdx = toIndex(numContinents, SLIDERS[3]);
|
||||
const nsIdx = toIndex(roughness, SLIDERS[4]);
|
||||
const smIdx = toIndex(smoothing, SLIDERS[5]);
|
||||
const glIdx = toIndex(glacialErosion, SLIDERS[6]);
|
||||
const heIdx = toIndex(hydraulicErosion, SLIDERS[7]);
|
||||
const teIdx = toIndex(thermalErosion, SLIDERS[8]);
|
||||
const rsIdx = toIndex(ridgeSharpening, SLIDERS[9]);
|
||||
const scIdx = toIndex(soilCreep, SLIDERS[10]);
|
||||
const twIdx = toIndex(terrainWarp, SLIDERS[11]);
|
||||
const csvIdx = toIndex(continentSizeVariety, SLIDERS[12]);
|
||||
const tmpIdx = toIndex(temperatureOffset, SLIDERS[13]);
|
||||
const prcIdx = toIndex(precipitationOffset, SLIDERS[14]);
|
||||
const lcIdx = toIndex(landCoverage, SLIDERS[15]);
|
||||
|
||||
// Mixed-radix packing (least-significant first: lcIdx, prcIdx, tmpIdx, csvIdx, twIdx, ...)
|
||||
let packed = BigInt(seed);
|
||||
packed = packed * BigInt(RADICES[15]) + BigInt(nIdx); // * 2556
|
||||
packed = packed * BigInt(RADICES[14]) + BigInt(jIdx); // * 21
|
||||
packed = packed * BigInt(RADICES[13]) + BigInt(pIdx); // * 117
|
||||
packed = packed * BigInt(RADICES[12]) + BigInt(cnIdx); // * 10
|
||||
packed = packed * BigInt(RADICES[11]) + BigInt(nsIdx); // * 51
|
||||
packed = packed * BigInt(RADICES[10]) + BigInt(smIdx); // * 21
|
||||
packed = packed * BigInt(RADICES[9]) + BigInt(glIdx); // * 21
|
||||
packed = packed * BigInt(RADICES[8]) + BigInt(heIdx); // * 21
|
||||
packed = packed * BigInt(RADICES[7]) + BigInt(teIdx); // * 21
|
||||
packed = packed * BigInt(RADICES[6]) + BigInt(rsIdx); // * 21
|
||||
packed = packed * BigInt(RADICES[5]) + BigInt(scIdx); // * 21
|
||||
packed = packed * BigInt(RADICES[4]) + BigInt(twIdx); // * 21
|
||||
packed = packed * BigInt(RADICES[3]) + BigInt(csvIdx); // * 21
|
||||
packed = packed * BigInt(RADICES[2]) + BigInt(tmpIdx); // * 31
|
||||
packed = packed * BigInt(RADICES[1]) + BigInt(prcIdx); // * 21
|
||||
packed = packed * BigInt(RADICES[0]) + BigInt(lcIdx); // * 21
|
||||
|
||||
let code = packed.toString(36).padStart(BASE_LEN, '0');
|
||||
|
||||
// Append toggled plate indices: "-" + 2-char base36 per index
|
||||
if (toggledIndices.length > 0) {
|
||||
code += '-' + toggledIndices
|
||||
.map(i => i.toString(36).padStart(IDX_CHARS, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a base36 planet code back into planet parameters.
|
||||
* Supports 22-char (current), 21-char (prev5), 18-char (prev4), 17-char (prev3), 16-char (prev2), 14-char (previous-gen), and 13-char (legacy) codes.
|
||||
* @param {string} code - base36 code (13, 14, 16, 17, 18, 21, or 22 chars, optionally followed by "-" + toggle indices)
|
||||
* @returns {{ seed: number, N: number, jitter: number, P: number, numContinents: number, roughness: number, terrainWarp: number, smoothing: number, glacialErosion: number, hydraulicErosion: number, thermalErosion: number, ridgeSharpening: number, soilCreep: number, continentSizeVariety: number, temperatureOffset: number, precipitationOffset: number, landCoverage: number, toggledIndices: number[] } | null}
|
||||
*/
|
||||
export function decodePlanetCode(code) {
|
||||
if (typeof code !== 'string') return null;
|
||||
code = code.trim().toLowerCase();
|
||||
|
||||
// Split base code from optional toggle suffix
|
||||
const dashIdx = code.indexOf('-');
|
||||
const base = dashIdx === -1 ? code : code.slice(0, dashIdx);
|
||||
const toggleStr = dashIdx === -1 ? '' : code.slice(dashIdx + 1);
|
||||
|
||||
const config = DECODE_FORMATS[base.length];
|
||||
if (!config) return null;
|
||||
if (!/^[0-9a-z]+$/.test(base)) return null;
|
||||
if (toggleStr && !/^[0-9a-z]+$/.test(toggleStr)) return null;
|
||||
if (toggleStr && toggleStr.length % IDX_CHARS !== 0) return null;
|
||||
|
||||
let packed;
|
||||
try {
|
||||
packed = parseBase36(base);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return decodeFormat(packed, config, toggleStr);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,362 @@
|
||||
// Plate generation — round-robin weighted fill with directional bias.
|
||||
// Each plate gets a random growth rate and preferred direction.
|
||||
|
||||
import { makeRng, makeRandInt } from './rng.js';
|
||||
import {
|
||||
PLATE_LOW_PLATE_T_HIGH, PLATE_LOW_PLATE_T_RANGE,
|
||||
PLATE_RATE_MIN_BASE, PLATE_RATE_MIN_LOW_T,
|
||||
PLATE_RATE_RANGE_BASE, PLATE_RATE_RANGE_LOW_T,
|
||||
PLATE_DIR_BASE_BASE, PLATE_DIR_BASE_LOW_T,
|
||||
PLATE_DIR_SCALE_BASE, PLATE_DIR_SCALE_LOW_T,
|
||||
PLATE_DIR_STRENGTH_CAP,
|
||||
PLATE_COMPACT_BASE, PLATE_COMPACT_LOW_T,
|
||||
PLATE_AREA_GOVERNOR_BASE, PLATE_AREA_GOVERNOR_LOW_T,
|
||||
PLATE_COMPACT_THRESHOLD_MULT, PLATE_COMPACT_PENALTY_MULT,
|
||||
PLATE_OMEGA_MIN, PLATE_OMEGA_RANGE,
|
||||
PLATE_SMOOTH_BASE, PLATE_SMOOTH_LOW_T,
|
||||
PLATE_SMOOTH_FIRST_THRESH, PLATE_SMOOTH_LATER_THRESH,
|
||||
} from './terrain-config.js';
|
||||
|
||||
export function generatePlates(mesh, r_xyz, numPlates, seed) {
|
||||
const { numRegions } = mesh;
|
||||
const r_plate = new Int32Array(numRegions).fill(-1);
|
||||
const rng = makeRng(seed + 0.5);
|
||||
const randInt = makeRandInt(seed);
|
||||
|
||||
// Farthest-point seed distribution with top-3 jitter
|
||||
const plateSeeds = new Set();
|
||||
const isSeed = new Uint8Array(numRegions);
|
||||
const minDistToSeed = new Float32Array(numRegions).fill(Infinity);
|
||||
|
||||
const firstSeed = randInt(numRegions);
|
||||
plateSeeds.add(firstSeed);
|
||||
isSeed[firstSeed] = 1;
|
||||
const fsx = r_xyz[3*firstSeed], fsy = r_xyz[3*firstSeed+1], fsz = r_xyz[3*firstSeed+2];
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
minDistToSeed[r] = 1 - (r_xyz[3*r]*fsx + r_xyz[3*r+1]*fsy + r_xyz[3*r+2]*fsz);
|
||||
}
|
||||
minDistToSeed[firstSeed] = 0;
|
||||
|
||||
while (plateSeeds.size < numPlates && plateSeeds.size < numRegions) {
|
||||
// Find top-3 farthest regions (flat vars, no object allocation)
|
||||
let t0r = -1, t0d = -1, t1r = -1, t1d = -1, t2r = -1, t2d = -1;
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
if (isSeed[r]) continue;
|
||||
const d = minDistToSeed[r];
|
||||
if (d > t2d) {
|
||||
if (d > t0d) {
|
||||
t2r = t1r; t2d = t1d; t1r = t0r; t1d = t0d; t0r = r; t0d = d;
|
||||
} else if (d > t1d) {
|
||||
t2r = t1r; t2d = t1d; t1r = r; t1d = d;
|
||||
} else {
|
||||
t2r = r; t2d = d;
|
||||
}
|
||||
}
|
||||
}
|
||||
let validCount = (t0r !== -1) + (t1r !== -1) + (t2r !== -1);
|
||||
if (!validCount) break;
|
||||
const pick = randInt(validCount);
|
||||
const newSeed = pick === 0 ? t0r : pick === 1 ? t1r : t2r;
|
||||
plateSeeds.add(newSeed);
|
||||
isSeed[newSeed] = 1;
|
||||
const nsx = r_xyz[3*newSeed], nsy = r_xyz[3*newSeed+1], nsz = r_xyz[3*newSeed+2];
|
||||
|
||||
// Fused pass: update minDistToSeed from new seed AND find top-3 for next iteration
|
||||
if (plateSeeds.size < numPlates) {
|
||||
t0r = -1; t0d = -1; t1r = -1; t1d = -1; t2r = -1; t2d = -1;
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
const d = 1 - (r_xyz[3*r]*nsx + r_xyz[3*r+1]*nsy + r_xyz[3*r+2]*nsz);
|
||||
if (d < minDistToSeed[r]) minDistToSeed[r] = d;
|
||||
if (isSeed[r]) continue;
|
||||
const md = minDistToSeed[r];
|
||||
if (md > t2d) {
|
||||
if (md > t0d) {
|
||||
t2r = t1r; t2d = t1d; t1r = t0r; t1d = t0d; t0r = r; t0d = md;
|
||||
} else if (md > t1d) {
|
||||
t2r = t1r; t2d = t1d; t1r = r; t1d = md;
|
||||
} else {
|
||||
t2r = r; t2d = md;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Next iteration can skip the search pass — top-3 is already computed
|
||||
validCount = (t0r !== -1) + (t1r !== -1) + (t2r !== -1);
|
||||
if (!validCount) break;
|
||||
const pick2 = randInt(validCount);
|
||||
const newSeed2 = pick2 === 0 ? t0r : pick2 === 1 ? t1r : t2r;
|
||||
plateSeeds.add(newSeed2);
|
||||
isSeed[newSeed2] = 1;
|
||||
const ns2x = r_xyz[3*newSeed2], ns2y = r_xyz[3*newSeed2+1], ns2z = r_xyz[3*newSeed2+2];
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
const d = 1 - (r_xyz[3*r]*ns2x + r_xyz[3*r+1]*ns2y + r_xyz[3*r+2]*ns2z);
|
||||
if (d < minDistToSeed[r]) minDistToSeed[r] = d;
|
||||
}
|
||||
} else {
|
||||
// Last seed — just update distances (needed for distance field, but loop will exit)
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
const d = 1 - (r_xyz[3*r]*nsx + r_xyz[3*r+1]*nsy + r_xyz[3*r+2]*nsz);
|
||||
if (d < minDistToSeed[r]) minDistToSeed[r] = d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Interpolation factor: more cragginess at low plate counts
|
||||
const lowPlateT = Math.max(0, Math.min(1, (PLATE_LOW_PLATE_T_HIGH - numPlates) / PLATE_LOW_PLATE_T_RANGE));
|
||||
|
||||
// Per-plate growth properties
|
||||
const plateGrowthRate = {};
|
||||
const plateGrowthDir = {};
|
||||
const plateDirStrength = {};
|
||||
|
||||
const rateMin = PLATE_RATE_MIN_BASE - PLATE_RATE_MIN_LOW_T * lowPlateT; // 0.7 → 0.3
|
||||
const rateRange = PLATE_RATE_RANGE_BASE + PLATE_RATE_RANGE_LOW_T * lowPlateT; // 2.3 → 4.7
|
||||
const dirBase = PLATE_DIR_BASE_BASE + PLATE_DIR_BASE_LOW_T * lowPlateT; // 0.15 → 0.4
|
||||
const dirScale = PLATE_DIR_SCALE_BASE + PLATE_DIR_SCALE_LOW_T * lowPlateT; // 0.25 → 0.5
|
||||
|
||||
for (const center of plateSeeds) {
|
||||
plateGrowthRate[center] = rateMin + rng() * rng() * rateRange;
|
||||
|
||||
const px = r_xyz[3*center], py = r_xyz[3*center+1], pz = r_xyz[3*center+2];
|
||||
const pLen = Math.sqrt(px*px + py*py + pz*pz) || 1;
|
||||
const nx = px/pLen, ny = py/pLen, nz = pz/pLen;
|
||||
const rx = rng()-0.5, ry = rng()-0.5, rz = rng()-0.5;
|
||||
const d = rx*nx + ry*ny + rz*nz;
|
||||
let tx = rx - d*nx, ty = ry - d*ny, tz = rz - d*nz;
|
||||
const tLen = Math.sqrt(tx*tx + ty*ty + tz*tz) || 1;
|
||||
plateGrowthDir[center] = [tx/tLen, ty/tLen, tz/tLen];
|
||||
|
||||
plateDirStrength[center] = Math.min(PLATE_DIR_STRENGTH_CAP, rng() * (dirBase + dirScale / plateGrowthRate[center]));
|
||||
}
|
||||
|
||||
// Per-plate frontiers — round-robin ensures every plate advances
|
||||
const plateIds = Array.from(plateSeeds);
|
||||
const frontiers = new Map();
|
||||
const plateAreaCount = {};
|
||||
for (const pid of plateIds) {
|
||||
r_plate[pid] = pid;
|
||||
frontiers.set(pid, [pid]);
|
||||
plateAreaCount[pid] = 1;
|
||||
}
|
||||
|
||||
const { adjOffset, adjList } = mesh;
|
||||
let remaining = numRegions - plateIds.length;
|
||||
const COMPACT_WEIGHT = PLATE_COMPACT_BASE - PLATE_COMPACT_LOW_T * lowPlateT; // 0.3 → 0.08
|
||||
const expectedArea = Math.max(1, (numRegions - plateIds.length) / numPlates);
|
||||
const areaGovernorMult = PLATE_AREA_GOVERNOR_BASE + PLATE_AREA_GOVERNOR_LOW_T * lowPlateT; // 2.0 → 4.0
|
||||
const invNumRegions = 1 / numRegions;
|
||||
|
||||
while (remaining > 0) {
|
||||
let anyProgress = false;
|
||||
for (const pid of plateIds) {
|
||||
const frontier = frontiers.get(pid);
|
||||
if (frontier.length === 0) continue;
|
||||
|
||||
const rate = plateGrowthRate[pid];
|
||||
const dir = plateGrowthDir[pid];
|
||||
const d0 = dir[0], d1 = dir[1], d2 = dir[2];
|
||||
const dirStr = plateDirStrength[pid];
|
||||
const dirStrHalf = dirStr * 0.5;
|
||||
let steps = Math.max(1, Math.ceil(rate * (0.5 + rng())));
|
||||
|
||||
// Governor: halve steps for plates exceeding threshold
|
||||
if (plateAreaCount[pid] > expectedArea * areaGovernorMult) {
|
||||
steps = Math.max(1, Math.ceil(steps * 0.5));
|
||||
}
|
||||
|
||||
// Compactness: expected chord distance for a circular plate of current area
|
||||
const expectedChordDist = Math.sqrt((plateAreaCount[pid] || 1) * invNumRegions / Math.PI) * 2;
|
||||
const compactThreshold = expectedChordDist * PLATE_COMPACT_THRESHOLD_MULT;
|
||||
|
||||
// Precompute seed coordinates
|
||||
const sx = r_xyz[3*pid], sy = r_xyz[3*pid+1], sz = r_xyz[3*pid+2];
|
||||
|
||||
for (let s = 0; s < steps && frontier.length > 0; s++) {
|
||||
let bestIdx = 0, bestScore = -Infinity;
|
||||
const samples = Math.min(frontier.length, 3 + Math.floor(dirStr * 5));
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const idx = randInt(frontier.length);
|
||||
const cell = frontier[idx];
|
||||
const ci = 3*cell;
|
||||
const dx = r_xyz[ci] - sx, dy = r_xyz[ci+1] - sy, dz = r_xyz[ci+2] - sz;
|
||||
const dLenSq = dx*dx + dy*dy + dz*dz;
|
||||
const dLen = Math.sqrt(dLenSq) || 1;
|
||||
const alignment = (dx*d0 + dy*d1 + dz*d2) / dLen;
|
||||
|
||||
// Compactness: seedDist = dLenSq/2 for unit-sphere points
|
||||
const excess = Math.max(0, dLenSq * 0.5 - compactThreshold);
|
||||
const compactPenalty = excess * (COMPACT_WEIGHT * PLATE_COMPACT_PENALTY_MULT);
|
||||
|
||||
const score = alignment * dirStr + rng() * (1 - dirStrHalf) - compactPenalty;
|
||||
if (score > bestScore) { bestScore = score; bestIdx = idx; }
|
||||
}
|
||||
|
||||
const current = frontier[bestIdx];
|
||||
frontier[bestIdx] = frontier[frontier.length - 1];
|
||||
frontier.pop();
|
||||
|
||||
for (let j = adjOffset[current], jEnd = adjOffset[current + 1]; j < jEnd; j++) {
|
||||
const nb = adjList[j];
|
||||
if (r_plate[nb] === -1) {
|
||||
r_plate[nb] = pid;
|
||||
frontier.push(nb);
|
||||
plateAreaCount[pid]++;
|
||||
remaining--;
|
||||
anyProgress = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!anyProgress) break;
|
||||
}
|
||||
|
||||
// Cleanup: assign orphaned regions to nearest claimed neighbor
|
||||
let orphans = true;
|
||||
while (orphans) {
|
||||
orphans = false;
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
if (r_plate[r] === -1) {
|
||||
for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) {
|
||||
const nb = adjList[j];
|
||||
if (r_plate[nb] !== -1) {
|
||||
r_plate[r] = r_plate[nb];
|
||||
orphans = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
smoothAndReconnectPlates(mesh, r_plate, plateSeeds, Math.round(PLATE_SMOOTH_BASE - PLATE_SMOOTH_LOW_T * lowPlateT));
|
||||
|
||||
// Assign an Euler pole + angular velocity per plate
|
||||
const plateVec = {};
|
||||
for (const center of plateSeeds) {
|
||||
// Random Euler pole uniformly distributed on the sphere
|
||||
const theta = rng() * 2 * Math.PI;
|
||||
const cosP = 2 * rng() - 1;
|
||||
const sinP = Math.sqrt(1 - cosP * cosP);
|
||||
const pole = [sinP * Math.cos(theta), sinP * Math.sin(theta), cosP];
|
||||
// Angular velocity: magnitude 0.5–2.0, random sign
|
||||
const omega = (PLATE_OMEGA_MIN + rng() * PLATE_OMEGA_RANGE) * (rng() < 0.5 ? -1 : 1);
|
||||
plateVec[center] = { pole, omega };
|
||||
}
|
||||
|
||||
return { r_plate, plateSeeds, plateVec };
|
||||
}
|
||||
|
||||
/**
|
||||
* Smooth plate boundaries via majority-vote, then reconnect severed plates.
|
||||
* @param {SphereMesh} mesh
|
||||
* @param {Int32Array} r_plate — mutated in place
|
||||
* @param {Set|Array} plateSeeds — seed region IDs (used for connectivity roots & protection)
|
||||
* @param {number} numPasses — number of majority-vote smoothing passes
|
||||
*/
|
||||
export function smoothAndReconnectPlates(mesh, r_plate, plateSeeds, numPasses) {
|
||||
const { numRegions, adjOffset, adjList } = mesh;
|
||||
const plateIds = Array.from(plateSeeds);
|
||||
|
||||
// Build seed lookup for protection during smoothing.
|
||||
// Protects plate seed regions from being reassigned by majority-vote.
|
||||
// After coarse→hi-res projection the seed IDs are coarse-mesh indices
|
||||
// that won't satisfy r_plate[pid] === pid on the hi-res mesh, so the
|
||||
// array stays all-zeros and protection is effectively skipped — this is
|
||||
// intentional since projected boundaries don't need seed anchoring.
|
||||
const isSeed = new Uint8Array(numRegions);
|
||||
for (const pid of plateIds) {
|
||||
if (pid < numRegions && r_plate[pid] === pid) isSeed[pid] = 1;
|
||||
}
|
||||
|
||||
// Smooth boundaries: majority-vote removes thin tendrils
|
||||
let maxDeg = 0;
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
const deg = adjOffset[r + 1] - adjOffset[r];
|
||||
if (deg > maxDeg) maxDeg = deg;
|
||||
}
|
||||
const cntPlates = new Int32Array(maxDeg);
|
||||
const cntValues = new Uint8Array(maxDeg);
|
||||
for (let pass = 0; pass < numPasses; pass++) {
|
||||
const threshold = pass === 0 ? PLATE_SMOOTH_FIRST_THRESH : PLATE_SMOOTH_LATER_THRESH;
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
const rStart = adjOffset[r], rEnd = adjOffset[r + 1];
|
||||
const deg = rEnd - rStart;
|
||||
let nDistinct = 0;
|
||||
for (let j = rStart; j < rEnd; j++) {
|
||||
const p = r_plate[adjList[j]];
|
||||
let found = false;
|
||||
for (let k = 0; k < nDistinct; k++) {
|
||||
if (cntPlates[k] === p) { cntValues[k]++; found = true; break; }
|
||||
}
|
||||
if (!found) { cntPlates[nDistinct] = p; cntValues[nDistinct] = 1; nDistinct++; }
|
||||
}
|
||||
let bestPlate = r_plate[r], bestCount = 0;
|
||||
for (let k = 0; k < nDistinct; k++) {
|
||||
if (cntValues[k] > bestCount) { bestCount = cntValues[k]; bestPlate = cntPlates[k]; }
|
||||
}
|
||||
if (bestCount > deg * threshold && !isSeed[r]) {
|
||||
r_plate[r] = bestPlate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reconnect: smoothing or projection may create disconnected plate fragments.
|
||||
// For each plate, keep the LARGEST connected component and mark the rest
|
||||
// for reassignment. This is stable across resolutions (unlike first-found).
|
||||
{
|
||||
const visited = new Uint8Array(numRegions);
|
||||
// Per-plate: track the largest component's BFS list
|
||||
const bestComponent = {}; // pid → [region indices]
|
||||
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
if (visited[r]) continue;
|
||||
const pid = r_plate[r];
|
||||
const bfs = [r];
|
||||
visited[r] = 1;
|
||||
for (let qi = 0; qi < bfs.length; qi++) {
|
||||
for (let ni = adjOffset[bfs[qi]], niEnd = adjOffset[bfs[qi] + 1]; ni < niEnd; ni++) {
|
||||
const nb = adjList[ni];
|
||||
if (!visited[nb] && r_plate[nb] === pid) {
|
||||
visited[nb] = 1;
|
||||
bfs.push(nb);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!bestComponent[pid] || bfs.length > bestComponent[pid].length) {
|
||||
bestComponent[pid] = bfs;
|
||||
}
|
||||
}
|
||||
|
||||
// Mark regions in the largest component per plate
|
||||
const inMain = new Uint8Array(numRegions);
|
||||
for (const pid of Object.keys(bestComponent)) {
|
||||
for (const r of bestComponent[pid]) inMain[r] = 1;
|
||||
}
|
||||
|
||||
// Reassign orphaned regions (not in their plate's largest component)
|
||||
// via BFS from the main-component boundary
|
||||
const queue = [];
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
if (!inMain[r]) {
|
||||
for (let ni = adjOffset[r], niEnd = adjOffset[r + 1]; ni < niEnd; ni++) {
|
||||
if (inMain[adjList[ni]]) {
|
||||
r_plate[r] = r_plate[adjList[ni]];
|
||||
inMain[r] = 1;
|
||||
queue.push(r);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let qi = 0; qi < queue.length; qi++) {
|
||||
const r = queue[qi];
|
||||
for (let ni = adjOffset[r], niEnd = adjOffset[r + 1]; ni < niEnd; ni++) {
|
||||
const nb = adjList[ni];
|
||||
if (!inMain[nb]) {
|
||||
r_plate[nb] = r_plate[r];
|
||||
inMain[nb] = 1;
|
||||
queue.push(nb);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Greyscale PNG encoding, 8-bit and 16-bit.
|
||||
//
|
||||
// Both are here because the Unreal landscape export needs both - a 16-bit height and an 8-bit weightmap
|
||||
// per paint layer per tile - and because a canvas cannot produce either. toBlob writes 8-bit RGBA and
|
||||
// Unreal's landscape importer wants single-channel, so the weightmaps would have to be un-RGBA'd on the
|
||||
// way in; the heights have no 16-bit canvas path at all. Writing the chunks directly is less code than
|
||||
// working around either.
|
||||
//
|
||||
// Compression is CompressionStream('deflate'), which is the zlib wrapper PNG asks for, not the raw
|
||||
// DEFLATE that 'deflate-raw' would give. Filter 0 (None) on every scanline: the rows here are either
|
||||
// smooth height ramps or near-flat weight fields, and Paeth would cost a pass over the image to save a
|
||||
// few per cent of a file that is written once and read once.
|
||||
|
||||
const _crc32Table = (() => {
|
||||
const t = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
|
||||
t[n] = c;
|
||||
}
|
||||
return t;
|
||||
})();
|
||||
|
||||
function crc32(buf) {
|
||||
let crc = 0xFFFFFFFF;
|
||||
for (let i = 0; i < buf.length; i++) crc = _crc32Table[(crc ^ buf[i]) & 0xFF] ^ (crc >>> 8);
|
||||
return (crc ^ 0xFFFFFFFF) >>> 0;
|
||||
}
|
||||
|
||||
function chunk(type, data) {
|
||||
const out = new Uint8Array(4 + 4 + data.length + 4);
|
||||
const dv = new DataView(out.buffer);
|
||||
dv.setUint32(0, data.length);
|
||||
for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i);
|
||||
out.set(data, 8);
|
||||
dv.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length)));
|
||||
return out;
|
||||
}
|
||||
|
||||
async function assemble(width, height, bitDepth, raw) {
|
||||
const ihdr = new Uint8Array(13);
|
||||
const iv = new DataView(ihdr.buffer);
|
||||
iv.setUint32(0, width);
|
||||
iv.setUint32(4, height);
|
||||
ihdr[8] = bitDepth;
|
||||
ihdr[9] = 0; // colour type 0: greyscale
|
||||
|
||||
const cs = new CompressionStream('deflate');
|
||||
const writer = cs.writable.getWriter();
|
||||
writer.write(raw);
|
||||
writer.close();
|
||||
const body = new Uint8Array(await new Response(cs.readable).arrayBuffer());
|
||||
|
||||
const parts = [
|
||||
new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]),
|
||||
chunk('IHDR', ihdr),
|
||||
chunk('IDAT', body),
|
||||
chunk('IEND', new Uint8Array(0)),
|
||||
];
|
||||
const png = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
||||
let at = 0;
|
||||
for (const part of parts) { png.set(part, at); at += part.length; }
|
||||
return new Blob([png], { type: 'image/png' });
|
||||
}
|
||||
|
||||
/** 16-bit greyscale PNG from a Uint16Array of width * height, row-major. Big-endian, as PNG requires. */
|
||||
export async function encodeGray16(width, height, data) {
|
||||
const rowLen = 1 + width * 2;
|
||||
const raw = new Uint8Array(height * rowLen);
|
||||
for (let y = 0; y < height; y++) {
|
||||
const off = y * rowLen;
|
||||
raw[off] = 0;
|
||||
for (let x = 0; x < width; x++) {
|
||||
const v = data[y * width + x];
|
||||
raw[off + 1 + x * 2] = (v >> 8) & 0xFF;
|
||||
raw[off + 2 + x * 2] = v & 0xFF;
|
||||
}
|
||||
}
|
||||
return assemble(width, height, 16, raw);
|
||||
}
|
||||
|
||||
/** 8-bit greyscale PNG from a Uint8Array of width * height, row-major. */
|
||||
export async function encodeGray8(width, height, data) {
|
||||
const rowLen = 1 + width;
|
||||
const raw = new Uint8Array(height * rowLen);
|
||||
for (let y = 0; y < height; y++) {
|
||||
const off = y * rowLen;
|
||||
raw[off] = 0;
|
||||
raw.set(data.subarray(y * width, (y + 1) * width), off + 1);
|
||||
}
|
||||
return assemble(width, height, 8, raw);
|
||||
}
|
||||
@@ -0,0 +1,686 @@
|
||||
// Precipitation simulation: moisture advection driven by wind, ocean warmth,
|
||||
// orographic effects, ITCZ uplift, frontal convergence, and polar fronts.
|
||||
// Computes per-region precipitation for summer and winter seasons.
|
||||
|
||||
import { smoothstep } from './wind.js';
|
||||
import { computeGradients } from './wind.js';
|
||||
import { elevToHeightKm } from './color-map.js';
|
||||
import { computeHeuristicPrecipitation, computeHeuristicWindField } from './heuristic-precip.js';
|
||||
import { smoothField, makeItczLookup, percentile } from './climate-util.js';
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
|
||||
// ── Wind convergence ─────────────────────────────────────────────────────────
|
||||
// Compute per-region convergence of the wind field. Negative divergence means
|
||||
// winds are piling into a region (frontal zone / ITCZ-like uplift). We measure
|
||||
// this as net inward flux: for each neighbor pair, how much does the neighbor's
|
||||
// wind point toward us vs. our wind point toward the neighbor?
|
||||
|
||||
function computeWindConvergence(mesh, r_xyz,
|
||||
r_wind3dX, r_wind3dY, r_wind3dZ) {
|
||||
const { adjOffset, adjList, numRegions } = mesh;
|
||||
const convergence = new Float32Array(numRegions);
|
||||
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
// Wind at r in 3D (pre-computed)
|
||||
const wdx = r_wind3dX[r];
|
||||
const wdy = r_wind3dY[r];
|
||||
const wdz = r_wind3dZ[r];
|
||||
|
||||
let conv = 0;
|
||||
let count = 0;
|
||||
const end = adjOffset[r + 1];
|
||||
for (let ni = adjOffset[r]; ni < end; ni++) {
|
||||
const nb = adjList[ni];
|
||||
// Direction from r to nb
|
||||
const dx = r_xyz[3 * nb] - r_xyz[3 * r];
|
||||
const dy = r_xyz[3 * nb + 1] - r_xyz[3 * r + 1];
|
||||
const dz = r_xyz[3 * nb + 2] - r_xyz[3 * r + 2];
|
||||
|
||||
// inFlux - outFlux = -(nw·d) - (w·d) = -((nw + w)·d)
|
||||
conv -= (r_wind3dX[nb] + wdx) * dx
|
||||
+ (r_wind3dY[nb] + wdy) * dy
|
||||
+ (r_wind3dZ[nb] + wdz) * dz;
|
||||
count++;
|
||||
}
|
||||
|
||||
// Normalize by neighbor count; positive = converging, negative = diverging
|
||||
convergence[r] = count > 0 ? conv / count : 0;
|
||||
}
|
||||
|
||||
return convergence;
|
||||
}
|
||||
|
||||
// ── Upwind moisture advection ────────────────────────────────────────────────
|
||||
// For each land cell, accumulate moisture from upwind neighbors.
|
||||
// Moisture originates at coast cells proportional to ocean warmth and
|
||||
// depletes with distance and elevation gain.
|
||||
|
||||
function advectMoisture(mesh, r_xyz, r_heightKm, r_isLand,
|
||||
r_windE, r_windN,
|
||||
r_wind3dX, r_wind3dY, r_wind3dZ,
|
||||
r_oceanWarmth, r_coastDistLand, maxHops, avgEdgeKm) {
|
||||
const { adjOffset, adjList, numRegions } = mesh;
|
||||
|
||||
const moisture = new Float32Array(numRegions);
|
||||
|
||||
// Initialize moisture: coastal land cells from adjacent ocean warmth,
|
||||
// ocean cells from their own warmth
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
if (!r_isLand[r]) {
|
||||
// Ocean cells: base moisture proportional to warmth
|
||||
const warmth = r_oceanWarmth ? r_oceanWarmth[r] : 0;
|
||||
moisture[r] = 0.4 + 0.35 * Math.max(0, warmth);
|
||||
continue;
|
||||
}
|
||||
if (r_coastDistLand[r] !== 0) continue; // not a coast cell
|
||||
|
||||
// Coastal land cell — check for onshore wind
|
||||
let warmthSum = 0;
|
||||
let oceanCount = 0;
|
||||
let oceanDirX = 0, oceanDirY = 0, oceanDirZ = 0;
|
||||
const end = adjOffset[r + 1];
|
||||
for (let ni = adjOffset[r]; ni < end; ni++) {
|
||||
const nb = adjList[ni];
|
||||
if (!r_isLand[nb]) {
|
||||
oceanCount++;
|
||||
if (r_oceanWarmth) warmthSum += r_oceanWarmth[nb];
|
||||
oceanDirX += r_xyz[3 * nb] - r_xyz[3 * r];
|
||||
oceanDirY += r_xyz[3 * nb + 1] - r_xyz[3 * r + 1];
|
||||
oceanDirZ += r_xyz[3 * nb + 2] - r_xyz[3 * r + 2];
|
||||
}
|
||||
}
|
||||
if (oceanCount === 0) continue;
|
||||
|
||||
const avgWarmth = warmthSum / oceanCount;
|
||||
|
||||
// Wind direction in 3D (pre-computed)
|
||||
const wdx = r_wind3dX[r];
|
||||
const wdy = r_wind3dY[r];
|
||||
const wdz = r_wind3dZ[r];
|
||||
|
||||
// Onshore = wind blows FROM ocean toward land = wind dot (ocean→region) < 0
|
||||
const windDotOcean = wdx * oceanDirX + wdy * oceanDirY + wdz * oceanDirZ;
|
||||
const onshore = windDotOcean < 0 ? 1.0 : 0.25;
|
||||
|
||||
// Base moisture: warm currents provide more, cold currents less
|
||||
const warmthFactor = 0.5 + 0.5 * Math.max(-0.8, Math.min(1, avgWarmth));
|
||||
moisture[r] = onshore * warmthFactor;
|
||||
}
|
||||
|
||||
// Base friction: ~78% moisture survives the full maxHops
|
||||
// distance over flat terrain. Per-hop retention = 0.78^(1/maxHops).
|
||||
const depletionBase = 1 - Math.pow(0.78, 1 / maxHops);
|
||||
|
||||
// Iterative downwind propagation (ping-pong double-buffering)
|
||||
let src = moisture;
|
||||
let dst = new Float32Array(numRegions);
|
||||
for (let iter = 0; iter < maxHops; iter++) {
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
if (!r_isLand[r]) { dst[r] = src[r]; continue; }
|
||||
|
||||
const we = r_windE[r], wn = r_windN[r];
|
||||
if (we * we + wn * wn < 1e-6) { dst[r] = src[r]; continue; }
|
||||
|
||||
// Wind direction in 3D (pre-computed)
|
||||
const wdx = r_wind3dX[r];
|
||||
const wdy = r_wind3dY[r];
|
||||
const wdz = r_wind3dZ[r];
|
||||
|
||||
// Find upwind neighbors (those where wind at neighbor points toward us)
|
||||
// Track weighted-average upwind elevation for gradient-based depletion
|
||||
let upwindMoisture = 0;
|
||||
let upwindWeight = 0;
|
||||
let upwindHeightSum = 0;
|
||||
const heightHere = r_heightKm[r];
|
||||
const end = adjOffset[r + 1];
|
||||
for (let ni = adjOffset[r]; ni < end; ni++) {
|
||||
const nb = adjList[ni];
|
||||
// Direction from nb to r
|
||||
const dx = r_xyz[3 * r] - r_xyz[3 * nb];
|
||||
const dy = r_xyz[3 * r + 1] - r_xyz[3 * nb + 1];
|
||||
const dz = r_xyz[3 * r + 2] - r_xyz[3 * nb + 2];
|
||||
|
||||
// Alignment: how much does wind at nb point toward r?
|
||||
const dot = r_wind3dX[nb] * dx + r_wind3dY[nb] * dy + r_wind3dZ[nb] * dz;
|
||||
if (dot > 0) {
|
||||
upwindMoisture += src[nb] * dot;
|
||||
upwindHeightSum += r_heightKm[nb] * dot;
|
||||
upwindWeight += dot;
|
||||
}
|
||||
}
|
||||
|
||||
if (upwindWeight > 0) {
|
||||
const incoming = upwindMoisture / upwindWeight;
|
||||
const upwindHeight = upwindHeightSum / upwindWeight;
|
||||
|
||||
// Depletion depends on physical height GAIN (km) from upwind.
|
||||
const heightGain = Math.max(0, heightHere - upwindHeight);
|
||||
|
||||
// Height gain per hop (km) shrinks at higher resolution.
|
||||
// Multiply by maxHops to get total rise over the advection
|
||||
// distance. A ~1 km total rise dumps significant moisture,
|
||||
// ~2 km near-total.
|
||||
const normalizedGain = heightGain * maxHops;
|
||||
const elevDepletion = Math.min(0.8, normalizedGain * 0.55);
|
||||
const depletion = depletionBase + elevDepletion;
|
||||
|
||||
const carried = incoming * Math.max(0, 1 - depletion);
|
||||
dst[r] = Math.max(src[r], carried);
|
||||
} else {
|
||||
dst[r] = src[r];
|
||||
}
|
||||
}
|
||||
|
||||
// Swap buffers
|
||||
const swap = src;
|
||||
src = dst;
|
||||
dst = swap;
|
||||
}
|
||||
|
||||
return src;
|
||||
}
|
||||
|
||||
// ── Main entry point ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute seasonal precipitation fields.
|
||||
*
|
||||
* @param {SphereMesh} mesh
|
||||
* @param {Float32Array} r_xyz - per-region 3D positions
|
||||
* @param {Float32Array} r_elevation - per-region elevation
|
||||
* @param {object} windResult - output from computeWind()
|
||||
* @param {object} oceanResult - output from computeOceanCurrents()
|
||||
* @returns {{ r_precip_summer, r_precip_winter }} normalized 0–1 arrays
|
||||
*/
|
||||
export function computePrecipitation(mesh, r_xyz, r_elevation, windResult, oceanResult, precipitationOffset = 0, landCoverage = 0.3) {
|
||||
console.log('[precipitation.js] computePrecipitation called, numRegions:', mesh.numRegions);
|
||||
const numRegions = mesh.numRegions;
|
||||
const timing = [];
|
||||
|
||||
const { r_lat, r_lon, r_isLand, r_continentality,
|
||||
r_eastX, r_eastY, r_eastZ,
|
||||
r_northX, r_northY, r_northZ } = windResult;
|
||||
|
||||
// Scale-dependent hop count: ~2000 km reach.
|
||||
// Average edge length ≈ π / sqrt(numRegions) radians ≈ (π * 6371) / sqrt(N) km
|
||||
// hops ≈ 2000 / edgeLengthKm
|
||||
const avgEdgeKm = (Math.PI * 6371) / Math.sqrt(numRegions);
|
||||
const avgEdgeRad = Math.PI / Math.sqrt(numRegions);
|
||||
const maxHops = Math.max(8, Math.min(20, Math.round(2000 / avgEdgeKm)));
|
||||
|
||||
// Coast distance through land — reuse BFS already computed by wind.js
|
||||
const r_coastDistLand = windResult.r_coastDistLand;
|
||||
|
||||
// Elevation gradient for orographic detection (shared).
|
||||
// Use a smoothed copy of elevation so local noise/crags don't fragment
|
||||
// the large-scale windward/leeward signal at high resolutions.
|
||||
// Target ~200 km smoothing radius — enough to average out terrain noise
|
||||
// while preserving the broad mountain-range slope.
|
||||
let t0 = performance.now();
|
||||
const elevSmoothPasses = Math.max(2, Math.round(200 / avgEdgeKm));
|
||||
const r_elevSmoothed = new Float32Array(r_elevation);
|
||||
smoothField(mesh, r_elevSmoothed, elevSmoothPasses);
|
||||
// Blend smoothed with actual: keeps broad slope signal but retains some local detail
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
r_elevSmoothed[r] = r_elevSmoothed[r] * 0.6 + r_elevation[r] * 0.4;
|
||||
}
|
||||
const r_elevGradE = new Float32Array(numRegions);
|
||||
const r_elevGradN = new Float32Array(numRegions);
|
||||
computeGradients(mesh, r_xyz, r_elevSmoothed,
|
||||
r_eastX, r_eastY, r_eastZ, r_northX, r_northY, r_northZ,
|
||||
r_elevGradE, r_elevGradN);
|
||||
timing.push({ stage: 'Precip: elevation gradients (smoothed)', ms: performance.now() - t0 });
|
||||
|
||||
// Pre-compute height in km for advection and mechanisms (elevation is constant across seasons)
|
||||
const r_heightKm = new Float32Array(numRegions);
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
r_heightKm[r] = elevToHeightKm(Math.max(0, r_elevation[r]));
|
||||
}
|
||||
|
||||
const result = {};
|
||||
|
||||
const seasons = [
|
||||
{ name: 'summer', shift: 5 },
|
||||
{ name: 'winter', shift: -5 }
|
||||
];
|
||||
|
||||
for (const { name, shift } of seasons) {
|
||||
t0 = performance.now();
|
||||
|
||||
const r_windE_raw = windResult[`r_wind_east_${name}`];
|
||||
const r_windN_raw = windResult[`r_wind_north_${name}`];
|
||||
const r_windSpeed = windResult[`r_wind_speed_${name}`];
|
||||
const r_pressure = windResult[`r_pressure_${name}`];
|
||||
const r_oceanWarmth = oceanResult[`r_ocean_warmth_${name}`];
|
||||
|
||||
const itczLookup = makeItczLookup(windResult.itczLons,
|
||||
name === 'summer' ? windResult.itczLatsSummer : windResult.itczLatsWinter);
|
||||
|
||||
// ── Blend complex wind with heuristic zonal wind (50-50) ──
|
||||
// Smooths out noisy pressure-derived wind patterns, strengthens
|
||||
// zonal consistency for advection and orographic effects.
|
||||
const { hWindE, hWindN } = computeHeuristicWindField(
|
||||
numRegions, r_lat, r_lon, itczLookup);
|
||||
const r_windE = new Float32Array(numRegions);
|
||||
const r_windN = new Float32Array(numRegions);
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
r_windE[r] = 0.5 * r_windE_raw[r] + 0.5 * hWindE[r];
|
||||
r_windN[r] = 0.5 * r_windN_raw[r] + 0.5 * hWindN[r];
|
||||
}
|
||||
|
||||
// Pre-compute 3D wind vectors for convergence and advection
|
||||
const r_wind3dX = new Float32Array(numRegions);
|
||||
const r_wind3dY = new Float32Array(numRegions);
|
||||
const r_wind3dZ = new Float32Array(numRegions);
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
const we = r_windE[r], wn = r_windN[r];
|
||||
r_wind3dX[r] = we * r_eastX[r] + wn * r_northX[r];
|
||||
r_wind3dY[r] = we * r_eastY[r] + wn * r_northY[r];
|
||||
r_wind3dZ[r] = we * r_eastZ[r] + wn * r_northZ[r];
|
||||
}
|
||||
|
||||
// ── Step 1a: Wind convergence field ──
|
||||
// Compute raw convergence then smooth heavily — real fronts are
|
||||
// messy, mobile bands, not sharp lines. The smoothing spreads the
|
||||
// signal over a wide area representing the zone where frontal
|
||||
// weather systems wander over a season.
|
||||
const r_convergence = computeWindConvergence(mesh, r_xyz,
|
||||
r_wind3dX, r_wind3dY, r_wind3dZ);
|
||||
// Smooth ~400 km worth of hops so frontal zones are broad bands
|
||||
const convSmoothPasses = Math.max(3, Math.round(400 / avgEdgeKm));
|
||||
smoothField(mesh, r_convergence, convSmoothPasses);
|
||||
|
||||
// ── Step 1b: Moisture advection from coasts ──
|
||||
const moisture = advectMoisture(mesh, r_xyz, r_heightKm, r_isLand,
|
||||
r_windE, r_windN,
|
||||
r_wind3dX, r_wind3dY, r_wind3dZ,
|
||||
r_oceanWarmth, r_coastDistLand, maxHops, avgEdgeKm);
|
||||
|
||||
const tAdvect = performance.now() - t0;
|
||||
|
||||
// ── Step 2: Apply precipitation mechanisms ──
|
||||
t0 = performance.now();
|
||||
const precip = new Float32Array(numRegions);
|
||||
const rainShadow = new Float32Array(numRegions);
|
||||
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
const lat = r_lat[r];
|
||||
const lon = r_lon[r];
|
||||
const absLatDeg = Math.abs(lat) / DEG;
|
||||
const elev = r_elevation[r];
|
||||
const isLand = r_isLand[r];
|
||||
|
||||
let p = moisture[r];
|
||||
|
||||
// (a) ITCZ uplift: boost moisture within ±15° of ITCZ
|
||||
const itczLat = itczLookup(lon);
|
||||
const distFromItcz = Math.abs(lat - itczLat) / DEG;
|
||||
const cont = (isLand && r_continentality) ? r_continentality[r] : 0;
|
||||
if (distFromItcz < 15) {
|
||||
const itczStrength = smoothstep(15, 0, distFromItcz);
|
||||
// Core ITCZ (within 5°): strong uplift and convective rain
|
||||
const coreBoost = distFromItcz < 5 ? 1.5 : 1.0;
|
||||
p = p * (1 + itczStrength * coreBoost) + itczStrength * 0.3;
|
||||
}
|
||||
|
||||
// (b) Frontal precipitation: actual wind convergence
|
||||
// Where winds collide (convergence > 0) air is forced upward,
|
||||
// creating turbulence and wringing out whatever moisture is present.
|
||||
// This naturally finds frontal zones, ITCZ-like convergence,
|
||||
// and any other place where air masses meet.
|
||||
const conv = r_convergence[r];
|
||||
if (conv > 0) {
|
||||
// Scale convergence: gentle convergence gives mild boost,
|
||||
// strong convergence (opposing air masses) gives large boost.
|
||||
// Only amplifies existing moisture — dry converging air
|
||||
// doesn't produce rain.
|
||||
// Raw convergence ∝ avgEdgeRad (neighbor displacements shrink
|
||||
// at higher resolution), so normalize to make scale-invariant.
|
||||
const convStrength = Math.min(1, (conv / avgEdgeRad) * 0.055);
|
||||
p = p * (1 + convStrength * 1.2) + convStrength * moisture[r] * 0.4;
|
||||
}
|
||||
|
||||
// (c) Orographic effects (land only)
|
||||
// The advection step already handles gradient-based moisture loss
|
||||
// per hop. This step adds the *local* precipitation boost on windward
|
||||
// slopes (forced uplift squeezes out extra rain at that cell) and a
|
||||
// moderate leeward shadow for any remaining moisture.
|
||||
if (isLand && elev > 0) {
|
||||
const we = r_windE[r], wn = r_windN[r];
|
||||
// Windward uplift: wind dot elevation gradient
|
||||
// Positive = wind blows upslope (windward), negative = downslope (leeward)
|
||||
const windDotGrad = we * r_elevGradE[r] + wn * r_elevGradN[r];
|
||||
|
||||
if (windDotGrad > 0) {
|
||||
// Windward: orographic enhancement — the steeper the slope
|
||||
// the wind is pushing up, the more rain wrung out.
|
||||
// gradient strength matters more than absolute height.
|
||||
const uplift = Math.min(1, windDotGrad * 15);
|
||||
p += uplift * 1.0;
|
||||
} else {
|
||||
// Leeward: rain shadow. The advection step already depleted
|
||||
// moisture crossing the ridge; this is the *extra* suppression
|
||||
// from descending/warming air (foehn drying) on the lee side.
|
||||
const shadow = Math.min(1, -windDotGrad * 18);
|
||||
p *= Math.max(0.02, 1 - shadow * 0.95);
|
||||
}
|
||||
}
|
||||
|
||||
// (d) Pressure-driven suppression/enhancement (hybrid)
|
||||
// Start with a gentle latitude-band expectation for subtropical
|
||||
// highs, then let the actual pressure field shift it — so the
|
||||
// effect tracks real geography without being too aggressive.
|
||||
const pDev = r_pressure[r]; // deviation from 1013 hPa
|
||||
|
||||
// Seasonal subtropical suppression: the subtropical high shifts
|
||||
// poleward in local summer (creating Mediterranean dry summers)
|
||||
// and retreats equatorward in local winter (allowing westerly rain).
|
||||
const inLocalSummer = (name === 'summer') ? (lat >= 0) : (lat < 0);
|
||||
const subtropCenter = inLocalSummer ? 30 : 24;
|
||||
const subtropWidth = inLocalSummer ? 16 : 12;
|
||||
let subtropPeak = inLocalSummer ? 0.50 : 0.30;
|
||||
|
||||
// East-coast monsoon relief: reduce summer drying where
|
||||
// poleward winds bring tropical moisture onshore. On Earth
|
||||
// this produces humid subtropical (Cfa) on east coasts
|
||||
// while west coasts keep Mediterranean (Cs) dry summers.
|
||||
if (isLand && inLocalSummer) {
|
||||
const polewardWind = lat >= 0 ? r_windN[r] : -r_windN[r];
|
||||
if (polewardWind > 0) {
|
||||
const coastDist = r_coastDistLand[r] >= 0 ? r_coastDistLand[r] : maxHops;
|
||||
const coastProximity = 1 - smoothstep(0, maxHops * 0.4, coastDist);
|
||||
const monsoonRelief = smoothstep(0, 0.15, polewardWind) * coastProximity;
|
||||
subtropPeak *= (1 - monsoonRelief * 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
const subtropDist = Math.abs(absLatDeg - subtropCenter);
|
||||
const latBandSuppression = subtropDist < subtropWidth
|
||||
? smoothstep(subtropWidth, 0, subtropDist) * subtropPeak : 0;
|
||||
|
||||
// Pressure modifier: high pressure adds suppression, low reduces it
|
||||
// Kept gentle — pressure nudges the baseline, doesn't overwhelm it.
|
||||
let pressureMod = 0;
|
||||
if (pDev > 0) {
|
||||
pressureMod = smoothstep(0, 12, pDev) * 0.25; // extra suppression
|
||||
} else {
|
||||
pressureMod = -smoothstep(0, 15, -pDev) * 0.2; // relief / enhancement
|
||||
}
|
||||
|
||||
const totalSuppression = Math.max(0, latBandSuppression + pressureMod);
|
||||
if (totalSuppression > 0) {
|
||||
p *= Math.max(0.05, 1 - totalSuppression);
|
||||
} else {
|
||||
// Net enhancement from low pressure outside subtropical belt
|
||||
p *= (1 - totalSuppression); // totalSuppression is negative here
|
||||
}
|
||||
|
||||
// (e) Polar front: diffuse precipitation at high latitudes
|
||||
// The polar front is broad and pushes moisture deep inland —
|
||||
// the blog cites ~2000 km downwind, ~1500 km crosswind from
|
||||
// any coast, including coasts with offshore winds.
|
||||
// It always brings *some* precipitation from its own cyclonic
|
||||
// activity, even deep inland, plus a stronger coastal component.
|
||||
if (absLatDeg > 40) {
|
||||
const polarStrength = smoothstep(40, 70, absLatDeg);
|
||||
const coastDist = r_coastDistLand[r] < 0 ? maxHops : r_coastDistLand[r];
|
||||
const inlandFade = 1 - smoothstep(0, maxHops, coastDist);
|
||||
// Base: always present regardless of coast distance
|
||||
const polarBase = polarStrength * 0.10;
|
||||
// Coastal enhancement: fades inland
|
||||
const polarCoastal = polarStrength * 0.20 * inlandFade;
|
||||
// Mostly enhances existing moisture, but adds some regardless
|
||||
p += polarBase + polarCoastal;
|
||||
p *= (1 + polarStrength * 0.15); // gentle multiplicative boost
|
||||
}
|
||||
|
||||
// (f) Continental interior dryness
|
||||
// Now that continentality is BFS-based (0 at coast, 0.5 at ~1000km,
|
||||
// 1.0 at ~2000km), we can use it directly. Squared curve keeps
|
||||
// near-coast areas gentle while ramping for deep interiors.
|
||||
if (isLand && cont > 0) {
|
||||
const dryness = cont * cont * 0.55;
|
||||
p *= Math.max(0.03, 1 - dryness);
|
||||
}
|
||||
|
||||
// (g) Lee cyclogenesis: localized wet zone on leeward side of high mountains
|
||||
// when ocean is nearby downwind (~200 km)
|
||||
const heightKm = r_heightKm[r];
|
||||
if (isLand && heightKm > 1.5) {
|
||||
const we = r_windE[r], wn = r_windN[r];
|
||||
const windDotGrad = we * r_elevGradE[r] + wn * r_elevGradN[r];
|
||||
// ~200 km in hops (scale-invariant)
|
||||
const leeCoastHops = Math.max(2, Math.round(200 / avgEdgeKm));
|
||||
if (windDotGrad < -0.01 && r_coastDistLand[r] >= 0 && r_coastDistLand[r] < leeCoastHops) {
|
||||
p += 0.15 * Math.min(1, heightKm / 5);
|
||||
}
|
||||
}
|
||||
|
||||
// Ocean cells: precipitation over ocean (for visual completeness)
|
||||
if (!isLand) {
|
||||
// ITCZ and frontal zones already contribute above.
|
||||
// Add baseline ocean precipitation, suppressed under high pressure
|
||||
const highPressureFade = pDev > 0 ? smoothstep(0, 12, pDev) : 0;
|
||||
const oceanBase = 0.15 * (1 - highPressureFade);
|
||||
p = Math.max(p, oceanBase);
|
||||
}
|
||||
|
||||
// (h) Hard distance-from-coast moisture cutoff
|
||||
// Beyond ~2000 km from any coast, moisture drops off steeply.
|
||||
// By 3000 km almost nothing remains.
|
||||
if (isLand && r_coastDistLand[r] > 0) {
|
||||
const distKm = r_coastDistLand[r] * avgEdgeKm;
|
||||
if (distKm > 2000) {
|
||||
const fade = 1 - smoothstep(2000, 3000, distKm);
|
||||
p *= Math.max(0.03, fade);
|
||||
}
|
||||
}
|
||||
|
||||
const precipMult = 1 + precipitationOffset * 0.5;
|
||||
let finalPrecip = p * precipMult;
|
||||
if (landCoverage > 0.4) {
|
||||
const t = (landCoverage - 0.4) / 0.6;
|
||||
finalPrecip *= 1 - t * t * 0.98;
|
||||
}
|
||||
precip[r] = Math.max(0, finalPrecip);
|
||||
}
|
||||
|
||||
const tMechanisms = performance.now() - t0;
|
||||
|
||||
// ── Step 2b: Rain shadow diagnostic — local source + bidirectional propagation ──
|
||||
// Seed leeward slopes with negative shadow strength and windward slopes
|
||||
// with positive orographic rain. Then propagate each in the correct
|
||||
// direction: shadow travels DOWNWIND (foehn drying), windward rain
|
||||
// extends UPWIND (rising air condenses approaching the mountains).
|
||||
{
|
||||
const { adjOffset, adjList } = mesh;
|
||||
// Seed: local orographic effect at each cell
|
||||
// Only significant terrain (≥0.8 km) seeds shadows — small hills
|
||||
// shouldn't cast continent-scale rain shadows.
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
if (!r_isLand[r] || r_elevation[r] <= 0) continue;
|
||||
const we = r_windE[r], wn = r_windN[r];
|
||||
const windDotGrad = we * r_elevGradE[r] + wn * r_elevGradN[r];
|
||||
const heightKm = r_heightKm[r];
|
||||
if (heightKm < 0.8) continue; // skip low terrain
|
||||
const heightScale = Math.min(1, (heightKm - 0.5) / 2.5);
|
||||
if (windDotGrad > 0) {
|
||||
rainShadow[r] = Math.min(1, windDotGrad * 20) * heightScale;
|
||||
} else if (windDotGrad < 0) {
|
||||
rainShadow[r] = -Math.min(1, -windDotGrad * 18) * heightScale;
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-compute wind-aligned neighbor lists once — avoids
|
||||
// redundant dot-product calculations inside every propagation
|
||||
// iteration. Two sets: "upwind" (nb's wind points toward r,
|
||||
// for shadow propagation) and "downwind" (r's wind points
|
||||
// toward nb, for windward propagation).
|
||||
const maxNbTotal = adjList.length;
|
||||
const upNb = new Int32Array(maxNbTotal);
|
||||
const upWt = new Float32Array(maxNbTotal);
|
||||
const upOff = new Int32Array(numRegions + 1);
|
||||
const dnNb = new Int32Array(maxNbTotal);
|
||||
const dnWt = new Float32Array(maxNbTotal);
|
||||
const dnOff = new Int32Array(numRegions + 1);
|
||||
let upCount = 0, dnCount = 0;
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
upOff[r] = upCount;
|
||||
dnOff[r] = dnCount;
|
||||
if (!r_isLand[r]) continue;
|
||||
const end = adjOffset[r + 1];
|
||||
for (let ni = adjOffset[r]; ni < end; ni++) {
|
||||
const nb = adjList[ni];
|
||||
const dx = r_xyz[3 * r] - r_xyz[3 * nb];
|
||||
const dy = r_xyz[3 * r + 1] - r_xyz[3 * nb + 1];
|
||||
const dz = r_xyz[3 * r + 2] - r_xyz[3 * nb + 2];
|
||||
// Upwind: wind at nb points toward r
|
||||
const upDot = r_wind3dX[nb] * dx + r_wind3dY[nb] * dy + r_wind3dZ[nb] * dz;
|
||||
if (upDot > 0) { upNb[upCount] = nb; upWt[upCount] = upDot; upCount++; }
|
||||
// Downwind: wind at r points toward nb (direction is -dx,-dy,-dz)
|
||||
const dnDot = -(r_wind3dX[r] * dx + r_wind3dY[r] * dy + r_wind3dZ[r] * dz);
|
||||
if (dnDot > 0) { dnNb[dnCount] = nb; dnWt[dnCount] = dnDot; dnCount++; }
|
||||
}
|
||||
}
|
||||
upOff[numRegions] = upCount;
|
||||
dnOff[numRegions] = dnCount;
|
||||
|
||||
// --- Pass 1: Propagate shadow DOWNWIND (~2500 km, 15% survives) ---
|
||||
const shadowHops = Math.max(8, Math.round(2500 / avgEdgeKm));
|
||||
const shadowDecay = 1 - Math.pow(0.15, 1 / shadowHops);
|
||||
const shadowField = new Float32Array(rainShadow);
|
||||
// Reusable ping-pong buffers for both shadow and windward passes
|
||||
let src = new Float32Array(shadowField);
|
||||
let dst = new Float32Array(numRegions);
|
||||
for (let iter = 0; iter < shadowHops; iter++) {
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
let upVal = 0, upW = 0;
|
||||
const uEnd = upOff[r + 1];
|
||||
for (let ui = upOff[r]; ui < uEnd; ui++) {
|
||||
const val = src[upNb[ui]];
|
||||
if (val < 0) { upVal += val * upWt[ui]; upW += upWt[ui]; }
|
||||
}
|
||||
if (upW > 0) {
|
||||
const carried = (upVal / upW) * (1 - shadowDecay);
|
||||
dst[r] = Math.min(src[r], carried);
|
||||
} else {
|
||||
dst[r] = src[r];
|
||||
}
|
||||
}
|
||||
const swap = src; src = dst; dst = swap;
|
||||
}
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
if (src[r] < shadowField[r]) shadowField[r] = src[r];
|
||||
}
|
||||
|
||||
// --- Pass 2: Propagate windward rain UPWIND (~1500 km, 25% survives) ---
|
||||
const windwardHops = Math.max(6, Math.round(1500 / avgEdgeKm));
|
||||
const windwardDecay = 1 - Math.pow(0.25, 1 / windwardHops);
|
||||
const windwardField = new Float32Array(rainShadow);
|
||||
// Reuse ping-pong buffers from shadow pass
|
||||
src.set(windwardField);
|
||||
dst.fill(0);
|
||||
for (let iter = 0; iter < windwardHops; iter++) {
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
let dnVal = 0, dnW = 0;
|
||||
const dEnd = dnOff[r + 1];
|
||||
for (let di = dnOff[r]; di < dEnd; di++) {
|
||||
const val = src[dnNb[di]];
|
||||
if (val > 0) { dnVal += val * dnWt[di]; dnW += dnWt[di]; }
|
||||
}
|
||||
if (dnW > 0) {
|
||||
const carried = (dnVal / dnW) * (1 - windwardDecay);
|
||||
dst[r] = Math.max(src[r], carried);
|
||||
} else {
|
||||
dst[r] = src[r];
|
||||
}
|
||||
}
|
||||
const swap = src; src = dst; dst = swap;
|
||||
}
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
if (src[r] > windwardField[r]) windwardField[r] = src[r];
|
||||
}
|
||||
|
||||
// Merge: shadow dominates if present, otherwise take windward
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
rainShadow[r] = shadowField[r] < 0 ? shadowField[r] : windwardField[r];
|
||||
}
|
||||
}
|
||||
// Smooth ~150 km so the zones read clearly
|
||||
const rsSmoothPasses = Math.max(2, Math.round(150 / avgEdgeKm));
|
||||
smoothField(mesh, rainShadow, rsSmoothPasses);
|
||||
|
||||
// ── Step 2c: Apply propagated rain shadow to actual precipitation ──
|
||||
// The local orographic effect in (c) only touches the mountain slopes
|
||||
// themselves. This step extends the shadow hundreds of km downwind and
|
||||
// boosts windward rain upwind, using the propagated field from 2b.
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
if (!r_isLand[r]) continue;
|
||||
const rs = rainShadow[r];
|
||||
if (rs < -0.01) {
|
||||
// Shadow zone: precipitation suppression behind mountains
|
||||
const strength = Math.min(1, -rs * 2.25);
|
||||
precip[r] *= Math.max(0.02, 1 - strength * 0.92);
|
||||
} else if (rs > 0.01) {
|
||||
// Windward zone: strong orographic precipitation enhancement
|
||||
precip[r] += rs * 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 3: Smooth (normalization deferred to blending step) ──
|
||||
t0 = performance.now();
|
||||
// Light smoothing ~100 km to blend cell-to-cell noise
|
||||
const precipSmoothPasses = Math.max(1, Math.round(100 / avgEdgeKm));
|
||||
smoothField(mesh, precip, precipSmoothPasses);
|
||||
const tSmooth = performance.now() - t0;
|
||||
|
||||
timing.push({ stage: `Precip: advection (${name})`, ms: tAdvect });
|
||||
timing.push({ stage: `Precip: mechanisms (${name})`, ms: tMechanisms });
|
||||
timing.push({ stage: `Precip: smooth (${name})`, ms: tSmooth });
|
||||
|
||||
result[`r_precip_${name}`] = precip;
|
||||
result[`r_rainshadow_${name}`] = rainShadow;
|
||||
}
|
||||
|
||||
// ── Step 4: Blend with heuristic model and normalize ──
|
||||
t0 = performance.now();
|
||||
const heuristic = computeHeuristicPrecipitation(mesh, r_xyz, r_elevation, windResult, r_elevGradE, r_elevGradN, r_coastDistLand);
|
||||
|
||||
for (const seasonName of ['summer', 'winter']) {
|
||||
const complex = result[`r_precip_${seasonName}`];
|
||||
const heur = heuristic[`r_precip_${seasonName}`];
|
||||
const blended = new Float32Array(numRegions);
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
blended[r] = 0.5 * complex[r] + 0.5 * heur[r];
|
||||
}
|
||||
|
||||
// 95th-percentile normalization on blended result
|
||||
const maxPrecip = percentile(blended, 0.95);
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
blended[r] = Math.min(1, blended[r] / maxPrecip);
|
||||
}
|
||||
|
||||
// Continental interior cap: interior regions can't exceed steppe-level
|
||||
// precipitation. At cont=1.0, cap is 0.20 per season (≈ 200mm
|
||||
// half-year → 400mm annual — solidly in steppe territory). Fades in
|
||||
// from cont 0.5 so the transition is gradual. Other factors (desert
|
||||
// factory, rain shadows, distance cutoff) can still push lower.
|
||||
const r_continentality = windResult.r_continentality;
|
||||
if (r_continentality) {
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
if (r_isLand[r] && r_continentality[r] > 0.5) {
|
||||
const t = smoothstep(0.5, 1.0, r_continentality[r]);
|
||||
const cap = 1.0 - t * 0.80; // 1.0 at cont=0.5, 0.20 at cont=1.0
|
||||
blended[r] = Math.min(blended[r], cap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result[`r_precip_${seasonName}`] = blended;
|
||||
}
|
||||
timing.push({ stage: 'Precip: heuristic blend+normalize', ms: performance.now() - t0 });
|
||||
|
||||
result._precipTiming = timing;
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Seeded RNG — deterministic pseudo-random number generators.
|
||||
|
||||
export function makeRng(seed) {
|
||||
let s = (Math.abs(Math.floor(seed * 9301 + 49297)) % 2147483646) + 1;
|
||||
return () => { s = (s * 16807) % 2147483647; return (s - 1) / 2147483646; };
|
||||
}
|
||||
|
||||
export function makeRandInt(seed) {
|
||||
const r = makeRng(seed);
|
||||
return (n) => Math.floor(r() * n);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// Three.js scene setup: renderer, cameras, controls, lights, atmosphere, water, stars.
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
|
||||
export const canvas = document.getElementById('canvas');
|
||||
export const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||
renderer.setSize(innerWidth, innerHeight);
|
||||
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||
|
||||
export const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x030308);
|
||||
|
||||
export const camera = new THREE.PerspectiveCamera(50, innerWidth/innerHeight, 0.1, 200);
|
||||
camera.position.set(0, 0.4, 2.8);
|
||||
|
||||
export const ctrl = new OrbitControls(camera, canvas);
|
||||
ctrl.enableDamping = true; ctrl.dampingFactor = 0.06;
|
||||
ctrl.enablePan = false;
|
||||
ctrl.minDistance = 1.4; ctrl.maxDistance = 8;
|
||||
ctrl.enableZoom = false; // disable built-in zoom; custom handler below
|
||||
|
||||
// Smooth zoom: wheel sets a target distance, each frame lerps toward it
|
||||
let _zoomTarget = camera.position.distanceTo(ctrl.target);
|
||||
const ZOOM_STEP = 0.92; // multiplier per tick (lower = faster zoom)
|
||||
const ZOOM_SMOOTH = 0.12; // lerp speed per frame (higher = snappier)
|
||||
|
||||
canvas.addEventListener('wheel', (e) => {
|
||||
if (!ctrl.enabled) return;
|
||||
e.preventDefault();
|
||||
const dir = Math.sign(e.deltaY);
|
||||
_zoomTarget *= dir > 0 ? 1 / ZOOM_STEP : ZOOM_STEP;
|
||||
_zoomTarget = THREE.MathUtils.clamp(_zoomTarget, ctrl.minDistance, ctrl.maxDistance);
|
||||
}, { passive: false });
|
||||
|
||||
// Pinch-to-zoom for globe (touch)
|
||||
let _pinchDist = 0;
|
||||
canvas.addEventListener('touchstart', (e) => {
|
||||
if (!ctrl.enabled || e.touches.length !== 2) { _pinchDist = 0; return; }
|
||||
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||||
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
||||
_pinchDist = Math.sqrt(dx * dx + dy * dy);
|
||||
}, { passive: true });
|
||||
|
||||
canvas.addEventListener('touchmove', (e) => {
|
||||
if (!ctrl.enabled || e.touches.length !== 2 || _pinchDist === 0) return;
|
||||
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||||
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
const ratio = _pinchDist / dist;
|
||||
_zoomTarget *= ratio;
|
||||
_zoomTarget = THREE.MathUtils.clamp(_zoomTarget, ctrl.minDistance, ctrl.maxDistance);
|
||||
_pinchDist = dist;
|
||||
}, { passive: true });
|
||||
|
||||
canvas.addEventListener('touchend', () => { _pinchDist = 0; }, { passive: true });
|
||||
|
||||
export function tickZoom() {
|
||||
const v = new THREE.Vector3().subVectors(camera.position, ctrl.target);
|
||||
const cur = v.length();
|
||||
const next = THREE.MathUtils.lerp(cur, _zoomTarget, ZOOM_SMOOTH);
|
||||
if (Math.abs(next - cur) < 0.0001) return;
|
||||
v.setLength(next);
|
||||
camera.position.copy(ctrl.target).add(v);
|
||||
}
|
||||
|
||||
scene.add(new THREE.AmbientLight(0xaabbcc, 3.5));
|
||||
export const sun = new THREE.DirectionalLight(0xfff8ee, 1.5);
|
||||
sun.position.set(5, 3, 4);
|
||||
scene.add(sun);
|
||||
|
||||
// Stars
|
||||
export let starsMesh;
|
||||
{ const g=new THREE.BufferGeometry(),p=[];
|
||||
for(let i=0;i<3000;i++){const th=Math.random()*Math.PI*2,ph=Math.acos(2*Math.random()-1),r=40+Math.random()*30;
|
||||
p.push(r*Math.sin(ph)*Math.cos(th),r*Math.sin(ph)*Math.sin(th),r*Math.cos(ph));}
|
||||
g.setAttribute('position',new THREE.Float32BufferAttribute(p,3));
|
||||
starsMesh = new THREE.Points(g,new THREE.PointsMaterial({color:0xffffff,size:0.08}));
|
||||
scene.add(starsMesh); }
|
||||
|
||||
// Atmosphere
|
||||
const atmosMat = new THREE.ShaderMaterial({
|
||||
uniforms:{c:{value:new THREE.Color(0.35,0.6,1.0)}},
|
||||
vertexShader:`varying vec3 vN,vP;void main(){vN=normalize(normalMatrix*normal);vP=(modelViewMatrix*vec4(position,1)).xyz;gl_Position=projectionMatrix*vec4(vP,1);}`,
|
||||
fragmentShader:`uniform vec3 c;varying vec3 vN,vP;void main(){float r=1.0-max(0.0,dot(normalize(-vP),vN));gl_FragColor=vec4(c,pow(r,3.5)*0.55);}`,
|
||||
transparent:true,side:THREE.FrontSide,depthWrite:false
|
||||
});
|
||||
export const atmosMesh = new THREE.Mesh(new THREE.SphereGeometry(1.12,64,64), atmosMat);
|
||||
scene.add(atmosMesh);
|
||||
|
||||
// Water sphere
|
||||
const waterMat = new THREE.MeshPhongMaterial({
|
||||
color:0x0c3a6e, transparent:true, opacity:0.55,
|
||||
shininess:120, specular:0x4488bb, depthWrite:false
|
||||
});
|
||||
export const waterMesh = new THREE.Mesh(new THREE.SphereGeometry(1.0,80,80), waterMat);
|
||||
scene.add(waterMesh);
|
||||
|
||||
// Equirectangular map camera & controls
|
||||
export const mapCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 100);
|
||||
mapCamera.position.set(0, 0, 5);
|
||||
mapCamera.lookAt(0, 0, 0);
|
||||
|
||||
export function updateMapCameraFrustum() {
|
||||
const aspect = innerWidth / innerHeight;
|
||||
const mapAspect = 2;
|
||||
let halfW, halfH;
|
||||
if (aspect > mapAspect) {
|
||||
halfH = 1.15;
|
||||
halfW = halfH * aspect;
|
||||
} else {
|
||||
halfW = 2.3;
|
||||
halfH = halfW / aspect;
|
||||
}
|
||||
mapCamera.left = -halfW; mapCamera.right = halfW;
|
||||
mapCamera.top = halfH; mapCamera.bottom = -halfH;
|
||||
mapCamera.updateProjectionMatrix();
|
||||
}
|
||||
updateMapCameraFrustum();
|
||||
|
||||
export const mapCtrl = new OrbitControls(mapCamera, canvas);
|
||||
mapCtrl.enableRotate = false;
|
||||
mapCtrl.enableDamping = true;
|
||||
mapCtrl.dampingFactor = 0.09;
|
||||
mapCtrl.panSpeed = 1.4;
|
||||
mapCtrl.screenSpacePanning = true;
|
||||
mapCtrl.mouseButtons = { LEFT: THREE.MOUSE.PAN, MIDDLE: THREE.MOUSE.PAN, RIGHT: THREE.MOUSE.PAN };
|
||||
mapCtrl.touches = { ONE: THREE.TOUCH.PAN, TWO: THREE.TOUCH.DOLLY_PAN };
|
||||
mapCtrl.minZoom = 0.5;
|
||||
mapCtrl.maxZoom = 20;
|
||||
mapCtrl.enableZoom = false; // custom handler below
|
||||
mapCtrl.enabled = false;
|
||||
|
||||
// Smooth zoom for map view (orthographic)
|
||||
let _mapZoomTarget = mapCamera.zoom;
|
||||
const MAP_ZOOM_STEP = 0.92;
|
||||
const MAP_ZOOM_SMOOTH = 0.12;
|
||||
|
||||
canvas.addEventListener('wheel', (e) => {
|
||||
if (!mapCtrl.enabled) return;
|
||||
e.preventDefault();
|
||||
const dir = Math.sign(e.deltaY);
|
||||
_mapZoomTarget *= dir < 0 ? 1 / MAP_ZOOM_STEP : MAP_ZOOM_STEP;
|
||||
_mapZoomTarget = THREE.MathUtils.clamp(_mapZoomTarget, mapCtrl.minZoom, mapCtrl.maxZoom);
|
||||
}, { passive: false });
|
||||
|
||||
// Pinch-to-zoom for map (touch)
|
||||
let _mapPinchDist = 0;
|
||||
canvas.addEventListener('touchstart', (e) => {
|
||||
if (!mapCtrl.enabled || e.touches.length !== 2) { _mapPinchDist = 0; return; }
|
||||
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||||
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
||||
_mapPinchDist = Math.sqrt(dx * dx + dy * dy);
|
||||
}, { passive: true });
|
||||
|
||||
canvas.addEventListener('touchmove', (e) => {
|
||||
if (!mapCtrl.enabled || e.touches.length !== 2 || _mapPinchDist === 0) return;
|
||||
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||||
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
const ratio = dist / _mapPinchDist;
|
||||
_mapZoomTarget *= ratio;
|
||||
_mapZoomTarget = THREE.MathUtils.clamp(_mapZoomTarget, mapCtrl.minZoom, mapCtrl.maxZoom);
|
||||
_mapPinchDist = dist;
|
||||
}, { passive: true });
|
||||
|
||||
canvas.addEventListener('touchend', () => { _mapPinchDist = 0; }, { passive: true });
|
||||
|
||||
export function tickMapZoom() {
|
||||
const cur = mapCamera.zoom;
|
||||
const next = THREE.MathUtils.lerp(cur, _mapZoomTarget, MAP_ZOOM_SMOOTH);
|
||||
if (Math.abs(next - cur) < 0.0001) return;
|
||||
mapCamera.zoom = next;
|
||||
mapCamera.updateProjectionMatrix();
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Simplex Noise 3D with fBm and ridged fBm variants.
|
||||
|
||||
import { makeRng } from './rng.js';
|
||||
|
||||
export class SimplexNoise {
|
||||
constructor(seed = 0) {
|
||||
this.G = [[1,1,0],[-1,1,0],[1,-1,0],[-1,-1,0],[1,0,1],[-1,0,1],[1,0,-1],[-1,0,-1],[0,1,1],[0,-1,1],[0,1,-1],[0,-1,-1]];
|
||||
const rng = makeRng(seed);
|
||||
const p = new Uint8Array(256);
|
||||
for (let i = 0; i < 256; i++) p[i] = i;
|
||||
for (let i = 255; i > 0; i--) { const j = Math.floor(rng()*(i+1)); [p[i],p[j]]=[p[j],p[i]]; }
|
||||
this.perm = new Uint8Array(512);
|
||||
this.pm12 = new Uint8Array(512);
|
||||
for (let i = 0; i < 512; i++) { this.perm[i] = p[i&255]; this.pm12[i] = this.perm[i]%12; }
|
||||
}
|
||||
|
||||
noise3D(x,y,z) {
|
||||
const F=1/3,H=1/6,s=(x+y+z)*F;
|
||||
const i=Math.floor(x+s),j=Math.floor(y+s),k=Math.floor(z+s);
|
||||
const t=(i+j+k)*H,x0=x-i+t,y0=y-j+t,z0=z-k+t;
|
||||
let i1,j1,k1,i2,j2,k2;
|
||||
if(x0>=y0){if(y0>=z0){i1=1;j1=0;k1=0;i2=1;j2=1;k2=0;}else if(x0>=z0){i1=1;j1=0;k1=0;i2=1;j2=0;k2=1;}else{i1=0;j1=0;k1=1;i2=1;j2=0;k2=1;}}
|
||||
else{if(y0<z0){i1=0;j1=0;k1=1;i2=0;j2=1;k2=1;}else if(x0<z0){i1=0;j1=1;k1=0;i2=0;j2=1;k2=1;}else{i1=0;j1=1;k1=0;i2=1;j2=1;k2=0;}}
|
||||
const x1=x0-i1+H,y1=y0-j1+H,z1=z0-k1+H,x2=x0-i2+2*H,y2=y0-j2+2*H,z2=z0-k2+2*H,x3=x0-1+3*H,y3=y0-1+3*H,z3=z0-1+3*H;
|
||||
const ii=i&255,jj=j&255,kk=k&255,{perm:P,pm12:M,G:g}=this;
|
||||
let n0=0,n1=0,n2=0,n3=0;
|
||||
let a=0.6-x0*x0-y0*y0-z0*z0;if(a>0){a*=a;const v=g[M[ii+P[jj+P[kk]]]];n0=a*a*(v[0]*x0+v[1]*y0+v[2]*z0);}
|
||||
let b=0.6-x1*x1-y1*y1-z1*z1;if(b>0){b*=b;const v=g[M[ii+i1+P[jj+j1+P[kk+k1]]]];n1=b*b*(v[0]*x1+v[1]*y1+v[2]*z1);}
|
||||
let c=0.6-x2*x2-y2*y2-z2*z2;if(c>0){c*=c;const v=g[M[ii+i2+P[jj+j2+P[kk+k2]]]];n2=c*c*(v[0]*x2+v[1]*y2+v[2]*z2);}
|
||||
let d=0.6-x3*x3-y3*y3-z3*z3;if(d>0){d*=d;const v=g[M[ii+1+P[jj+1+P[kk+1]]]];n3=d*d*(v[0]*x3+v[1]*y3+v[2]*z3);}
|
||||
return 32*(n0+n1+n2+n3);
|
||||
}
|
||||
|
||||
fbm(x,y,z,octaves=5,persistence=2/3) {
|
||||
let sum=0,max=0,amp=1;
|
||||
for(let o=0;o<octaves;o++){const f=1<<o;sum+=amp*this.noise3D(x*f,y*f,z*f);max+=amp;amp*=persistence;}
|
||||
return sum/max;
|
||||
}
|
||||
|
||||
ridgedFbm(x, y, z, octaves = 6, lacunarity = 2.0, gain = 0.5, offset = 1.0) {
|
||||
let sum = 0, freq = 1, amp = 1, prev = 1, maxVal = 0;
|
||||
for (let o = 0; o < octaves; o++) {
|
||||
let n = this.noise3D(x * freq, y * freq, z * freq);
|
||||
n = offset - Math.abs(n);
|
||||
n = n * n;
|
||||
sum += n * amp * prev;
|
||||
maxVal += amp;
|
||||
prev = Math.min(n, 1);
|
||||
freq *= lacunarity;
|
||||
amp *= gain;
|
||||
}
|
||||
return sum / maxVal;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
// Sphere mesh construction: Fibonacci sphere → Delaunay → close pole → SphereMesh.
|
||||
// Adapted from Red Blob Games sphere-mesh.js.
|
||||
|
||||
let _Delaunator = null;
|
||||
export function setDelaunator(D) { _Delaunator = D; }
|
||||
|
||||
// Fibonacci sphere with jitter — evenly-distributed points using the
|
||||
// Fibonacci spiral. Jitter randomises positions for more organic Voronoi cells.
|
||||
export function generateFibonacciSphere(N, jitter, rng) {
|
||||
const r_xyz = new Float32Array(3 * N);
|
||||
const s = 3.6 / Math.sqrt(N);
|
||||
const dlong = Math.PI * (3 - Math.sqrt(5));
|
||||
const dz = 2.0 / N;
|
||||
|
||||
for (let k = 0, lng = 0, z = 1 - dz / 2; k < N; k++, z -= dz) {
|
||||
const r = Math.sqrt(1 - z * z);
|
||||
let latDeg = Math.asin(z) * 180 / Math.PI;
|
||||
let lonDeg = lng * 180 / Math.PI;
|
||||
|
||||
if (jitter > 0) {
|
||||
const jLat = (rng() - rng());
|
||||
const jLon = (rng() - rng());
|
||||
const nextZ = Math.max(-1, z - dz * 2 * Math.PI * r / s);
|
||||
latDeg += jitter * jLat * (latDeg - Math.asin(nextZ) * 180 / Math.PI);
|
||||
lonDeg += jitter * jLon * (s / r * 180 / Math.PI);
|
||||
}
|
||||
|
||||
const latR = latDeg * Math.PI / 180;
|
||||
const lonR = lonDeg * Math.PI / 180;
|
||||
r_xyz[3*k] = Math.cos(latR) * Math.cos(lonR);
|
||||
r_xyz[3*k+1] = Math.cos(latR) * Math.sin(lonR);
|
||||
r_xyz[3*k+2] = Math.sin(latR);
|
||||
|
||||
lng += dlong;
|
||||
}
|
||||
return r_xyz;
|
||||
}
|
||||
|
||||
// Stereographic projection (for Delaunay on a sphere).
|
||||
// Projects every point from the "north pole" (0,0,1) onto a plane.
|
||||
export function stereographicProjection(r_xyz, N) {
|
||||
const flat = new Float64Array(2 * N);
|
||||
for (let i = 0; i < N; i++) {
|
||||
const z = r_xyz[3*i+2];
|
||||
// Clamp denominator to prevent Infinity when a jittered point lands
|
||||
// on or near the projection pole (z ≈ 1). The exact projected position
|
||||
// doesn't matter for near-pole points — addPoleToMesh corrects connectivity.
|
||||
const denom = Math.max(1e-12, 1 - z);
|
||||
flat[2*i] = r_xyz[3*i] / denom;
|
||||
flat[2*i+1] = r_xyz[3*i+1] / denom;
|
||||
}
|
||||
return flat;
|
||||
}
|
||||
|
||||
// Add pole back into mesh — close the mesh by connecting hull edges to the pole.
|
||||
export function addPoleToMesh(poleId, triangles, halfedges) {
|
||||
const numSides = triangles.length;
|
||||
const next = s => (s % 3 === 2) ? s - 2 : s + 1;
|
||||
|
||||
let numUnpaired = 0, firstUnpaired = -1;
|
||||
const pointToSide = [];
|
||||
for (let s = 0; s < numSides; s++) {
|
||||
if (halfedges[s] === -1) {
|
||||
numUnpaired++;
|
||||
pointToSide[triangles[s]] = s;
|
||||
firstUnpaired = s;
|
||||
}
|
||||
}
|
||||
|
||||
const nt = new Int32Array(numSides + 3 * numUnpaired);
|
||||
const nh = new Int32Array(numSides + 3 * numUnpaired);
|
||||
nt.set(triangles);
|
||||
nh.set(halfedges);
|
||||
|
||||
for (let i = 0, s = firstUnpaired;
|
||||
i < numUnpaired;
|
||||
i++, s = pointToSide[nt[next(s)]]) {
|
||||
const ns = numSides + 3 * i;
|
||||
nh[s] = ns;
|
||||
nh[ns] = s;
|
||||
nt[ns] = nt[next(s)];
|
||||
nt[ns + 1] = nt[s];
|
||||
nt[ns + 2] = poleId;
|
||||
const k = numSides + (3 * i + 4) % (3 * numUnpaired);
|
||||
nh[ns + 2] = k;
|
||||
nh[k] = ns + 2;
|
||||
}
|
||||
|
||||
return { triangles: nt, halfedges: nh };
|
||||
}
|
||||
|
||||
// Lightweight dual-mesh helper wrapping Delaunator output.
|
||||
// Regions = Voronoi cells, Triangles = Delaunay triangles, Sides = half-edges.
|
||||
export class SphereMesh {
|
||||
constructor(triangles, halfedges, numRegions) {
|
||||
this.triangles = triangles;
|
||||
this.halfedges = halfedges;
|
||||
this.numRegions = numRegions;
|
||||
this.numSides = triangles.length;
|
||||
this.numTriangles = (triangles.length / 3) | 0;
|
||||
|
||||
this._r_s = new Int32Array(numRegions).fill(-1);
|
||||
for (let s = 0; s < this.numSides; s++) {
|
||||
const r = triangles[s];
|
||||
if (this._r_s[r] === -1) this._r_s[r] = s;
|
||||
}
|
||||
|
||||
// Pre-compute flat adjacency lists for r_circulate_r and r_circulate_t.
|
||||
// Replaces per-call half-edge traversal with cache-friendly array reads.
|
||||
const adjCount = new Int32Array(numRegions);
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
const s0 = this._r_s[r];
|
||||
if (s0 === -1) continue;
|
||||
let s = s0;
|
||||
do {
|
||||
adjCount[r]++;
|
||||
s = this._next(this.halfedges[s]);
|
||||
} while (s !== s0);
|
||||
}
|
||||
|
||||
this._adjOffset = new Int32Array(numRegions + 1);
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
this._adjOffset[r + 1] = this._adjOffset[r] + adjCount[r];
|
||||
}
|
||||
|
||||
const totalAdj = this._adjOffset[numRegions];
|
||||
this._adjList = new Int32Array(totalAdj); // neighbor regions
|
||||
this._adjTriList = new Int32Array(totalAdj); // neighbor triangles
|
||||
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
const s0 = this._r_s[r];
|
||||
if (s0 === -1) continue;
|
||||
let s = s0;
|
||||
let idx = this._adjOffset[r];
|
||||
do {
|
||||
this._adjList[idx] = this.s_end_r(s);
|
||||
this._adjTriList[idx] = this.s_inner_t(s);
|
||||
idx++;
|
||||
s = this._next(this.halfedges[s]);
|
||||
} while (s !== s0);
|
||||
}
|
||||
|
||||
// Public aliases for direct adjacency iteration (avoids r_circulate_r copy overhead)
|
||||
this.adjOffset = this._adjOffset;
|
||||
this.adjList = this._adjList;
|
||||
}
|
||||
|
||||
_next(s) { return (s % 3 === 2) ? s - 2 : s + 1; }
|
||||
s_begin_r(s){ return this.triangles[s]; }
|
||||
s_end_r(s) { return this.triangles[this._next(s)]; }
|
||||
s_inner_t(s){ return (s / 3) | 0; }
|
||||
s_outer_t(s){ return (this.halfedges[s] / 3) | 0; }
|
||||
|
||||
r_circulate_r(out, r) {
|
||||
const start = this._adjOffset[r];
|
||||
const end = this._adjOffset[r + 1];
|
||||
const len = end - start;
|
||||
out.length = len;
|
||||
for (let i = 0; i < len; i++) out[i] = this._adjList[start + i];
|
||||
return out;
|
||||
}
|
||||
|
||||
r_circulate_t(out, r) {
|
||||
const start = this._adjOffset[r];
|
||||
const end = this._adjOffset[r + 1];
|
||||
const len = end - start;
|
||||
out.length = len;
|
||||
for (let i = 0; i < len; i++) out[i] = this._adjTriList[start + i];
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
// Build sphere — Fibonacci points → Delaunay → close pole.
|
||||
export function buildSphere(N, jitter, rng) {
|
||||
const r_xyz = generateFibonacciSphere(N, jitter, rng);
|
||||
const flat = stereographicProjection(r_xyz, N);
|
||||
const delaunay = new _Delaunator(flat);
|
||||
|
||||
const poleXYZ = new Float32Array(3 * (N + 1));
|
||||
poleXYZ.set(r_xyz);
|
||||
poleXYZ[3*N] = 0; poleXYZ[3*N+1] = 0; poleXYZ[3*N+2] = 1;
|
||||
|
||||
const closed = addPoleToMesh(N, delaunay.triangles, delaunay.halfedges);
|
||||
const mesh = new SphereMesh(closed.triangles, closed.halfedges, N + 1);
|
||||
return { mesh, r_xyz: poleXYZ };
|
||||
}
|
||||
|
||||
// Pre-compute Euclidean distance between each region and its neighbors.
|
||||
// Indexed by the same adjacency slot as adjList: neighborDist[i] is the
|
||||
// distance from region r to adjList[i] where adjOffset[r] <= i < adjOffset[r+1].
|
||||
export function computeNeighborDist(mesh, r_xyz) {
|
||||
const { adjOffset, adjList } = mesh;
|
||||
const neighborDist = new Float32Array(adjList.length);
|
||||
for (let r = 0; r < mesh.numRegions; r++) {
|
||||
const x = r_xyz[3*r], y = r_xyz[3*r+1], z = r_xyz[3*r+2];
|
||||
for (let i = adjOffset[r]; i < adjOffset[r+1]; i++) {
|
||||
const nb = adjList[i];
|
||||
const dx = x - r_xyz[3*nb], dy = y - r_xyz[3*nb+1], dz = z - r_xyz[3*nb+2];
|
||||
neighborDist[i] = Math.sqrt(dx*dx + dy*dy + dz*dz);
|
||||
}
|
||||
}
|
||||
return neighborDist;
|
||||
}
|
||||
|
||||
// Triangle centres (= Voronoi vertices on the sphere).
|
||||
export function generateTriangleCenters(mesh, r_xyz) {
|
||||
const { numTriangles } = mesh;
|
||||
const t_xyz = new Float32Array(3 * numTriangles);
|
||||
for (let t = 0; t < numTriangles; t++) {
|
||||
const s0 = 3 * t;
|
||||
const a = mesh.s_begin_r(s0),
|
||||
b = mesh.s_begin_r(s0 + 1),
|
||||
c = mesh.s_begin_r(s0 + 2);
|
||||
t_xyz[3*t] = (r_xyz[3*a] + r_xyz[3*b] + r_xyz[3*c]) / 3;
|
||||
t_xyz[3*t+1] = (r_xyz[3*a+1]+r_xyz[3*b+1]+r_xyz[3*c+1]) / 3;
|
||||
t_xyz[3*t+2] = (r_xyz[3*a+2]+r_xyz[3*b+2]+r_xyz[3*c+2]) / 3;
|
||||
}
|
||||
return t_xyz;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Shared mutable application state.
|
||||
// All modules import this same object, so mutations are visible everywhere.
|
||||
export const state = {
|
||||
planetMesh: null,
|
||||
wireMesh: null,
|
||||
arrowGroup: null,
|
||||
windArrowGroup: null,
|
||||
curData: null,
|
||||
plateColors: {},
|
||||
_hoverBackup: null,
|
||||
hoveredPlate: -1,
|
||||
hoveredRegion: -1,
|
||||
hoveredKoppen: -1,
|
||||
_koppenHoverBackup: null,
|
||||
_mapKoppenHoverBackup: null,
|
||||
mapMesh: null,
|
||||
mapFaceToSide: null,
|
||||
_mapHoverBackup: null,
|
||||
mapGridMesh: null,
|
||||
globeGridMesh: null,
|
||||
gridEnabled: true,
|
||||
gridSpacing: 15,
|
||||
mapMode: false,
|
||||
mapCenterLon: 0,
|
||||
dragStart: null,
|
||||
debugLayer: '',
|
||||
isTouchDevice: ('ontouchstart' in window) || (navigator.maxTouchPoints > 0),
|
||||
editMode: false,
|
||||
oceanCurrentArrowGroup: null,
|
||||
climateComputed: false,
|
||||
pendingToggles: new Set(),
|
||||
_pendingBackup: null,
|
||||
_mapPendingBackup: null,
|
||||
importedHeightmap: false,
|
||||
// The painted map's overlay sheet (painted-overlay-view.js): the classified marks and their texture,
|
||||
// the meshes that show it on the globe and the map, and whether the toggle asks for it.
|
||||
overlay: null,
|
||||
overlayVisible: false,
|
||||
overlayGlobeMesh: null,
|
||||
overlayMapMesh: null,
|
||||
};
|
||||
@@ -0,0 +1,273 @@
|
||||
// Super plates: groups connected same-type plates into ~20 larger tectonic
|
||||
// units that move cohesively, producing broad orogenic belts while preserving
|
||||
// fine-grained detail from individual plate interactions.
|
||||
|
||||
/**
|
||||
* Build super plate assignments from individual plates.
|
||||
*
|
||||
* @param {Object} mesh Sphere mesh (adjOffset, adjList, numRegions)
|
||||
* @param {Int32Array} r_plate Region → plate seed ID
|
||||
* @param {Set} plateSeeds Set of all plate seed IDs
|
||||
* @param {Object} plateVec plate seed → { pole: [x,y,z], omega }
|
||||
* @param {Set} plateIsOcean Set of ocean plate seed IDs
|
||||
* @param {Object} plateDensity plate seed → density value
|
||||
* @returns {{ r_superPlate, superPlateVec, superPlateIsOcean, superPlateDensity, numSuperPlates }}
|
||||
*/
|
||||
export function buildSuperPlates(mesh, r_plate, plateSeeds, plateVec, plateIsOcean, plateDensity) {
|
||||
const { numRegions, adjOffset, adjList } = mesh;
|
||||
const numPlates = plateSeeds.size;
|
||||
|
||||
// 1. Count regions per plate (plate areas)
|
||||
const plateArea = {};
|
||||
for (const pid of plateSeeds) plateArea[pid] = 0;
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
plateArea[r_plate[r]]++;
|
||||
}
|
||||
|
||||
// 2. Build plate adjacency graph
|
||||
// plateNeighbors: pid → Set of neighbor plate IDs
|
||||
const plateNeighbors = {};
|
||||
for (const pid of plateSeeds) plateNeighbors[pid] = new Set();
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
const myPlate = r_plate[r];
|
||||
for (let ni = adjOffset[r], niEnd = adjOffset[r + 1]; ni < niEnd; ni++) {
|
||||
const nbPlate = r_plate[adjList[ni]];
|
||||
if (nbPlate !== myPlate) {
|
||||
plateNeighbors[myPlate].add(nbPlate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Connected components of same-type plates (BFS on plate graph)
|
||||
const plateVisited = new Set();
|
||||
const components = []; // each: array of plate seed IDs
|
||||
for (const pid of plateSeeds) {
|
||||
if (plateVisited.has(pid)) continue;
|
||||
const isOcean = plateIsOcean.has(pid);
|
||||
const comp = [];
|
||||
const queue = [pid];
|
||||
plateVisited.add(pid);
|
||||
let head = 0;
|
||||
while (head < queue.length) {
|
||||
const cur = queue[head++];
|
||||
comp.push(cur);
|
||||
for (const nb of plateNeighbors[cur]) {
|
||||
if (!plateVisited.has(nb) && plateIsOcean.has(nb) === isOcean) {
|
||||
plateVisited.add(nb);
|
||||
queue.push(nb);
|
||||
}
|
||||
}
|
||||
}
|
||||
components.push(comp);
|
||||
}
|
||||
|
||||
// 4. Split large components to reach target count
|
||||
const target = Math.max(2, Math.min(20, Math.round(numPlates / 4)));
|
||||
const totalPlates = numPlates;
|
||||
|
||||
// plateToSuperPlate: plate seed → super plate ID
|
||||
const plateToSuperPlate = {};
|
||||
let nextSuperPlate = 0;
|
||||
|
||||
for (const comp of components) {
|
||||
const k = Math.max(1, Math.round(target * comp.length / totalPlates));
|
||||
|
||||
if (k <= 1) {
|
||||
// Entire component is one super plate
|
||||
const spId = nextSuperPlate++;
|
||||
for (const pid of comp) plateToSuperPlate[pid] = spId;
|
||||
} else {
|
||||
// Farthest-point seeding on plate graph using area-weighted
|
||||
// distances, then multi-source Dijkstra assignment.
|
||||
// Edge cost = sqrt(area of destination plate), so traversing a
|
||||
// large plate costs more than a small one → more equal-area splits.
|
||||
const compSet = new Set(comp);
|
||||
const localAdj = {};
|
||||
for (const pid of comp) {
|
||||
localAdj[pid] = [];
|
||||
for (const nb of plateNeighbors[pid]) {
|
||||
if (compSet.has(nb)) localAdj[pid].push(nb);
|
||||
}
|
||||
}
|
||||
|
||||
// Edge weight: sqrt of destination plate area (linear proxy)
|
||||
const edgeWeight = {};
|
||||
for (const pid of comp) {
|
||||
edgeWeight[pid] = Math.sqrt(plateArea[pid] || 1);
|
||||
}
|
||||
|
||||
// Dijkstra from source set — updates dist in-place
|
||||
const dist = {};
|
||||
const dijkstraFrom = (startPids) => {
|
||||
for (const pid of comp) dist[pid] = Infinity;
|
||||
const visited = new Set();
|
||||
for (const s of startPids) dist[s] = 0;
|
||||
for (let iter = 0; iter < comp.length; iter++) {
|
||||
// Find unvisited node with smallest dist
|
||||
let cur = -1, minD = Infinity;
|
||||
for (const pid of comp) {
|
||||
if (!visited.has(pid) && dist[pid] < minD) {
|
||||
minD = dist[pid]; cur = pid;
|
||||
}
|
||||
}
|
||||
if (cur === -1) break;
|
||||
visited.add(cur);
|
||||
for (const nb of localAdj[cur]) {
|
||||
const nd = dist[cur] + edgeWeight[nb];
|
||||
if (nd < dist[nb]) dist[nb] = nd;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Farthest-point seeding: pick k seeds maximizing minimum weighted distance
|
||||
const seeds = [comp[0]];
|
||||
dijkstraFrom([comp[0]]);
|
||||
|
||||
for (let si = 1; si < k; si++) {
|
||||
let farthest = comp[0], maxDist = -1;
|
||||
for (const pid of comp) {
|
||||
if (dist[pid] > maxDist) {
|
||||
maxDist = dist[pid];
|
||||
farthest = pid;
|
||||
}
|
||||
}
|
||||
seeds.push(farthest);
|
||||
dijkstraFrom(seeds);
|
||||
}
|
||||
|
||||
// Multi-source Dijkstra from seeds to assign plates to nearest seed
|
||||
const assignment = {};
|
||||
for (const pid of comp) assignment[pid] = -1;
|
||||
const d = {};
|
||||
for (const pid of comp) d[pid] = Infinity;
|
||||
const visited = new Set();
|
||||
for (let si = 0; si < seeds.length; si++) {
|
||||
const spId = nextSuperPlate + si;
|
||||
assignment[seeds[si]] = spId;
|
||||
d[seeds[si]] = 0;
|
||||
}
|
||||
for (let iter = 0; iter < comp.length; iter++) {
|
||||
let cur = -1, minD = Infinity;
|
||||
for (const pid of comp) {
|
||||
if (!visited.has(pid) && d[pid] < minD) {
|
||||
minD = d[pid]; cur = pid;
|
||||
}
|
||||
}
|
||||
if (cur === -1) break;
|
||||
visited.add(cur);
|
||||
for (const nb of localAdj[cur]) {
|
||||
const nd = d[cur] + edgeWeight[nb];
|
||||
if (nd < d[nb]) {
|
||||
d[nb] = nd;
|
||||
assignment[nb] = assignment[cur];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const pid of comp) {
|
||||
plateToSuperPlate[pid] = assignment[pid];
|
||||
}
|
||||
nextSuperPlate += seeds.length;
|
||||
}
|
||||
}
|
||||
|
||||
const numSuperPlates = nextSuperPlate;
|
||||
|
||||
// 5. Build r_superPlate: region → super plate ID
|
||||
const r_superPlate = new Int32Array(numRegions);
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
r_superPlate[r] = plateToSuperPlate[r_plate[r]];
|
||||
}
|
||||
|
||||
// 6. Compute super plate Euler poles (area-weighted)
|
||||
// L = sum(area_i * omega_i * pole_i) — resultant angular momentum vector
|
||||
// omega_avg = sum(area_i * |omega_i|) / sum(area_i) — restores magnitude
|
||||
const spLx = new Float64Array(numSuperPlates);
|
||||
const spLy = new Float64Array(numSuperPlates);
|
||||
const spLz = new Float64Array(numSuperPlates);
|
||||
const spOmegaSum = new Float64Array(numSuperPlates);
|
||||
const spAreaSum = new Float64Array(numSuperPlates);
|
||||
const spLargestPlate = new Array(numSuperPlates).fill(null); // { pid, area } for fallback
|
||||
|
||||
for (const pid of plateSeeds) {
|
||||
const spId = plateToSuperPlate[pid];
|
||||
const pv = plateVec[pid];
|
||||
if (!pv || !pv.pole) continue; // skip synthetic/zero-velocity plates
|
||||
const area = plateArea[pid];
|
||||
const omega = pv.omega;
|
||||
const px = pv.pole[0], py = pv.pole[1], pz = pv.pole[2];
|
||||
|
||||
spLx[spId] += area * omega * px;
|
||||
spLy[spId] += area * omega * py;
|
||||
spLz[spId] += area * omega * pz;
|
||||
spOmegaSum[spId] += area * Math.abs(omega);
|
||||
spAreaSum[spId] += area;
|
||||
|
||||
if (!spLargestPlate[spId] || area > spLargestPlate[spId].area) {
|
||||
spLargestPlate[spId] = { pid, area };
|
||||
}
|
||||
}
|
||||
|
||||
const superPlateVec = {};
|
||||
for (let sp = 0; sp < numSuperPlates; sp++) {
|
||||
const lx = spLx[sp], ly = spLy[sp], lz = spLz[sp];
|
||||
const lLen = Math.sqrt(lx * lx + ly * ly + lz * lz);
|
||||
const totalArea = spAreaSum[sp];
|
||||
|
||||
if (lLen < 1e-8 || totalArea < 1) {
|
||||
// Fallback: use largest constituent plate's pole
|
||||
const largest = spLargestPlate[sp];
|
||||
if (largest) {
|
||||
const pv = plateVec[largest.pid];
|
||||
if (pv && pv.pole) {
|
||||
superPlateVec[sp] = { pole: [pv.pole[0], pv.pole[1], pv.pole[2]], omega: pv.omega };
|
||||
continue;
|
||||
}
|
||||
}
|
||||
superPlateVec[sp] = { pole: [0, 1, 0], omega: 0 };
|
||||
continue;
|
||||
}
|
||||
|
||||
const pole = [lx / lLen, ly / lLen, lz / lLen];
|
||||
const omega = spOmegaSum[sp] / totalArea;
|
||||
// Preserve sign from resultant direction
|
||||
superPlateVec[sp] = { pole, omega };
|
||||
}
|
||||
|
||||
// 7. Super plate ocean/land type: majority area of constituent plates
|
||||
const superPlateIsOcean = new Set();
|
||||
const spOceanArea = new Float64Array(numSuperPlates);
|
||||
const spTotalArea = new Float64Array(numSuperPlates);
|
||||
for (const pid of plateSeeds) {
|
||||
const spId = plateToSuperPlate[pid];
|
||||
const area = plateArea[pid];
|
||||
spTotalArea[spId] += area;
|
||||
if (plateIsOcean.has(pid)) spOceanArea[spId] += area;
|
||||
}
|
||||
for (let sp = 0; sp < numSuperPlates; sp++) {
|
||||
if (spOceanArea[sp] > spTotalArea[sp] * 0.5) {
|
||||
superPlateIsOcean.add(sp);
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Super plate density: area-weighted average
|
||||
const superPlateDensity = {};
|
||||
const spDensitySum = new Float64Array(numSuperPlates);
|
||||
const spDensityArea = new Float64Array(numSuperPlates);
|
||||
for (const pid of plateSeeds) {
|
||||
const spId = plateToSuperPlate[pid];
|
||||
const area = plateArea[pid];
|
||||
const density = plateDensity[pid];
|
||||
if (density !== undefined) {
|
||||
spDensitySum[spId] += area * density;
|
||||
spDensityArea[spId] += area;
|
||||
}
|
||||
}
|
||||
for (let sp = 0; sp < numSuperPlates; sp++) {
|
||||
superPlateDensity[sp] = spDensityArea[sp] > 0
|
||||
? spDensitySum[sp] / spDensityArea[sp]
|
||||
: 2.7; // fallback average crust density
|
||||
}
|
||||
|
||||
return { r_superPlate, superPlateVec, superPlateIsOcean, superPlateDensity, numSuperPlates };
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// Temperature simulation: computes per-region surface temperature for summer
|
||||
// and winter seasons based on ITCZ position, continentality, moisture-dependent
|
||||
// elevation lapse rate (dry adiabatic 9.3 C/km to moist adiabatic 4.5 C/km),
|
||||
// ocean current warmth, and precipitation/cloud cover moderation.
|
||||
// Returns normalized 0-1 values mapped to a fixed -45 to +45 C range.
|
||||
|
||||
import { smoothstep } from './wind.js';
|
||||
import { elevToHeightKm } from './color-map.js';
|
||||
import { smoothField, makeItczLookup } from './climate-util.js';
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
|
||||
// ── Diffuse ocean warmth onto nearby coastal land ───────────────────────────
|
||||
// Uses plate-based continentality so that warmth spreads freely across
|
||||
// shallow continental-shelf ocean and penetrates further inland. Ocean cells
|
||||
// on continental plates (shallow seas) inherit warmth from nearby oceanic-
|
||||
// plate cells first, then the warmth diffuses onto land.
|
||||
|
||||
function diffuseOceanWarmth(mesh, r_oceanWarmth, r_isLand, r_plateContinentality, passes) {
|
||||
const { adjOffset, adjList, numRegions } = mesh;
|
||||
const coastal = new Float32Array(numRegions);
|
||||
|
||||
// Seed: all ocean cells contribute their warmth directly.
|
||||
// Continental-shelf ocean cells may have weak/no current warmth;
|
||||
// they'll pick up values from nearby oceanic-plate neighbors via diffusion.
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
if (!r_isLand[r]) {
|
||||
coastal[r] = r_oceanWarmth ? r_oceanWarmth[r] : 0;
|
||||
}
|
||||
}
|
||||
|
||||
const tmp = new Float32Array(numRegions);
|
||||
for (let pass = 0; pass < passes; pass++) {
|
||||
tmp.set(coastal);
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
// Skip deep-interior continental cells (plate-based)
|
||||
if (r_plateContinentality && r_plateContinentality[r] >= 0.95) continue;
|
||||
|
||||
// Ocean cells also participate in diffusion so continental-shelf
|
||||
// cells inherit warmth from nearby open-ocean neighbors
|
||||
let sum = coastal[r];
|
||||
let count = 1;
|
||||
const end = adjOffset[r + 1];
|
||||
for (let ni = adjOffset[r]; ni < end; ni++) {
|
||||
sum += coastal[adjList[ni]];
|
||||
count++;
|
||||
}
|
||||
tmp[r] = sum / count;
|
||||
}
|
||||
coastal.set(tmp);
|
||||
}
|
||||
|
||||
return coastal;
|
||||
}
|
||||
|
||||
// ── Main entry point ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute seasonal temperature fields.
|
||||
*
|
||||
* @param {SphereMesh} mesh
|
||||
* @param {Float32Array} r_xyz - per-region 3D positions
|
||||
* @param {Float32Array} r_elevation - per-region elevation
|
||||
* @param {object} windResult - output from computeWind()
|
||||
* @param {object} oceanResult - output from computeOceanCurrents()
|
||||
* @param {object} precipResult - output from computePrecipitation()
|
||||
* @returns {{ r_temperature_summer, r_temperature_winter, _tempTiming }}
|
||||
*/
|
||||
export function computeTemperature(mesh, r_xyz, r_elevation, windResult, oceanResult, precipResult, temperatureOffset = 0) {
|
||||
const numRegions = mesh.numRegions;
|
||||
const timing = [];
|
||||
|
||||
const { r_lat, r_lon, r_isLand, r_continentality, r_plateContinentality } = windResult;
|
||||
|
||||
// Minimal smoothing: 1 pass just to blend cell-to-cell noise
|
||||
const smoothPasses = 1;
|
||||
|
||||
const T_MIN = -45;
|
||||
const T_MAX = 45;
|
||||
const T_RANGE = T_MAX - T_MIN;
|
||||
|
||||
const result = {};
|
||||
|
||||
// Pre-compute constants shared across seasons
|
||||
const avgEdgeKm = (Math.PI * 6371) / Math.sqrt(numRegions);
|
||||
const oceanWarmthPasses = Math.max(4, Math.round(1400 / avgEdgeKm));
|
||||
const plateCont = r_plateContinentality || r_continentality;
|
||||
|
||||
const seasons = ['summer', 'winter'];
|
||||
|
||||
for (const name of seasons) {
|
||||
const t0 = performance.now();
|
||||
|
||||
const r_oceanWarmth = oceanResult[`r_ocean_warmth_${name}`];
|
||||
const r_oceanSpeed = oceanResult[`r_ocean_speed_${name}`];
|
||||
const r_precip = precipResult[`r_precip_${name}`];
|
||||
|
||||
const itczLookup = makeItczLookup(windResult.itczLons,
|
||||
name === 'summer' ? windResult.itczLatsSummer : windResult.itczLatsWinter);
|
||||
|
||||
// Pre-compute diffused ocean warmth for coastal land influence
|
||||
// Use plate-based continentality for diffusion so warmth crosses
|
||||
// continental shelves and reaches further inland
|
||||
const coastalWarmth = diffuseOceanWarmth(mesh, r_oceanWarmth, r_isLand, plateCont, oceanWarmthPasses);
|
||||
|
||||
const temp = new Float32Array(numRegions);
|
||||
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
const lat = r_lat[r];
|
||||
const lon = r_lon[r];
|
||||
const latDeg = lat / DEG;
|
||||
const isLand = r_isLand[r];
|
||||
const elev = r_elevation[r];
|
||||
const cont = r_continentality ? r_continentality[r] : 0;
|
||||
const pCont = r_plateContinentality ? r_plateContinentality[r] : cont;
|
||||
|
||||
// ── 1. Base temperature from thermal equator (ITCZ) ──
|
||||
// Two curves blended by absolute latitude:
|
||||
// - T_itcz: based on distance from the actual (land-warped) ITCZ
|
||||
// - T_flat: based on distance from a fixed ITCZ at ±5° (ocean default)
|
||||
// Near the tropics the real ITCZ matters; at high latitudes the
|
||||
// ITCZ position is irrelevant and a stable zonal baseline takes over.
|
||||
const tropicalHW = 13; // flat plateau half-width (degrees)
|
||||
const maxDist = 90 - tropicalHW;
|
||||
|
||||
// Actual ITCZ curve
|
||||
const itczLat = itczLookup(lon);
|
||||
const distItcz = Math.abs(lat - itczLat) / DEG;
|
||||
const tItcz = Math.max(0, distItcz - tropicalHW) / maxDist;
|
||||
const T_itcz = 28 - 47 * Math.pow(tItcz, 1.4);
|
||||
|
||||
// Flat reference curve (ITCZ at 5° in summer hemisphere)
|
||||
const flatItczLat = (name === 'summer' ? 5 : -5) * DEG;
|
||||
const distFlat = Math.abs(lat - flatItczLat) / DEG;
|
||||
const tFlat = Math.max(0, distFlat - tropicalHW) / maxDist;
|
||||
const T_flat = 28 - 47 * Math.pow(tFlat, 1.4);
|
||||
|
||||
// Blend: ITCZ curve dominates tropics, flat curve dominates poles
|
||||
const absLatDeg = Math.abs(lat) / DEG;
|
||||
const blend = smoothstep(45, 90, absLatDeg);
|
||||
let T = T_itcz * (1 - blend) + T_flat * blend;
|
||||
|
||||
// ── 2. Elevation lapse rate ──
|
||||
// Moisture-dependent: dry air cools at ~9.8 C/km (dry adiabatic),
|
||||
// saturated air at ~5 C/km (moist adiabatic) due to latent heat
|
||||
// release. Use precipitation as a moisture proxy to interpolate.
|
||||
const moisture = r_precip ? r_precip[r] : 0.5;
|
||||
const lapse = 4.5 + 4.8 * (1 - moisture); // 4.5 C/km (wet) to 9.3 C/km (dry)
|
||||
if (isLand && elev > 0) {
|
||||
T -= lapse * elevToHeightKm(elev);
|
||||
}
|
||||
|
||||
// ── 5. Ocean current temperature influence ──
|
||||
if (!isLand && r_oceanWarmth && r_oceanSpeed) {
|
||||
// Direct ocean effect: warm/cold currents shift SST
|
||||
const warmth = r_oceanWarmth[r];
|
||||
const speed = r_oceanSpeed[r];
|
||||
T += warmth * Math.min(1, speed * 2) * 16;
|
||||
} else if (isLand) {
|
||||
// Coastal land: diffused ocean warmth fades with plate-based
|
||||
// continentality so the effect reaches further inland and
|
||||
// crosses continental shelves naturally
|
||||
const cw = coastalWarmth[r];
|
||||
if (Math.abs(cw) > 0.001) {
|
||||
T += cw * (1 - smoothstep(0, 0.95, pCont)) * 20;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 6. Precipitation / cloud cover moderation ──
|
||||
if (r_precip) {
|
||||
const p = r_precip[r];
|
||||
if (p > 0.5) {
|
||||
// High precip → clouds → moderate toward latitude baseline
|
||||
const mod = smoothstep(0.5, 1.0, p) * 0.15;
|
||||
// Pull toward 0 (moderate extremes)
|
||||
T *= (1 - mod);
|
||||
} else if (p < 0.3) {
|
||||
// Low precip → clear skies → amplify extremes
|
||||
const amp = smoothstep(0.3, 0.0, p) * 0.15;
|
||||
T *= (1 + amp);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 7. Maritime / continental moderation ──
|
||||
// Ocean has high thermal inertia: coasts and small islands have
|
||||
// smaller seasonal temperature swings (moderate climate), while
|
||||
// continental interiors get more extreme summers and winters.
|
||||
// Compute an annual-mean baseline (ITCZ at equator, no seasonal
|
||||
// shift) and scale the seasonal deviation by continentality.
|
||||
{
|
||||
const distAnn = Math.abs(lat) / DEG; // distance from equator
|
||||
const tAnn = Math.max(0, distAnn - tropicalHW) / maxDist;
|
||||
const T_annual = 28 - 47 * Math.pow(tAnn, 1.4); // match new curve
|
||||
// Apply same moisture-dependent lapse to annual baseline
|
||||
const T_ann_adj = isLand && elev > 0
|
||||
? T_annual - lapse * elevToHeightKm(elev)
|
||||
: T_annual;
|
||||
const deviation = T - T_ann_adj;
|
||||
// Latitude-dependent seasonal boost: ITCZ shift alone gives ~5-6°C
|
||||
// swing; real planets have 15-25°C from direct solar heating.
|
||||
// Peaks at 55-75° latitude, zero at equator and poles.
|
||||
const seasonalBoost = 12 * smoothstep(10, 55, distAnn)
|
||||
* (1 - smoothstep(75, 90, distAnn));
|
||||
const isLocalSummer = (name === 'summer') ? (lat >= 0) : (lat < 0);
|
||||
const seasonSign = isLocalSummer ? 1 : -1;
|
||||
const boostedDeviation = deviation + seasonSign * seasonalBoost;
|
||||
// Maritime: coast damps swing to 50%, deep interior amplifies to 120%
|
||||
const maritimeFactor = 0.50 + cont * 0.70;
|
||||
T = T_ann_adj + boostedDeviation * maritimeFactor;
|
||||
}
|
||||
|
||||
T += temperatureOffset;
|
||||
temp[r] = T;
|
||||
}
|
||||
|
||||
const tCompute = performance.now() - t0;
|
||||
|
||||
// ── 7. Laplacian smoothing ──
|
||||
const tSmooth0 = performance.now();
|
||||
smoothField(mesh, temp, smoothPasses);
|
||||
const tSmooth = performance.now() - tSmooth0;
|
||||
|
||||
// ── 8. Normalize to 0-1 using fixed range ──
|
||||
const tNorm0 = performance.now();
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
temp[r] = Math.max(0, Math.min(1, (temp[r] - T_MIN) / T_RANGE));
|
||||
}
|
||||
const tNorm = performance.now() - tNorm0;
|
||||
|
||||
timing.push({ stage: `Temp: compute (${name})`, ms: tCompute });
|
||||
timing.push({ stage: `Temp: smooth (${name})`, ms: tSmooth });
|
||||
timing.push({ stage: `Temp: normalize (${name})`, ms: tNorm });
|
||||
|
||||
result[`r_temperature_${name}`] = temp;
|
||||
}
|
||||
|
||||
result._tempTiming = timing;
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
// Terrain generation tunable constants.
|
||||
// Grouped by subsystem for iterative tuning.
|
||||
// These are internal algorithm constants, NOT user-facing slider parameters.
|
||||
|
||||
// ── Collision & Stress ──
|
||||
export const COLLISION_THRESHOLD = 0.75;
|
||||
export const COLLISION_DT_BASE = 1e-2;
|
||||
export const COLLISION_DT_REF_REGIONS = 10000;
|
||||
export const PAIR_INTENSITY_BASE = 0.5;
|
||||
export const SUBDUCT_UNDULATION_DENSITY_DECAY = 12;
|
||||
export const SUBDUCT_UNDULATION_FREQ = 6;
|
||||
export const SUBDUCT_UNDULATION_AMP = 0.4;
|
||||
export const SUBDUCT_FACTOR_BASE = 0.5;
|
||||
export const SUBDUCT_FACTOR_TANH_SCALE = 8;
|
||||
export const SUBDUCT_THRESHOLD = 0.55;
|
||||
export const BOUNDARY_TYPE_THRESH_FACTOR = 0.3;
|
||||
|
||||
export const STRESS_PROPAGATE_MIN = 0.01;
|
||||
export const STRESS_PROPAGATE_CUTOFF = 0.005;
|
||||
export const STRESS_DIR_FACTOR_MIN = 0.1;
|
||||
export const STRESS_DIR_FACTOR_BASE = 0.3;
|
||||
export const STRESS_DIR_FACTOR_SCALE = 0.7;
|
||||
export const STRESS_DIR_BLEND_PARENT = 0.8;
|
||||
export const STRESS_DIR_BLEND_TRAVEL = 0.2;
|
||||
export const STRESS_DIR_SMOOTH_PASSES = 2;
|
||||
export const STRESS_DIR_SELF_WEIGHT = 2;
|
||||
|
||||
export const STRESS_DECAY_BASE = 0.5;
|
||||
export const STRESS_DECAY_SPREAD_FACTOR = 0.04;
|
||||
export const STRESS_SUBDUCT_DECAY_MULT = 0.45;
|
||||
export const STRESS_PASSES_PER_SPREAD = 3;
|
||||
|
||||
export const STRESS_PERCENTILE = 0.97;
|
||||
|
||||
// Blend weights for dual-layer orogeny (small plates vs super plates)
|
||||
export const SMALL_W = 0.05;
|
||||
export const SUPER_W = 0.95;
|
||||
|
||||
// ── Distance Fields & Zone Widths ──
|
||||
export const INTERIOR_BAND_BASE = 16;
|
||||
export const TECTONIC_REACH_BASE = 20;
|
||||
export const COASTAL_PLAIN_WIDTH_BASE = 18;
|
||||
export const COAST_BFS_WIDTH_BASE = 8;
|
||||
|
||||
// ── Mountain Profiles ──
|
||||
export const RIDGE_STRENGTH = 0.15;
|
||||
export const RIDGE_SIGMA_BASE = 5;
|
||||
export const RIDGE_PEAK_SHIFT_BASE = 2;
|
||||
export const RIDGE_EXTENT_BASE = 10;
|
||||
export const RIDGE_ASYM_SUBDUCT_NARROW = 0.6;
|
||||
export const RIDGE_ASYM_OVERRIDE_WIDEN = 0.5;
|
||||
export const RIDGE_STRESS_WIDTH_BASE = 0.75;
|
||||
export const RIDGE_STRESS_WIDTH_SCALE = 0.5;
|
||||
export const RIDGE_WIDTH_NOISE_AMP = 0.2;
|
||||
export const RIDGE_HEIGHT_VAR_BASE = 0.6;
|
||||
export const RIDGE_HEIGHT_VAR_SCALE = 0.6;
|
||||
export const RIDGE_HEIGHT_VAR_FREQ = 2.5;
|
||||
|
||||
export const BASE_SCALE = 0.6;
|
||||
export const ASYMMETRY_FACTOR = 0.8;
|
||||
|
||||
export const SUBDUCTING_SUPPRESSION = 0.42;
|
||||
|
||||
export const STRESS_MAG_SCALE = 0.40;
|
||||
export const STRESS_DEPRESS_FRAC = 0.4;
|
||||
export const STRESS_HEIGHT_VAR_BASE = 0.60;
|
||||
export const STRESS_HEIGHT_VAR_SCALE = 0.8;
|
||||
|
||||
export const SUBDUCTING_REACH_MIN = 0.35;
|
||||
export const SUBDUCTING_REACH_RANGE = 0.3;
|
||||
|
||||
// ── Fold Ridges ──
|
||||
export const FOLD_FREQ_PRIMARY = 160;
|
||||
export const FOLD_FREQ_SECONDARY = 400;
|
||||
export const FOLD_MEAN_OFFSET = 0.36;
|
||||
export const FOLD_PHASE_WARP_AMP = 0.08;
|
||||
export const FOLD_PHASE_WARP2_AMP = 0.12;
|
||||
export const FOLD_AMP_MOD_BASE = 0.6;
|
||||
export const FOLD_AMP_MOD_SCALE = 0.4;
|
||||
export const FOLD_AMP_MOD2_BASE = 0.5;
|
||||
export const FOLD_AMP_MOD2_SCALE = 0.5;
|
||||
export const FOLD_SECONDARY_ALONG = 0.85;
|
||||
export const FOLD_SECONDARY_CROSS = 0.15;
|
||||
export const FOLD_SECONDARY_AMP = 0.18;
|
||||
export const FOLD_NOISE_MAG_SCALE = 0.8;
|
||||
export const FOLD_ELEV_THRESHOLD = 0.05;
|
||||
export const FOLD_ELEV_SCALE = 4;
|
||||
export const FOLD_ELEV_BOOST_OFFSET = 0.03;
|
||||
export const FOLD_ELEV_BOOST_SCALE = 6;
|
||||
export const FOLD_SF_SUPPRESS = 1.5;
|
||||
export const FOLD_FREQ_MULT_SCALE = 2.0;
|
||||
|
||||
// ── Basins & Rifts ──
|
||||
export const RIFT_HALF_WIDTH_BASE = 4;
|
||||
export const RIFT_FLOOR_MULT = 1.5;
|
||||
export const RIFT_SHOULDER_MULT = 2.5;
|
||||
export const RIFT_AXIS_DEPTH = -0.18;
|
||||
export const RIFT_AXIS_VOLCANIC_AMP = 0.06;
|
||||
export const RIFT_FLOOR_DEPTH = -0.12;
|
||||
export const RIFT_FLOOR_TAPER = 0.3;
|
||||
export const RIFT_FLOOR_VOLCANIC_AMP = 0.03;
|
||||
export const RIFT_SHOULDER_UPLIFT = 0.05;
|
||||
export const RIFT_FADEOUT_RESIDUAL = 0.2;
|
||||
|
||||
export const BASIN_FREQ = 1.8;
|
||||
export const BASIN_FACTOR_BIAS = 0.5;
|
||||
export const BASIN_FACTOR_SCALE = 0.6;
|
||||
export const FORELAND_STRESS_THRESH = 0.15;
|
||||
export const FORELAND_WIDTH_FRAC = 0.3;
|
||||
export const FORELAND_BASIN_DEPTH = 0.05;
|
||||
export const FORELAND_PEAK_POS = 0.2;
|
||||
export const FORELAND_BASIN_DEEPENING_BASE = 0.5;
|
||||
export const FORELAND_BASIN_DEEPENING_SCALE = 0.5;
|
||||
|
||||
// ── Back-Arc & Foreland ──
|
||||
export const BACK_ARC_START_BASE = 2;
|
||||
export const BACK_ARC_PEAK_BASE = 3;
|
||||
export const BACK_ARC_END_BASE = 5;
|
||||
export const BACK_ARC_DEPTH = 0.14;
|
||||
export const BACK_ARC_SUBDUCT_THRESH = 0.50;
|
||||
|
||||
// ── Noise Layering ──
|
||||
export const WARP_SCALE = 0.4;
|
||||
export const OROGENIC_FREQ = 1.5;
|
||||
export const NOISE_ACTIVITY_SCALE = 4;
|
||||
export const NOISE_BASE_SCALE = 0.25;
|
||||
export const NOISE_ACTIVITY_CONTRIB = 0.75;
|
||||
export const PLATEAU_SUPPRESS_MIN = 0.30;
|
||||
export const PLATEAU_SUPPRESS_SCALE = 0.60;
|
||||
export const BASIN_AMP_SUPPRESS = 0.5;
|
||||
export const CRATON_AMP_SUPPRESS = 0.25;
|
||||
export const RIDGED_NOISE_AMP = 1.5;
|
||||
export const DETAIL_NOISE_FREQ_MULT = 4;
|
||||
export const DETAIL_NOISE_AMP = 0.5;
|
||||
export const FINE_NOISE_FREQ_MULT = 8;
|
||||
export const FINE_NOISE_AMP = 0.25;
|
||||
export const OCEAN_NOISE_AMP = 0.3;
|
||||
|
||||
// ── Dissection & Summits ──
|
||||
export const DISSECT_THRESHOLD = 0.10;
|
||||
export const DISSECT_AMP = 0.55;
|
||||
export const DISSECT_ELEV_SCALE = 2;
|
||||
export const SUMMIT_THRESHOLD = 0.55;
|
||||
export const SUMMIT_STRESS_MIN = 0.03;
|
||||
export const SUMMIT_SPIKE_OFFSET = 0.40;
|
||||
export const SUMMIT_STRESS_FLOOR = 0.25;
|
||||
|
||||
// ── Interior Elevation ──
|
||||
export const PLATE_BASE_HEIGHT_MEAN = -0.15;
|
||||
export const PLATE_BASE_HEIGHT_STDDEV = 0.025;
|
||||
export const INTERIOR_BASE_SHIELD = 0.14;
|
||||
export const INTERIOR_BASE_BASIN = 0.04;
|
||||
export const INTERIOR_TECTONIC = 0.20;
|
||||
export const COASTAL_DEPRESSION = -0.08;
|
||||
export const COASTAL_DEPRESSION_BASIN_REDUCE = 0.4;
|
||||
export const INTERIOR_UPLIFT_RAMP_FRAC = 0.4;
|
||||
export const INTERIOR_UPLIFT_MOD_AMP = 0.2;
|
||||
export const INTERIOR_FLOOR = 0.008;
|
||||
export const PLATEAU_BOOST = 0.04;
|
||||
export const PLATEAU_START_BASE = 3;
|
||||
export const MOUNTAIN_BOOST_FRAC = 0.3;
|
||||
export const FOLD_BELT_MULT = 3;
|
||||
export const CRATON_TECTONIC_MULT = 2.5;
|
||||
export const BASIN_TECTONIC_MULT = 2;
|
||||
|
||||
// ── Continental Margins ──
|
||||
export const SHELF_NARROW_BASE = 4;
|
||||
export const SHELF_WIDE_BASE = 12;
|
||||
export const SLOPE_WIDTH_BASE = 7;
|
||||
export const SHELF_DEPTH_START = -0.08;
|
||||
export const SHELF_DEPTH_RANGE = 0.08;
|
||||
export const SLOPE_DEPTH_RANGE = 0.19;
|
||||
export const ABYSS_BASE = -0.35;
|
||||
export const ABYSS_NOISE_AMP = 0.03;
|
||||
export const OCEAN_FLOOR_CLAMP = -0.005;
|
||||
|
||||
// ── Mid-Ocean Features ──
|
||||
export const RIDGE_HALF_WIDTH_BASE = 4;
|
||||
export const RIDGE_UPLIFT_NOISE = 0.12;
|
||||
export const RIDGE_UPLIFT_BASE = 0.06;
|
||||
export const FRACTURE_HALF_WIDTH_BASE = 3;
|
||||
export const FRACTURE_DEPTH = 0.03;
|
||||
export const TRENCH_BASE_DEPTH = 0.20;
|
||||
export const TRENCH_STRESS_DEPTH = 0.20;
|
||||
|
||||
// ── Coastal Roughening ──
|
||||
export const COAST_ROUGHEN_BASE = 8;
|
||||
export const COAST_PASSIVE_FREQ = 6;
|
||||
export const COAST_ACTIVE_FREQ = 9;
|
||||
export const COAST_PASSIVE_AMP = 0.08;
|
||||
export const COAST_ACTIVE_AMP = 0.12;
|
||||
export const COAST_WARP_PASSIVE_REACH = 1.2;
|
||||
export const COAST_WARP_ACTIVE_REACH = 1.5;
|
||||
export const COAST_WARP_AMT = 0.35;
|
||||
export const COAST_SUBDUCT_SUP_LOW = 0.45;
|
||||
export const COAST_SUBDUCT_SUP_RANGE = 0.55;
|
||||
|
||||
// ── Island Scattering ──
|
||||
export const ISLAND_DIST_BASE = 4;
|
||||
export const ISLAND_FREQ = 17.5;
|
||||
export const ISLAND_THRESHOLD_BASE = 0.35;
|
||||
export const ISLAND_THRESHOLD_STRESS = 0.2;
|
||||
export const ISLAND_BUMP_AMP = 0.22;
|
||||
export const ISLAND_PEAK_FLOOR = 0.04;
|
||||
export const ISLAND_SUBDUCT_MAX = 0.3;
|
||||
|
||||
export const MAX_OCEAN_ARC_ELEV = 0.20;
|
||||
|
||||
// ── Island Arcs ──
|
||||
export const ARC_DIST_BASE = 5;
|
||||
export const ARC_PEAK_DIST_BASE = 1.5;
|
||||
export const ARC_SIGMA_BASE_VAL = 1.5;
|
||||
export const ARC_THRESHOLD = 0.30;
|
||||
export const ARC_UPLIFT_AMP = 0.55;
|
||||
export const ARC_SUBDUCT_THRESH = 0.45;
|
||||
|
||||
// ── Volcanic Features ──
|
||||
export const VOLC_MIN_SPACING = 0.015;
|
||||
export const VOLC_SIGMA_BASE = 0.003;
|
||||
export const VOLC_HEIGHT_BASE = 0.15;
|
||||
export const VOLC_HEIGHT_VAR_BASE = 0.7;
|
||||
export const VOLC_HEIGHT_VAR_RANGE = 0.6;
|
||||
export const VOLC_SIGMA_VAR_BASE = 0.6;
|
||||
export const VOLC_SIGMA_VAR_RANGE = 0.8;
|
||||
export const VOLC_SUBDUCT_THRESH = 0.45;
|
||||
|
||||
// ── Large Igneous Provinces ──
|
||||
export const LIP_SIGMA = 0.025;
|
||||
export const LIP_HEIGHT = 0.04;
|
||||
|
||||
// ── Hotspot Chains ──
|
||||
export const NUM_HOTSPOTS = 5;
|
||||
export const CHAIN_LENGTH = 6;
|
||||
export const CHAIN_DECAY = 0.75;
|
||||
export const CHAIN_SPACING = 0.06;
|
||||
export const DOME_SIGMA = 0.006;
|
||||
export const DOME_STRENGTH = 0.60;
|
||||
export const SWELL_SIGMA_MULT = 2;
|
||||
export const SWELL_STR_MULT = 0.10;
|
||||
export const DOME_OCEAN_BOOST = 1.8;
|
||||
export const DOME_PEAK_THRESH_SIGMA = 5.5;
|
||||
export const DOME_SWELL_THRESH_SIGMA = 3;
|
||||
export const DOME_DRIFT_STRETCH = 1.4;
|
||||
export const DOME_RIFT_BOOST = 0.5;
|
||||
export const DOME_CALDERA_SIGMA_FRAC = 0.25;
|
||||
export const DOME_CALDERA_DEPTH_FRAC = 0.20;
|
||||
export const DOME_CALDERA_STRENGTH_MIN = 0.15;
|
||||
export const DOME_AGE_BROADENING = 0.06;
|
||||
export const DOME_SHAPE_WARP_FREQ = 8;
|
||||
export const DOME_SHAPE_WARP_AMP = 0.4;
|
||||
export const DOME_SHAPE_WARP_DETAIL_FREQ = 20;
|
||||
export const DOME_SHAPE_WARP_DETAIL_AMP = 0.40;
|
||||
export const DOME_TEXTURE_BASE_WEIGHT = 0.7;
|
||||
export const DOME_TEXTURE_DETAIL_WEIGHT = 0.3;
|
||||
export const DOME_TEXTURE_ACTIVE_MIN = 0.4;
|
||||
export const DOME_TEXTURE_ACTIVE_MAX = 1.2;
|
||||
export const DOME_TEXTURE_AGE_MIN_SHIFT = 0.3;
|
||||
export const DOME_TEXTURE_AGE_MAX_SHIFT = 0.2;
|
||||
|
||||
// ── Hypsometry & Isostasy ──
|
||||
export const PEAK_COMPRESS_POWER = 0.90;
|
||||
export const ISOSTATIC_K = 0.07;
|
||||
export const HYPS_BLEND = 0.40;
|
||||
export const HYPS_LOW_BREAK = 0.60;
|
||||
export const HYPS_MID_BREAK = 0.85;
|
||||
export const HYPS_LOW_ELEV_FRAC = 0.25;
|
||||
export const HYPS_MID_ELEV_FRAC = 0.35;
|
||||
export const HYPS_HIGH_POWER = 0.7;
|
||||
export const FILL_LEVEL = 0.005;
|
||||
|
||||
// ── Passive Margin Coastal Plain ──
|
||||
export const PLAIN_TARGET = 0.02;
|
||||
export const PLAIN_SUPPRESSION_STRENGTH = 0.6;
|
||||
|
||||
// ── Domain Warp (terrain-post.js) ──
|
||||
export const WARP_FREQ = 4;
|
||||
export const WARP_OCTAVES = 5;
|
||||
export const WARP_MAX_AMP_MULT = 0.13;
|
||||
export const WARP_BIAS_BASE = 0.25;
|
||||
export const WARP_BIAS_STRENGTH_SCALE = 0.5;
|
||||
export const WARP_HOTSPOT_DAMPEN = 0.8;
|
||||
|
||||
// ── Smoothing (terrain-post.js) ──
|
||||
export const SMOOTH_EDGE_SENSITIVITY = 12;
|
||||
|
||||
// ── Glacial Erosion (terrain-post.js) ──
|
||||
export const GLACIAL_LAT_DIVISOR = 4.5;
|
||||
export const GLACIAL_ELEV_LOW = 0.5;
|
||||
export const GLACIAL_ELEV_HIGH = 0.9;
|
||||
export const GLACIAL_ELEV_FACTOR_SCALE = 0.3;
|
||||
export const GLACIAL_ELEV_FACTOR_LAT_BASE = 0.3;
|
||||
export const GLACIAL_ELEV_FACTOR_LAT_SCALE = 0.7;
|
||||
export const GLACIAL_CARVE_RATE = 0.025;
|
||||
export const GLACIAL_CONVERGENCE_BONUS = 0.015;
|
||||
export const GLACIAL_DEPOSIT_AMOUNT = 0.007;
|
||||
export const GLACIAL_FJORD_CARVE = 0.020;
|
||||
export const GLACIAL_FLOW_THRESHOLD = 0.1;
|
||||
export const GLACIAL_FJORD_THRESHOLD = 0.5;
|
||||
export const GLACIAL_WIDENING_FRAC = 0.4;
|
||||
export const GLACIAL_TERMINUS_RATIO = 0.3;
|
||||
export const GLACIAL_FJORD_ICE_MIN = 0.2;
|
||||
export const GLACIAL_POST_SMOOTH = 0.3;
|
||||
export const GLACIAL_MID_FLOOD_FRAC = 0.75;
|
||||
export const GLACIAL_MID_FLOOD_CARVE = 0.85;
|
||||
export const GLACIAL_INITIAL_CARVE = 0.5;
|
||||
|
||||
// ── Hydraulic Erosion (terrain-post.js) ──
|
||||
export const HYDRAULIC_DEPOSIT_FRAC = 0.5;
|
||||
export const HYDRAULIC_SLOPE_SENSITIVITY = 50;
|
||||
|
||||
// ── Thermal Erosion (terrain-post.js) ──
|
||||
export const THERMAL_TRANSFER_FRAC = 0.5;
|
||||
|
||||
// ── Ridge Sharpening (terrain-post.js) ──
|
||||
export const RIDGE_SHARPEN_CAP = 2.0;
|
||||
export const VALLEY_DEEPEN_FACTOR = 0.5;
|
||||
export const VALLEY_FLOOR_FRAC = 0.5;
|
||||
export const VALLEY_FLOOR_MIN = 0.001;
|
||||
|
||||
// ── Priority Flood (terrain-post.js) ──
|
||||
export const FLOOD_NOISE_AMP = 0.01;
|
||||
export const FLOOD_CARVE_RADIUS_FRAC = 0.3;
|
||||
|
||||
// ── Plate Generation ──
|
||||
export const PLATE_LOW_PLATE_T_HIGH = 80;
|
||||
export const PLATE_LOW_PLATE_T_RANGE = 60;
|
||||
export const PLATE_RATE_MIN_BASE = 0.7;
|
||||
export const PLATE_RATE_MIN_LOW_T = 0.4;
|
||||
export const PLATE_RATE_RANGE_BASE = 2.3;
|
||||
export const PLATE_RATE_RANGE_LOW_T = 2.4;
|
||||
export const PLATE_DIR_BASE_BASE = 0.15;
|
||||
export const PLATE_DIR_BASE_LOW_T = 0.25;
|
||||
export const PLATE_DIR_SCALE_BASE = 0.25;
|
||||
export const PLATE_DIR_SCALE_LOW_T = 0.25;
|
||||
export const PLATE_DIR_STRENGTH_CAP = 0.85;
|
||||
export const PLATE_COMPACT_BASE = 0.3;
|
||||
export const PLATE_COMPACT_LOW_T = 0.22;
|
||||
export const PLATE_AREA_GOVERNOR_BASE = 2.0;
|
||||
export const PLATE_AREA_GOVERNOR_LOW_T = 2.0;
|
||||
export const PLATE_COMPACT_THRESHOLD_MULT = 1.8;
|
||||
export const PLATE_COMPACT_PENALTY_MULT = 4;
|
||||
export const PLATE_OMEGA_MIN = 0.5;
|
||||
export const PLATE_OMEGA_RANGE = 1.5;
|
||||
export const PLATE_SMOOTH_BASE = 3;
|
||||
export const PLATE_SMOOTH_LOW_T = 2;
|
||||
export const PLATE_SMOOTH_FIRST_THRESH = 0.4;
|
||||
export const PLATE_SMOOTH_LATER_THRESH = 0.5;
|
||||
|
||||
// ── Coarse Projection ──
|
||||
export const N_COARSE = 20000;
|
||||
export const COARSE_JITTER = 0.75;
|
||||
export const COARSE_PERTURB_BASE = 1.5;
|
||||
export const COARSE_PERTURB_LOW_T = 1.0;
|
||||
export const COARSE_FBM_BASE_FREQ = 8;
|
||||
export const COARSE_FBM_OCTAVES = 4;
|
||||
export const COARSE_FBM_DECAY = 0.5;
|
||||
export const COARSE_FBM_FREQ_MULT = 2;
|
||||
@@ -0,0 +1,839 @@
|
||||
// Terrain quality metrics — computes a numeric scorecard from generation
|
||||
// output for automated tuning evaluation. Runs inside the web worker
|
||||
// after generation completes.
|
||||
//
|
||||
// Each metric function receives a context object with mesh, arrays, and
|
||||
// debug layers, and returns a plain object of named scores.
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Average edge length in radians for the current mesh resolution. */
|
||||
function avgEdgeRad(numRegions) {
|
||||
return Math.PI / Math.sqrt(numRegions);
|
||||
}
|
||||
|
||||
/** Convert a BFS hop‐distance to approximate km (Earth radius). */
|
||||
function hopsToKm(hops, numRegions) {
|
||||
return hops * avgEdgeRad(numRegions) * 6371;
|
||||
}
|
||||
|
||||
/** Percentile of a Float32Array (0–1). Mutates a copy. */
|
||||
function percentile(arr, p) {
|
||||
const sorted = Float32Array.from(arr).sort();
|
||||
const idx = Math.min(Math.floor(p * sorted.length), sorted.length - 1);
|
||||
return sorted[idx];
|
||||
}
|
||||
|
||||
/** Flood-fill connected components on a boolean mask using mesh adjacency. */
|
||||
function connectedComponents(mesh, mask) {
|
||||
const N = mesh.numRegions;
|
||||
const label = new Int32Array(N).fill(-1);
|
||||
const components = []; // array of { id, cells: Set }
|
||||
let nextId = 0;
|
||||
const queue = [];
|
||||
|
||||
for (let r = 0; r < N; r++) {
|
||||
if (!mask[r] || label[r] >= 0) continue;
|
||||
const id = nextId++;
|
||||
const cells = new Set();
|
||||
label[r] = id;
|
||||
cells.add(r);
|
||||
queue.length = 0;
|
||||
queue.push(r);
|
||||
let head = 0;
|
||||
while (head < queue.length) {
|
||||
const cur = queue[head++];
|
||||
const off0 = mesh.adjOffset[cur];
|
||||
const off1 = mesh.adjOffset[cur + 1];
|
||||
for (let i = off0; i < off1; i++) {
|
||||
const nb = mesh.adjList[i];
|
||||
if (mask[nb] && label[nb] < 0) {
|
||||
label[nb] = id;
|
||||
cells.add(nb);
|
||||
queue.push(nb);
|
||||
}
|
||||
}
|
||||
}
|
||||
components.push({ id, cells });
|
||||
}
|
||||
return { label, components };
|
||||
}
|
||||
|
||||
/** BFS distance (in hops) from a seed set, with optional barrier mask. */
|
||||
function bfsDistance(mesh, seeds, barrier) {
|
||||
const N = mesh.numRegions;
|
||||
const dist = new Int32Array(N).fill(-1);
|
||||
const queue = [];
|
||||
let head = 0;
|
||||
for (const r of seeds) {
|
||||
if (barrier && barrier[r]) continue;
|
||||
dist[r] = 0;
|
||||
queue.push(r);
|
||||
}
|
||||
while (head < queue.length) {
|
||||
const cur = queue[head++];
|
||||
const d1 = dist[cur] + 1;
|
||||
const off0 = mesh.adjOffset[cur];
|
||||
const off1 = mesh.adjOffset[cur + 1];
|
||||
for (let i = off0; i < off1; i++) {
|
||||
const nb = mesh.adjList[i];
|
||||
if (dist[nb] >= 0) continue;
|
||||
if (barrier && barrier[nb]) continue;
|
||||
dist[nb] = d1;
|
||||
queue.push(nb);
|
||||
}
|
||||
}
|
||||
return dist;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Tier 1 — Artistic Interest
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Continental Silhouette Variety
|
||||
* Measures variance of convex-hull-solidity across continents.
|
||||
* (Approximated: since we're on a sphere mesh, we use the ratio of
|
||||
* actual cell count to the BFS-bounding-box area as a proxy for solidity.)
|
||||
*/
|
||||
function continentSilhouette(ctx) {
|
||||
const { mesh, r_elevation } = ctx;
|
||||
const N = mesh.numRegions;
|
||||
const isLand = new Uint8Array(N);
|
||||
for (let r = 0; r < N; r++) if (r_elevation[r] > 0) isLand[r] = 1;
|
||||
|
||||
const { components } = connectedComponents(mesh, isLand);
|
||||
// Filter to continents (>0.5% of land cells)
|
||||
const totalLand = components.reduce((s, c) => s + c.cells.size, 0);
|
||||
const minSize = Math.max(10, totalLand * 0.005);
|
||||
const continents = components.filter(c => c.cells.size >= minSize);
|
||||
const islands = components.filter(c => c.cells.size < minSize);
|
||||
|
||||
// Approximate solidity: area / (pi * (max_bfs_radius)^2)
|
||||
// We compute max BFS radius from centroid of each continent
|
||||
const solidities = [];
|
||||
for (const cont of continents) {
|
||||
const cellArr = Array.from(cont.cells);
|
||||
// Find approximate centroid (cell with min max-distance to others via BFS from random sample)
|
||||
const sample = cellArr[Math.floor(cellArr.length / 2)];
|
||||
const distFromSample = bfsDistance(mesh, [sample], null);
|
||||
let maxDist = 0;
|
||||
for (const r of cellArr) {
|
||||
if (distFromSample[r] > maxDist) maxDist = distFromSample[r];
|
||||
}
|
||||
// Solidity proxy: cellCount / (pi * maxDist^2)
|
||||
const circleArea = Math.PI * maxDist * maxDist;
|
||||
const solidity = circleArea > 0 ? Math.min(1, cont.cells.size / circleArea) : 1;
|
||||
solidities.push(solidity);
|
||||
}
|
||||
|
||||
const mean = solidities.length > 0
|
||||
? solidities.reduce((a, b) => a + b, 0) / solidities.length : 0;
|
||||
const variance = solidities.length > 1
|
||||
? solidities.reduce((s, v) => s + (v - mean) ** 2, 0) / solidities.length : 0;
|
||||
|
||||
return {
|
||||
continent_count: continents.length,
|
||||
island_count_total: islands.length,
|
||||
island_cells_total: islands.reduce((s, c) => s + c.cells.size, 0),
|
||||
continent_solidity_mean: +mean.toFixed(4),
|
||||
continent_solidity_variance: +variance.toFixed(4),
|
||||
// Store components for reuse by other metrics
|
||||
_continents: continents,
|
||||
_islands: islands,
|
||||
_isLand: isLand,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Elevation Drama
|
||||
* Relief headroom (p95-p50 of land), plus check that peaks are clustered.
|
||||
*/
|
||||
function elevationDrama(ctx) {
|
||||
const { mesh, r_elevation } = ctx;
|
||||
const N = mesh.numRegions;
|
||||
const landElev = [];
|
||||
for (let r = 0; r < N; r++) {
|
||||
if (r_elevation[r] > 0) landElev.push(r_elevation[r]);
|
||||
}
|
||||
if (landElev.length < 10) {
|
||||
return { relief_headroom: 0, peak_clustering: 0 };
|
||||
}
|
||||
const arr = new Float32Array(landElev);
|
||||
const p50 = percentile(arr, 0.50);
|
||||
const p95 = percentile(arr, 0.95);
|
||||
const relief = p95 - p50;
|
||||
|
||||
// Peak clustering: fraction of top-5% cells that have a top-5% neighbor
|
||||
const threshold = p95;
|
||||
const isPeak = new Uint8Array(N);
|
||||
let peakCount = 0;
|
||||
for (let r = 0; r < N; r++) {
|
||||
if (r_elevation[r] >= threshold) { isPeak[r] = 1; peakCount++; }
|
||||
}
|
||||
let clustered = 0;
|
||||
for (let r = 0; r < N; r++) {
|
||||
if (!isPeak[r]) continue;
|
||||
const off0 = mesh.adjOffset[r];
|
||||
const off1 = mesh.adjOffset[r + 1];
|
||||
for (let i = off0; i < off1; i++) {
|
||||
if (isPeak[mesh.adjList[i]]) { clustered++; break; }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
relief_headroom: +relief.toFixed(4),
|
||||
peak_clustering: peakCount > 0 ? +(clustered / peakCount).toFixed(4) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Coast Complexity
|
||||
* Dimensionless roughness: coastline_cell_count / sqrt(land_cell_count).
|
||||
*/
|
||||
function coastComplexity(ctx) {
|
||||
const { mesh, r_elevation } = ctx;
|
||||
const N = mesh.numRegions;
|
||||
let landCount = 0;
|
||||
let coastCount = 0;
|
||||
for (let r = 0; r < N; r++) {
|
||||
if (r_elevation[r] <= 0) continue;
|
||||
landCount++;
|
||||
const off0 = mesh.adjOffset[r];
|
||||
const off1 = mesh.adjOffset[r + 1];
|
||||
for (let i = off0; i < off1; i++) {
|
||||
if (r_elevation[mesh.adjList[i]] <= 0) { coastCount++; break; }
|
||||
}
|
||||
}
|
||||
const index = landCount > 0 ? coastCount / Math.sqrt(landCount) : 0;
|
||||
return {
|
||||
coast_complexity_index: +index.toFixed(4),
|
||||
coastline_cells: coastCount,
|
||||
land_cells: landCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ocean Floor Texture
|
||||
* Standard deviation of ocean elevations + trench presence.
|
||||
*/
|
||||
function oceanFloorTexture(ctx) {
|
||||
const { mesh, r_elevation } = ctx;
|
||||
const N = mesh.numRegions;
|
||||
const oceanElev = [];
|
||||
for (let r = 0; r < N; r++) {
|
||||
if (r_elevation[r] <= 0) oceanElev.push(r_elevation[r]);
|
||||
}
|
||||
if (oceanElev.length < 10) {
|
||||
return { ocean_elev_stddev: 0, trench_fraction: 0 };
|
||||
}
|
||||
const arr = new Float32Array(oceanElev);
|
||||
const mean = oceanElev.reduce((a, b) => a + b, 0) / oceanElev.length;
|
||||
const variance = oceanElev.reduce((s, v) => s + (v - mean) ** 2, 0) / oceanElev.length;
|
||||
const stddev = Math.sqrt(variance);
|
||||
|
||||
// Trench fraction: cells below p2 (expect distinct spike)
|
||||
const p02 = percentile(arr, 0.02);
|
||||
const p05 = percentile(arr, 0.05);
|
||||
const trenchGap = p05 - p02; // distance between p2 and p5 — large = distinct trench tail
|
||||
|
||||
return {
|
||||
ocean_elev_stddev: +stddev.toFixed(5),
|
||||
ocean_trench_gap: +trenchGap.toFixed(5),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Flat Land on Ocean Plates
|
||||
* Land cells assigned to ocean plates that lack volcanic/tectonic relief.
|
||||
* These should be mountainous/volcanic, not flat plains.
|
||||
*/
|
||||
function flatOceanPlateLand(ctx) {
|
||||
const { mesh, r_elevation, r_plate, plateIsOcean, debugLayers } = ctx;
|
||||
const N = mesh.numRegions;
|
||||
const oceanPlateSet = new Set(plateIsOcean);
|
||||
let oceanPlateLandCount = 0;
|
||||
let flatOceanPlateLandCount = 0;
|
||||
const FLAT_THRESHOLD = 0.21; // below ~50m (quartic elev mapping) — barely above sea level
|
||||
|
||||
for (let r = 0; r < N; r++) {
|
||||
if (r_elevation[r] <= 0) continue;
|
||||
if (!oceanPlateSet.has(r_plate[r])) continue;
|
||||
// This is land on an ocean plate
|
||||
oceanPlateLandCount++;
|
||||
if (r_elevation[r] < FLAT_THRESHOLD) {
|
||||
flatOceanPlateLandCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ocean_plate_land_cells: oceanPlateLandCount,
|
||||
flat_ocean_plate_land_cells: flatOceanPlateLandCount,
|
||||
flat_ocean_plate_land_fraction: oceanPlateLandCount > 0
|
||||
? +(flatOceanPlateLandCount / oceanPlateLandCount).toFixed(4) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Tier 2 — Scientific Plausibility
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Bimodal Hypsometry
|
||||
* Fit two-Gaussian model to elevation histogram. Measure trough depth
|
||||
* and mode positions.
|
||||
*/
|
||||
function bimodalHypsometry(ctx) {
|
||||
const { r_elevation } = ctx;
|
||||
const N = r_elevation.length;
|
||||
const BINS = 200;
|
||||
const minE = -0.5, maxE = 0.8;
|
||||
const binW = (maxE - minE) / BINS;
|
||||
const hist = new Float64Array(BINS);
|
||||
|
||||
for (let r = 0; r < N; r++) {
|
||||
const b = Math.floor((r_elevation[r] - minE) / binW);
|
||||
if (b >= 0 && b < BINS) hist[b]++;
|
||||
}
|
||||
// Normalize
|
||||
const total = hist.reduce((a, b) => a + b, 0);
|
||||
for (let i = 0; i < BINS; i++) hist[i] /= total;
|
||||
|
||||
// Find two peaks: one below sea level (ocean), one above (land)
|
||||
const seaBin = Math.floor((0 - minE) / binW);
|
||||
let oceanPeak = 0, oceanPeakVal = 0;
|
||||
for (let i = 0; i < seaBin; i++) {
|
||||
if (hist[i] > oceanPeakVal) { oceanPeakVal = hist[i]; oceanPeak = i; }
|
||||
}
|
||||
let landPeak = seaBin, landPeakVal = 0;
|
||||
for (let i = seaBin; i < BINS; i++) {
|
||||
if (hist[i] > landPeakVal) { landPeakVal = hist[i]; landPeak = i; }
|
||||
}
|
||||
|
||||
// Trough: minimum between the two peaks
|
||||
let troughVal = Infinity;
|
||||
for (let i = oceanPeak; i <= landPeak; i++) {
|
||||
if (hist[i] < troughVal) troughVal = hist[i];
|
||||
}
|
||||
const peakAvg = (oceanPeakVal + landPeakVal) / 2;
|
||||
const troughDepth = peakAvg > 0 ? 1 - troughVal / peakAvg : 0;
|
||||
|
||||
return {
|
||||
ocean_mode_elev: +(minE + (oceanPeak + 0.5) * binW).toFixed(4),
|
||||
land_mode_elev: +(minE + (landPeak + 0.5) * binW).toFixed(4),
|
||||
hypsometry_trough_depth: +troughDepth.toFixed(4),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mountain–Boundary Spatial Correlation
|
||||
* Top 5% land cells should cluster near actual plate boundaries
|
||||
* (cells where r_plate differs from a neighbor), not the propagated
|
||||
* stress field which extends far inland.
|
||||
*/
|
||||
function mountainBoundaryCorrelation(ctx) {
|
||||
const { mesh, r_elevation, r_plate } = ctx;
|
||||
const N = mesh.numRegions;
|
||||
|
||||
// Find actual plate boundary cells (where r_plate differs from a neighbor)
|
||||
const boundaryCells = [];
|
||||
for (let r = 0; r < N; r++) {
|
||||
const pid = r_plate[r];
|
||||
const off0 = mesh.adjOffset[r];
|
||||
const off1 = mesh.adjOffset[r + 1];
|
||||
for (let i = off0; i < off1; i++) {
|
||||
if (r_plate[mesh.adjList[i]] !== pid) {
|
||||
boundaryCells.push(r);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (boundaryCells.length === 0) {
|
||||
return { mountain_boundary_ratio: 1.0 };
|
||||
}
|
||||
|
||||
const distToBoundary = bfsDistance(mesh, boundaryCells, null);
|
||||
|
||||
// Land cells only
|
||||
const landDists = [];
|
||||
const mountainDists = [];
|
||||
const landElev = [];
|
||||
for (let r = 0; r < N; r++) {
|
||||
if (r_elevation[r] <= 0) continue;
|
||||
landElev.push(r_elevation[r]);
|
||||
}
|
||||
const p95 = percentile(new Float32Array(landElev), 0.95);
|
||||
|
||||
for (let r = 0; r < N; r++) {
|
||||
if (r_elevation[r] <= 0 || distToBoundary[r] < 0) continue;
|
||||
landDists.push(distToBoundary[r]);
|
||||
if (r_elevation[r] >= p95) mountainDists.push(distToBoundary[r]);
|
||||
}
|
||||
|
||||
const medianLand = landDists.length > 0
|
||||
? percentile(new Float32Array(landDists), 0.5) : 0;
|
||||
const medianMountain = mountainDists.length > 0
|
||||
? percentile(new Float32Array(mountainDists), 0.5) : 0;
|
||||
const ratio = medianLand > 0 ? medianMountain / medianLand : 1;
|
||||
|
||||
return {
|
||||
mountain_boundary_ratio: +ratio.toFixed(4),
|
||||
mountain_boundary_median_hops: +medianMountain.toFixed(1),
|
||||
all_land_boundary_median_hops: +medianLand.toFixed(1),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Orogenic Power vs Elevation Correlation
|
||||
* The tectonic signal should survive post-processing.
|
||||
*/
|
||||
function orogenicCorrelation(ctx) {
|
||||
const { r_elevation, debugLayers } = ctx;
|
||||
if (!debugLayers || !debugLayers.orogenicPower) {
|
||||
return { orogenic_elev_correlation: null };
|
||||
}
|
||||
const op = debugLayers.orogenicPower;
|
||||
const N = r_elevation.length;
|
||||
|
||||
// Pearson correlation on land cells
|
||||
let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0, sumY2 = 0, n = 0;
|
||||
for (let r = 0; r < N; r++) {
|
||||
if (r_elevation[r] <= 0) continue;
|
||||
const x = op[r], y = r_elevation[r];
|
||||
sumX += x; sumY += y; sumXY += x * y;
|
||||
sumX2 += x * x; sumY2 += y * y;
|
||||
n++;
|
||||
}
|
||||
if (n < 10) return { orogenic_elev_correlation: 0 };
|
||||
const denom = Math.sqrt((n * sumX2 - sumX * sumX) * (n * sumY2 - sumY * sumY));
|
||||
const corr = denom > 0 ? (n * sumXY - sumX * sumY) / denom : 0;
|
||||
|
||||
return {
|
||||
orogenic_elev_correlation: +corr.toFixed(4),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Erosion–Slope Coherence
|
||||
* Hydraulic erosion should preferentially hit high-slope cells.
|
||||
*/
|
||||
function erosionSlopeCoherence(ctx) {
|
||||
const { mesh, r_elevation, r_xyz, debugLayers } = ctx;
|
||||
if (!debugLayers || !debugLayers.erosionDelta) {
|
||||
return { erosion_slope_correlation: null };
|
||||
}
|
||||
const delta = debugLayers.erosionDelta;
|
||||
const N = mesh.numRegions;
|
||||
|
||||
// Compute slope per land cell (max elevation difference to neighbors)
|
||||
let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0, sumY2 = 0, n = 0;
|
||||
for (let r = 0; r < N; r++) {
|
||||
if (r_elevation[r] <= 0) continue;
|
||||
const off0 = mesh.adjOffset[r];
|
||||
const off1 = mesh.adjOffset[r + 1];
|
||||
let maxSlope = 0;
|
||||
for (let i = off0; i < off1; i++) {
|
||||
const nb = mesh.adjList[i];
|
||||
const dh = Math.abs(r_elevation[r] - r_elevation[nb]);
|
||||
if (dh > maxSlope) maxSlope = dh;
|
||||
}
|
||||
// Erosion delta should be negative (erosion) where slope is high
|
||||
const x = maxSlope;
|
||||
const y = -delta[r]; // positive = more erosion
|
||||
sumX += x; sumY += y; sumXY += x * y;
|
||||
sumX2 += x * x; sumY2 += y * y;
|
||||
n++;
|
||||
}
|
||||
if (n < 10) return { erosion_slope_correlation: 0 };
|
||||
const denom = Math.sqrt((n * sumX2 - sumX * sumX) * (n * sumY2 - sumY * sumY));
|
||||
const corr = denom > 0 ? (n * sumXY - sumX * sumY) / denom : 0;
|
||||
|
||||
return {
|
||||
erosion_slope_correlation: +corr.toFixed(4),
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Tier 1+ — Island Metrics
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Island analysis: count, size distribution, arc association, elevation profile.
|
||||
*/
|
||||
function islandMetrics(ctx, silhouetteResult) {
|
||||
const { mesh, r_elevation, r_stress, r_plate, plateIsOcean } = ctx;
|
||||
const N = mesh.numRegions;
|
||||
const islands = silhouetteResult._islands;
|
||||
const oceanPlateSet = new Set(plateIsOcean);
|
||||
|
||||
if (!islands || islands.length === 0) {
|
||||
return {
|
||||
island_count: 0,
|
||||
island_size_max: 0,
|
||||
island_mean_elevation: 0,
|
||||
island_arc_association: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Size distribution
|
||||
const sizes = islands.map(c => c.cells.size).sort((a, b) => b - a);
|
||||
|
||||
// Distance to high-stress cells (proxy for convergent boundaries)
|
||||
const stressCells = [];
|
||||
for (let r = 0; r < N; r++) {
|
||||
if (r_stress[r] > 0.2) stressCells.push(r);
|
||||
}
|
||||
const distToStress = stressCells.length > 0
|
||||
? bfsDistance(mesh, stressCells, null) : null;
|
||||
|
||||
// Per-island analysis
|
||||
let arcAssocCount = 0;
|
||||
let totalMeanElev = 0;
|
||||
const ARC_DIST_THRESHOLD = Math.round(800 / hopsToKm(1, N)); // ~800km in hops
|
||||
|
||||
for (const island of islands) {
|
||||
// Mean elevation
|
||||
let elevSum = 0;
|
||||
let minDistToStress = Infinity;
|
||||
for (const r of island.cells) {
|
||||
elevSum += r_elevation[r];
|
||||
if (distToStress && distToStress[r] >= 0 && distToStress[r] < minDistToStress) {
|
||||
minDistToStress = distToStress[r];
|
||||
}
|
||||
}
|
||||
totalMeanElev += elevSum / island.cells.size;
|
||||
if (minDistToStress <= ARC_DIST_THRESHOLD) arcAssocCount++;
|
||||
}
|
||||
|
||||
return {
|
||||
island_count: islands.length,
|
||||
island_size_max: sizes[0],
|
||||
island_size_median: sizes[Math.floor(sizes.length / 2)],
|
||||
island_mean_elevation: +(totalMeanElev / islands.length).toFixed(4),
|
||||
island_arc_association: +(arcAssocCount / islands.length).toFixed(4),
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Tier 1+ — Coastal Lowland & Shelf Metrics
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Near-sea-level land fraction and elevation band distribution.
|
||||
*/
|
||||
function coastalLowlandIndex(ctx) {
|
||||
const { r_elevation } = ctx;
|
||||
const N = r_elevation.length;
|
||||
// Elevation bands in normalized units (roughly: 0.01 ≈ 80m)
|
||||
// 0-50m ≈ 0-0.00625, 50-200m ≈ 0.00625-0.025, 200-500m ≈ 0.025-0.0625, 500m+ ≈ 0.0625+
|
||||
// But the exact scale depends on the planet's max elevation.
|
||||
// Use relative bands: bottom 5%, 5-20%, 20-50%, 50%+ of land elevation range.
|
||||
const landElevs = [];
|
||||
for (let r = 0; r < N; r++) {
|
||||
if (r_elevation[r] > 0) landElevs.push(r_elevation[r]);
|
||||
}
|
||||
if (landElevs.length < 10) {
|
||||
return { lowland_fraction: 0, midland_fraction: 0, highland_fraction: 0 };
|
||||
}
|
||||
|
||||
const sorted = new Float32Array(landElevs).sort();
|
||||
const p10 = sorted[Math.floor(sorted.length * 0.10)];
|
||||
const p30 = sorted[Math.floor(sorted.length * 0.30)];
|
||||
|
||||
// Thresholds from elevToHeightKm() quartic mapping:
|
||||
// 50m (0.05km) → elev 0.21, 200m → 0.31, 500m → 0.40
|
||||
let band0_50 = 0, band50_200 = 0, band200_500 = 0, band500plus = 0;
|
||||
for (const e of landElevs) {
|
||||
if (e < 0.21) band0_50++;
|
||||
else if (e < 0.31) band50_200++;
|
||||
else if (e < 0.40) band200_500++;
|
||||
else band500plus++;
|
||||
}
|
||||
const total = landElevs.length;
|
||||
|
||||
return {
|
||||
land_band_0_50m_frac: +(band0_50 / total).toFixed(4),
|
||||
land_band_50_200m_frac: +(band50_200 / total).toFixed(4),
|
||||
land_band_200_500m_frac: +(band200_500 / total).toFixed(4),
|
||||
land_band_500m_plus_frac: +(band500plus / total).toFixed(4),
|
||||
coastal_lowland_fraction: +((band0_50 + band50_200) / total).toFixed(4),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Shelf Width — distance from coast to -200m depth.
|
||||
* Separately for active vs passive margins (using stress as proxy).
|
||||
*/
|
||||
function shelfWidth(ctx) {
|
||||
const { mesh, r_elevation, r_stress } = ctx;
|
||||
const N = mesh.numRegions;
|
||||
|
||||
// Find coastline cells (land adjacent to ocean)
|
||||
const coastCells = [];
|
||||
const coastIsActive = [];
|
||||
for (let r = 0; r < N; r++) {
|
||||
if (r_elevation[r] <= 0) continue;
|
||||
const off0 = mesh.adjOffset[r];
|
||||
const off1 = mesh.adjOffset[r + 1];
|
||||
let isCoast = false;
|
||||
for (let i = off0; i < off1; i++) {
|
||||
if (r_elevation[mesh.adjList[i]] <= 0) { isCoast = true; break; }
|
||||
}
|
||||
if (isCoast) {
|
||||
coastCells.push(r);
|
||||
// Active margin: near high stress
|
||||
coastIsActive.push(r_stress[r] > 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
// For each coast cell, walk outward into ocean measuring:
|
||||
// 1) Distance to shelf break (elevation crossing below p25 of ocean depth)
|
||||
// 2) First-ocean-cell elevation (diagnostic)
|
||||
//
|
||||
// We use a relative shelf break threshold based on actual ocean elevation
|
||||
// distribution rather than a fixed value, since the elevation scale varies.
|
||||
const oceanElevs = [];
|
||||
for (let r = 0; r < N; r++) {
|
||||
if (r_elevation[r] <= 0) oceanElevs.push(r_elevation[r]);
|
||||
}
|
||||
// Shelf break = 25th percentile of ocean depth (shallow quarter = shelf)
|
||||
const SHELF_BREAK_DEPTH = oceanElevs.length > 0
|
||||
? percentile(new Float32Array(oceanElevs), 0.25) : -0.1;
|
||||
|
||||
const activeWidths = [];
|
||||
const passiveWidths = [];
|
||||
let firstOceanElevSum = 0;
|
||||
let firstOceanCount = 0;
|
||||
|
||||
// Sample coastline to keep computation bounded (every 3rd coast cell)
|
||||
for (let ci = 0; ci < coastCells.length; ci += 3) {
|
||||
const start = coastCells[ci];
|
||||
const visited = new Set();
|
||||
visited.add(start);
|
||||
let frontier = [start];
|
||||
let dist = 0;
|
||||
let found = false;
|
||||
const MAX_DIST = 80;
|
||||
|
||||
while (frontier.length > 0 && dist < MAX_DIST) {
|
||||
dist++;
|
||||
const next = [];
|
||||
for (const cur of frontier) {
|
||||
const off0 = mesh.adjOffset[cur];
|
||||
const off1 = mesh.adjOffset[cur + 1];
|
||||
for (let i = off0; i < off1; i++) {
|
||||
const nb = mesh.adjList[i];
|
||||
if (visited.has(nb)) continue;
|
||||
visited.add(nb);
|
||||
if (r_elevation[nb] > 0) continue; // stay in ocean
|
||||
if (dist === 1) {
|
||||
firstOceanElevSum += r_elevation[nb];
|
||||
firstOceanCount++;
|
||||
}
|
||||
if (r_elevation[nb] <= SHELF_BREAK_DEPTH) {
|
||||
if (coastIsActive[ci]) activeWidths.push(dist);
|
||||
else passiveWidths.push(dist);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
next.push(nb);
|
||||
}
|
||||
if (found) break;
|
||||
}
|
||||
if (found) break;
|
||||
frontier = next;
|
||||
}
|
||||
}
|
||||
|
||||
const medianActive = activeWidths.length > 0
|
||||
? percentile(new Float32Array(activeWidths), 0.5) : 0;
|
||||
const medianPassive = passiveWidths.length > 0
|
||||
? percentile(new Float32Array(passiveWidths), 0.5) : 0;
|
||||
|
||||
return {
|
||||
shelf_break_threshold: +SHELF_BREAK_DEPTH.toFixed(4),
|
||||
shelf_width_active_hops: +medianActive.toFixed(1),
|
||||
shelf_width_passive_hops: +medianPassive.toFixed(1),
|
||||
shelf_width_active_km: +hopsToKm(medianActive, N).toFixed(0),
|
||||
shelf_width_passive_km: +hopsToKm(medianPassive, N).toFixed(0),
|
||||
shelf_passive_wider_than_active: medianPassive > medianActive,
|
||||
shelf_measurements_active: activeWidths.length,
|
||||
shelf_measurements_passive: passiveWidths.length,
|
||||
shelf_first_ocean_cell_mean_elev: firstOceanCount > 0
|
||||
? +(firstOceanElevSum / firstOceanCount).toFixed(5) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Continental Interior Elevation Gradient
|
||||
* How steeply land rises from coastline inland.
|
||||
*/
|
||||
function interiorGradient(ctx) {
|
||||
const { mesh, r_elevation } = ctx;
|
||||
const N = mesh.numRegions;
|
||||
|
||||
// Find coastal land cells
|
||||
const coastSeeds = [];
|
||||
const isOcean = new Uint8Array(N);
|
||||
for (let r = 0; r < N; r++) {
|
||||
if (r_elevation[r] <= 0) { isOcean[r] = 1; continue; }
|
||||
const off0 = mesh.adjOffset[r];
|
||||
const off1 = mesh.adjOffset[r + 1];
|
||||
for (let i = off0; i < off1; i++) {
|
||||
if (r_elevation[mesh.adjList[i]] <= 0) { coastSeeds.push(r); break; }
|
||||
}
|
||||
}
|
||||
|
||||
// BFS distance from coast (land only)
|
||||
const distFromCoast = bfsDistance(mesh, coastSeeds, isOcean);
|
||||
|
||||
// Bin by distance, compute mean elevation at each distance band
|
||||
const MAX_BAND = 30; // ~30 hops inland
|
||||
const bandElev = new Float64Array(MAX_BAND);
|
||||
const bandCount = new Int32Array(MAX_BAND);
|
||||
for (let r = 0; r < N; r++) {
|
||||
if (r_elevation[r] <= 0 || distFromCoast[r] < 0) continue;
|
||||
const band = Math.min(distFromCoast[r], MAX_BAND - 1);
|
||||
bandElev[band] += r_elevation[r];
|
||||
bandCount[band]++;
|
||||
}
|
||||
|
||||
// Compute gradient from band 0 to band 5 (first ~5 hops = near-coast)
|
||||
const nearCoastElev = bandCount[0] > 0 ? bandElev[0] / bandCount[0] : 0;
|
||||
let midBand = 5;
|
||||
while (midBand > 1 && bandCount[midBand] === 0) midBand--;
|
||||
const midElev = bandCount[midBand] > 0 ? bandElev[midBand] / bandCount[midBand] : 0;
|
||||
const nearCoastGradient = midBand > 0 ? (midElev - nearCoastElev) / midBand : 0;
|
||||
|
||||
// Gradient per km
|
||||
const hopKm = hopsToKm(1, N);
|
||||
const gradientPerKm = hopKm > 0 ? nearCoastGradient / hopKm : 0;
|
||||
|
||||
return {
|
||||
near_coast_mean_elev: +nearCoastElev.toFixed(5),
|
||||
interior_gradient_per_hop: +nearCoastGradient.toFixed(5),
|
||||
interior_gradient_per_km: +gradientPerKm.toFixed(6),
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Tier 3 — Layer Coherence
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hotspot Contribution Distinctiveness
|
||||
* Kurtosis of hotspot layer — should be high (sparse, intense).
|
||||
*/
|
||||
function hotspotDistinctiveness(ctx) {
|
||||
const { debugLayers } = ctx;
|
||||
if (!debugLayers || !debugLayers.hotspot) {
|
||||
return { hotspot_kurtosis: null };
|
||||
}
|
||||
const hs = debugLayers.hotspot;
|
||||
const N = hs.length;
|
||||
let sum = 0, n = 0;
|
||||
for (let i = 0; i < N; i++) {
|
||||
if (hs[i] !== 0) { sum += hs[i]; n++; }
|
||||
}
|
||||
if (n < 10) return { hotspot_kurtosis: 0, hotspot_active_fraction: 0 };
|
||||
const mean = sum / n;
|
||||
let m2 = 0, m4 = 0;
|
||||
for (let i = 0; i < N; i++) {
|
||||
if (hs[i] === 0) continue;
|
||||
const d = hs[i] - mean;
|
||||
m2 += d * d;
|
||||
m4 += d * d * d * d;
|
||||
}
|
||||
m2 /= n; m4 /= n;
|
||||
const kurtosis = m2 > 0 ? m4 / (m2 * m2) - 3 : 0; // excess kurtosis
|
||||
|
||||
return {
|
||||
hotspot_kurtosis: +kurtosis.toFixed(2),
|
||||
hotspot_active_fraction: +(n / N).toFixed(4),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Back-Arc and Fold Ridge Presence
|
||||
* Verify these features have nonzero signal where expected.
|
||||
*/
|
||||
function backArcFoldPresence(ctx) {
|
||||
const { debugLayers } = ctx;
|
||||
const result = {};
|
||||
|
||||
if (debugLayers && debugLayers.backArc) {
|
||||
const ba = debugLayers.backArc;
|
||||
let nonzero = 0, sum = 0;
|
||||
for (let i = 0; i < ba.length; i++) {
|
||||
if (ba[i] !== 0) { nonzero++; sum += Math.abs(ba[i]); }
|
||||
}
|
||||
result.back_arc_active_cells = nonzero;
|
||||
result.back_arc_mean_magnitude = nonzero > 0 ? +(sum / nonzero).toFixed(5) : 0;
|
||||
}
|
||||
|
||||
if (debugLayers && debugLayers.foldRidge) {
|
||||
const fr = debugLayers.foldRidge;
|
||||
let nonzero = 0, sum = 0;
|
||||
for (let i = 0; i < fr.length; i++) {
|
||||
if (fr[i] !== 0) { nonzero++; sum += Math.abs(fr[i]); }
|
||||
}
|
||||
result.fold_ridge_active_cells = nonzero;
|
||||
result.fold_ridge_mean_magnitude = nonzero > 0 ? +(sum / nonzero).toFixed(5) : 0;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Main entry point
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute all terrain quality metrics.
|
||||
*
|
||||
* @param {Object} ctx — context with:
|
||||
* mesh, r_xyz, r_elevation, r_plate, plateIsOcean (Array or Set),
|
||||
* r_stress, debugLayers, prePostElev (optional)
|
||||
* @returns {Object} flat scorecard of named metrics
|
||||
*/
|
||||
export function computeTerrainMetrics(ctx) {
|
||||
// Normalize plateIsOcean to an iterable of seed region IDs
|
||||
if (ctx.plateIsOcean instanceof Set) {
|
||||
ctx.plateIsOcean = Array.from(ctx.plateIsOcean);
|
||||
}
|
||||
|
||||
const t0 = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
|
||||
const silhouette = continentSilhouette(ctx);
|
||||
const drama = elevationDrama(ctx);
|
||||
const coast = coastComplexity(ctx);
|
||||
const oceanFloor = oceanFloorTexture(ctx);
|
||||
const flatOcean = flatOceanPlateLand(ctx);
|
||||
const hyps = bimodalHypsometry(ctx);
|
||||
const mtnBoundary = mountainBoundaryCorrelation(ctx);
|
||||
const orogenic = orogenicCorrelation(ctx);
|
||||
const erosion = erosionSlopeCoherence(ctx);
|
||||
const islands = islandMetrics(ctx, silhouette);
|
||||
const lowland = coastalLowlandIndex(ctx);
|
||||
const shelf = shelfWidth(ctx);
|
||||
const gradient = interiorGradient(ctx);
|
||||
const hotspot = hotspotDistinctiveness(ctx);
|
||||
const backArcFold = backArcFoldPresence(ctx);
|
||||
|
||||
const elapsed = (typeof performance !== 'undefined' ? performance.now() : Date.now()) - t0;
|
||||
|
||||
// Flatten into single scorecard, dropping internal fields
|
||||
const scorecard = {};
|
||||
for (const partial of [silhouette, drama, coast, oceanFloor, flatOcean, hyps,
|
||||
mtnBoundary, orogenic, erosion, islands, lowland,
|
||||
shelf, gradient, hotspot, backArcFold]) {
|
||||
for (const [k, v] of Object.entries(partial)) {
|
||||
if (!k.startsWith('_')) scorecard[k] = v;
|
||||
}
|
||||
}
|
||||
scorecard._metrics_ms = +elapsed.toFixed(1);
|
||||
|
||||
return scorecard;
|
||||
}
|
||||
@@ -0,0 +1,840 @@
|
||||
// Terrain post-processing: domain warping, bilateral smoothing, and
|
||||
// flow-based erosion. Runs after elevation assignment to deform terrain
|
||||
// for organic shapes, soften harsh boundaries, and carve natural
|
||||
// drainage patterns.
|
||||
|
||||
import { SimplexNoise } from './simplex-noise.js';
|
||||
import {
|
||||
FLOOD_NOISE_AMP, FLOOD_CARVE_RADIUS_FRAC,
|
||||
WARP_FREQ, WARP_OCTAVES, WARP_MAX_AMP_MULT,
|
||||
WARP_BIAS_BASE, WARP_BIAS_STRENGTH_SCALE, WARP_HOTSPOT_DAMPEN,
|
||||
SMOOTH_EDGE_SENSITIVITY,
|
||||
GLACIAL_LAT_DIVISOR, GLACIAL_ELEV_LOW, GLACIAL_ELEV_HIGH,
|
||||
GLACIAL_ELEV_FACTOR_SCALE, GLACIAL_ELEV_FACTOR_LAT_BASE, GLACIAL_ELEV_FACTOR_LAT_SCALE,
|
||||
GLACIAL_CARVE_RATE, GLACIAL_CONVERGENCE_BONUS, GLACIAL_DEPOSIT_AMOUNT,
|
||||
GLACIAL_FJORD_CARVE, GLACIAL_FLOW_THRESHOLD, GLACIAL_FJORD_THRESHOLD,
|
||||
GLACIAL_WIDENING_FRAC, GLACIAL_TERMINUS_RATIO, GLACIAL_FJORD_ICE_MIN,
|
||||
GLACIAL_POST_SMOOTH, GLACIAL_MID_FLOOD_FRAC, GLACIAL_MID_FLOOD_CARVE,
|
||||
GLACIAL_INITIAL_CARVE,
|
||||
HYDRAULIC_DEPOSIT_FRAC, HYDRAULIC_SLOPE_SENSITIVITY,
|
||||
THERMAL_TRANSFER_FRAC,
|
||||
RIDGE_SHARPEN_CAP, VALLEY_DEEPEN_FACTOR, VALLEY_FLOOR_FRAC, VALLEY_FLOOR_MIN,
|
||||
} from './terrain-config.js';
|
||||
|
||||
/**
|
||||
* Inline binary min-heap keyed on an external Float32Array of priorities.
|
||||
* Each cell is pushed/popped exactly once — no decrease-key needed.
|
||||
*/
|
||||
class MinHeap {
|
||||
constructor(keyArray) {
|
||||
this._key = keyArray;
|
||||
this._data = [];
|
||||
}
|
||||
get size() { return this._data.length; }
|
||||
push(cell) {
|
||||
this._data.push(cell);
|
||||
let i = this._data.length - 1;
|
||||
while (i > 0) {
|
||||
const parent = (i - 1) >> 1;
|
||||
if (this._key[this._data[i]] >= this._key[this._data[parent]]) break;
|
||||
const tmp = this._data[i]; this._data[i] = this._data[parent]; this._data[parent] = tmp;
|
||||
i = parent;
|
||||
}
|
||||
}
|
||||
pop() {
|
||||
const top = this._data[0];
|
||||
const last = this._data.pop();
|
||||
if (this._data.length > 0) {
|
||||
this._data[0] = last;
|
||||
let i = 0;
|
||||
const n = this._data.length;
|
||||
while (true) {
|
||||
let smallest = i;
|
||||
const l = 2 * i + 1, r = 2 * i + 2;
|
||||
if (l < n && this._key[this._data[l]] < this._key[this._data[smallest]]) smallest = l;
|
||||
if (r < n && this._key[this._data[r]] < this._key[this._data[smallest]]) smallest = r;
|
||||
if (smallest === i) break;
|
||||
const tmp = this._data[i]; this._data[i] = this._data[smallest]; this._data[smallest] = tmp;
|
||||
i = smallest;
|
||||
}
|
||||
}
|
||||
return top;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Priority-flood pit resolution with canyon carving.
|
||||
* Ensures every land cell has a monotonically descending drainage path to
|
||||
* the ocean, favoring carving through spill points over filling pit floors.
|
||||
*
|
||||
* Pass 1: Standard Barnes et al. priority-flood fill from ocean-adjacent
|
||||
* land cells inward → surface[], drainTo[]
|
||||
* Pass 2: Redistribute fill deficit as carving along spill paths
|
||||
* Pass 3: Enforce monotonic drainage with epsilon gradient
|
||||
*/
|
||||
function priorityFloodCarve(mesh, r_elevation, r_isOcean, carveStrength) {
|
||||
const N = mesh.numRegions;
|
||||
const { adjOffset, adjList } = mesh;
|
||||
const EPS = 1e-7;
|
||||
|
||||
// --- Identify the main ocean body via BFS ---
|
||||
// Find connected ocean components and mark only the largest as "open ocean"
|
||||
const oceanLabel = new Int32Array(N).fill(-1);
|
||||
const componentSizes = [];
|
||||
for (let r = 0; r < N; r++) {
|
||||
if (!r_isOcean[r] || oceanLabel[r] >= 0) continue;
|
||||
const label = componentSizes.length;
|
||||
let size = 0;
|
||||
const queue = [r];
|
||||
oceanLabel[r] = label;
|
||||
while (queue.length > 0) {
|
||||
const cur = queue.pop();
|
||||
size++;
|
||||
for (let i = adjOffset[cur], iEnd = adjOffset[cur + 1]; i < iEnd; i++) {
|
||||
const nb = adjList[i];
|
||||
if (r_isOcean[nb] && oceanLabel[nb] < 0) {
|
||||
oceanLabel[nb] = label;
|
||||
queue.push(nb);
|
||||
}
|
||||
}
|
||||
}
|
||||
componentSizes.push(size);
|
||||
}
|
||||
let mainOceanLabel = 0;
|
||||
for (let i = 1; i < componentSizes.length; i++) {
|
||||
if (componentSizes[i] > componentSizes[mainOceanLabel]) mainOceanLabel = i;
|
||||
}
|
||||
const isOpenOcean = new Uint8Array(N);
|
||||
for (let r = 0; r < N; r++) {
|
||||
if (r_isOcean[r] && oceanLabel[r] === mainOceanLabel) isOpenOcean[r] = 1;
|
||||
}
|
||||
|
||||
// --- Deterministic hash for noise perturbation (meander paths) ---
|
||||
// Small noise on priority keys makes the flood front irregular,
|
||||
// producing winding drainage paths instead of straight lines
|
||||
const NOISE_AMP = FLOOD_NOISE_AMP; // amplitude relative to typical elevation range
|
||||
function cellNoise(r) {
|
||||
let h = (r * 2654435761) >>> 0; // Knuth multiplicative hash
|
||||
h = ((h >>> 16) ^ h) * 0x45d9f3b >>> 0;
|
||||
h = ((h >>> 16) ^ h) >>> 0;
|
||||
return (h / 0xffffffff) * NOISE_AMP;
|
||||
}
|
||||
|
||||
const surface = new Float32Array(r_elevation);
|
||||
const drainTo = new Int32Array(N).fill(-1);
|
||||
const visited = new Uint8Array(N);
|
||||
|
||||
// Priority key array — elevation + small noise for meandering
|
||||
const key = new Float32Array(N);
|
||||
for (let r = 0; r < N; r++) key[r] = r_elevation[r] + cellNoise(r);
|
||||
|
||||
const heap = new MinHeap(key);
|
||||
|
||||
// Seed: land cells adjacent to the main open ocean (not inland seas)
|
||||
for (let r = 0; r < N; r++) {
|
||||
if (r_isOcean[r]) { visited[r] = 1; continue; }
|
||||
for (let i = adjOffset[r], iEnd = adjOffset[r + 1]; i < iEnd; i++) {
|
||||
if (isOpenOcean[adjList[i]]) {
|
||||
visited[r] = 1;
|
||||
drainTo[r] = adjList[i]; // drains to open ocean neighbor
|
||||
heap.push(r);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 1: priority-flood fill (noise-perturbed for winding paths)
|
||||
while (heap.size > 0) {
|
||||
const r = heap.pop();
|
||||
const surfR = surface[r];
|
||||
for (let i = adjOffset[r], iEnd = adjOffset[r + 1]; i < iEnd; i++) {
|
||||
const nb = adjList[i];
|
||||
if (visited[nb]) continue;
|
||||
visited[nb] = 1;
|
||||
drainTo[nb] = r;
|
||||
if (r_elevation[nb] < surfR + EPS) {
|
||||
// Pit detected — fill to current surface + epsilon
|
||||
surface[nb] = surfR + EPS;
|
||||
key[nb] = surface[nb] + cellNoise(nb);
|
||||
}
|
||||
// else: neighbor drains naturally, surface[nb] already = r_elevation[nb]
|
||||
heap.push(nb);
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: carve-bias redistribution
|
||||
// For each filled cell, trace path back to ocean, find the peak (spill point),
|
||||
// and redistribute deficit as carving near the peak
|
||||
for (let r = 0; r < N; r++) {
|
||||
if (r_isOcean[r]) continue;
|
||||
const deficit = surface[r] - r_elevation[r];
|
||||
if (deficit <= EPS) continue;
|
||||
|
||||
// Trace drainTo path toward ocean, collect path and find peak
|
||||
const path = [];
|
||||
let peakIdx = -1;
|
||||
let peakElev = -Infinity;
|
||||
let cur = r;
|
||||
while (cur >= 0 && !r_isOcean[cur]) {
|
||||
path.push(cur);
|
||||
if (r_elevation[cur] > peakElev) {
|
||||
peakElev = r_elevation[cur];
|
||||
peakIdx = path.length - 1;
|
||||
}
|
||||
cur = drainTo[cur];
|
||||
}
|
||||
|
||||
if (peakIdx < 0 || path.length === 0) continue;
|
||||
|
||||
// Carve: lower cells near the peak using a triangle kernel
|
||||
const carveAmount = deficit * carveStrength;
|
||||
const radius = Math.max(3, Math.ceil(path.length * FLOOD_CARVE_RADIUS_FRAC));
|
||||
const startIdx = Math.max(0, peakIdx - radius);
|
||||
const endIdx = Math.min(path.length - 1, peakIdx + radius);
|
||||
|
||||
let kernelSum = 0;
|
||||
for (let k = startIdx; k <= endIdx; k++) {
|
||||
const dist = Math.abs(k - peakIdx);
|
||||
kernelSum += 1 - dist / (radius + 1);
|
||||
}
|
||||
if (kernelSum > 0) {
|
||||
for (let k = startIdx; k <= endIdx; k++) {
|
||||
const dist = Math.abs(k - peakIdx);
|
||||
const weight = (1 - dist / (radius + 1)) / kernelSum;
|
||||
r_elevation[path[k]] -= carveAmount * weight;
|
||||
if (r_elevation[path[k]] < 0) r_elevation[path[k]] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Fill: raise the pit floor by the remaining fraction
|
||||
const fillAmount = deficit * (1 - carveStrength);
|
||||
r_elevation[r] += fillAmount;
|
||||
}
|
||||
|
||||
// Pass 3: enforce monotonic drainage along drainTo paths
|
||||
// Process cells in order of ascending surface (re-sort by surface)
|
||||
const order = [];
|
||||
for (let r = 0; r < N; r++) {
|
||||
if (!r_isOcean[r]) order.push(r);
|
||||
}
|
||||
order.sort((a, b) => surface[a] - surface[b]);
|
||||
|
||||
for (let i = 0; i < order.length; i++) {
|
||||
const r = order[i];
|
||||
const target = drainTo[r];
|
||||
if (target < 0) continue;
|
||||
const targetElev = r_isOcean[target] ? 0 : r_elevation[target];
|
||||
if (r_elevation[r] <= targetElev) {
|
||||
r_elevation[r] = targetElev + EPS;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain warping — displaces each region's elevation lookup by FBM simplex
|
||||
* noise in the tangent plane, producing organic, squiggly coastlines and
|
||||
* mountain ridges. Scale-invariant: noise is evaluated in 3D coordinate
|
||||
* space and amplitude is in radians (physical distance on the sphere).
|
||||
*
|
||||
* For each region:
|
||||
* 1. Compute a tangent-plane frame (east/north) at its position on the unit sphere
|
||||
* 2. Use FBM simplex noise (4 octaves, frequency 6) to generate two
|
||||
* displacement values in the tangent plane
|
||||
* 3. Displace the region's 3D position along the tangent frame by the noise
|
||||
* offsets, then re-project onto the unit sphere
|
||||
* 4. Walk the mesh graph (greedy nearest-neighbor) from the original region
|
||||
* toward the displaced point to find the closest region
|
||||
* 5. Copy that source region's elevation to the output
|
||||
*/
|
||||
export function warpTerrain(mesh, r_elevation, r_xyz, seed, strength, r_hotspot) {
|
||||
if (strength <= 0) return;
|
||||
|
||||
const N = mesh.numRegions;
|
||||
const { adjOffset, adjList } = mesh;
|
||||
const noise = new SimplexNoise(seed + 9999);
|
||||
const freq = WARP_FREQ;
|
||||
const octaves = WARP_OCTAVES;
|
||||
const maxAmp = WARP_MAX_AMP_MULT * strength; // radians (~760 km at Earth scale when strength=1)
|
||||
|
||||
const out = new Float32Array(r_elevation);
|
||||
|
||||
for (let r = 0; r < N; r++) {
|
||||
const px = r_xyz[3 * r], py = r_xyz[3 * r + 1], pz = r_xyz[3 * r + 2];
|
||||
|
||||
// Tangent frame: east = normalize(cross(up, pos)), north = cross(pos, east)
|
||||
let ex = -pz, ey = 0, ez = px; // cross([0,1,0], pos) = [-pz, 0, px]
|
||||
const elen = Math.sqrt(ex * ex + ez * ez);
|
||||
if (elen > 1e-10) { ex /= elen; ez /= elen; }
|
||||
else { ex = 1; ez = 0; } // poles
|
||||
|
||||
const nx = py * ez;
|
||||
const ny = pz * ex - px * ez;
|
||||
const nz = -py * ex;
|
||||
const nlen = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1;
|
||||
const nnx = nx / nlen, nny = ny / nlen, nnz = nz / nlen;
|
||||
|
||||
// FBM noise → two displacement values
|
||||
const d1 = noise.fbm(px * freq, py * freq, pz * freq, octaves) * maxAmp;
|
||||
const d2 = noise.fbm(px * freq + 31.7, py * freq + 47.3, pz * freq + 19.1, octaves) * maxAmp;
|
||||
|
||||
// Displace position along tangent frame and re-project onto unit sphere
|
||||
let wx = px + ex * d1 + nnx * d2;
|
||||
let wy = py + ey * d1 + nny * d2;
|
||||
let wz = pz + ez * d1 + nnz * d2;
|
||||
const wlen = Math.sqrt(wx * wx + wy * wy + wz * wz) || 1;
|
||||
wx /= wlen; wy /= wlen; wz /= wlen;
|
||||
|
||||
// Greedy mesh walk from r toward the displaced point
|
||||
let cur = r;
|
||||
let bestDot = wx * px + wy * py + wz * pz;
|
||||
for (;;) {
|
||||
let moved = false;
|
||||
for (let i = adjOffset[cur], iEnd = adjOffset[cur + 1]; i < iEnd; i++) {
|
||||
const nb = adjList[i];
|
||||
const dot = wx * r_xyz[3 * nb] + wy * r_xyz[3 * nb + 1] + wz * r_xyz[3 * nb + 2];
|
||||
if (dot > bestDot) {
|
||||
bestDot = dot;
|
||||
cur = nb;
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
if (!moved) break;
|
||||
}
|
||||
|
||||
out[r] = r_elevation[cur];
|
||||
}
|
||||
|
||||
// Weighted max: pick whichever is larger, biased by strength
|
||||
// At strength≈0 → 75% original, at strength=1 → 75% warped
|
||||
// Dampen near hotspots so volcanic peaks keep their sculpted shape
|
||||
const warpBias = WARP_BIAS_BASE + WARP_BIAS_STRENGTH_SCALE * strength;
|
||||
for (let r = 0; r < N; r++) {
|
||||
const orig = r_elevation[r];
|
||||
const warped = out[r];
|
||||
let bias = warpBias;
|
||||
if (r_hotspot) {
|
||||
const hotFrac = Math.min(1, Math.abs(r_hotspot[r]) / (Math.abs(orig) || 1));
|
||||
bias *= 1 - WARP_HOTSPOT_DAMPEN * hotFrac;
|
||||
}
|
||||
if (warped > orig) {
|
||||
r_elevation[r] = orig + (warped - orig) * bias;
|
||||
} else {
|
||||
r_elevation[r] = warped + (orig - warped) * (1 - bias);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bilateral-weighted Laplacian smoothing.
|
||||
* Neighbors with similar elevation receive more weight, preserving ridges
|
||||
* and trenches while blending the banded artefacts from BFS distance fields.
|
||||
* Coastline cells (land adjacent to ocean) are locked to prevent drift.
|
||||
*/
|
||||
export function smoothElevation(mesh, r_elevation, r_isOcean, iterations, strength) {
|
||||
const N = mesh.numRegions;
|
||||
const tmp = new Float32Array(N);
|
||||
const { adjOffset, adjList } = mesh;
|
||||
|
||||
// Pre-compute coastline lock: land cells adjacent to at least one ocean cell
|
||||
const locked = new Uint8Array(N);
|
||||
for (let r = 0; r < N; r++) {
|
||||
if (r_isOcean[r]) continue;
|
||||
for (let i = adjOffset[r], iEnd = adjOffset[r + 1]; i < iEnd; i++) {
|
||||
if (r_isOcean[adjList[i]]) { locked[r] = 1; break; }
|
||||
}
|
||||
}
|
||||
|
||||
for (let iter = 0; iter < iterations; iter++) {
|
||||
for (let r = 0; r < N; r++) {
|
||||
if (locked[r]) { tmp[r] = r_elevation[r]; continue; }
|
||||
|
||||
const h = r_elevation[r];
|
||||
let wSum = 0, hSum = 0;
|
||||
for (let i = adjOffset[r], iEnd = adjOffset[r + 1]; i < iEnd; i++) {
|
||||
const nh = r_elevation[adjList[i]];
|
||||
const diff = Math.abs(nh - h);
|
||||
const w = 1 / (1 + diff * SMOOTH_EDGE_SENSITIVITY);
|
||||
wSum += w;
|
||||
hSum += nh * w;
|
||||
}
|
||||
if (wSum > 0) {
|
||||
const avg = hSum / wSum;
|
||||
tmp[r] = h + (avg - h) * strength;
|
||||
} else {
|
||||
tmp[r] = h;
|
||||
}
|
||||
}
|
||||
// Copy back
|
||||
for (let r = 0; r < N; r++) r_elevation[r] = tmp[r];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined iterative erosion — interleaves hydraulic (stream power) and
|
||||
* thermal (talus-angle) passes so they interact each iteration.
|
||||
*
|
||||
* Hydraulic: Braun-Willett implicit stream power. Rebuilds drainage graph
|
||||
* each iteration so carved valleys attract more flow.
|
||||
*
|
||||
* Thermal: Slope-driven material transport. Redistributes material from
|
||||
* steep slopes to lower neighbors using a simultaneous delta buffer.
|
||||
*
|
||||
* Each iteration runs one hydraulic step then one thermal step (if their
|
||||
* respective iteration counts haven't been exhausted).
|
||||
*/
|
||||
export function erodeComposite(mesh, r_elevation, r_xyz, r_isOcean,
|
||||
hIters, K, m, dt,
|
||||
tIters, talusSlope, kThermal,
|
||||
gIters, glacialStrength,
|
||||
neighborDist)
|
||||
{
|
||||
gIters = gIters || 0;
|
||||
glacialStrength = glacialStrength || 0;
|
||||
|
||||
const totalIters = Math.max(hIters, tIters, gIters);
|
||||
if (totalIters <= 0) return;
|
||||
|
||||
const N = mesh.numRegions;
|
||||
const { adjOffset, adjList } = mesh;
|
||||
|
||||
// Collect land cell indices
|
||||
const landCells = [];
|
||||
for (let r = 0; r < N; r++) {
|
||||
if (!r_isOcean[r]) landCells.push(r);
|
||||
}
|
||||
const landCount = landCells.length;
|
||||
if (landCount === 0) return;
|
||||
|
||||
// Shared buffers
|
||||
const drainTarget = new Int32Array(N);
|
||||
const cellDist = new Float32Array(N);
|
||||
const flow = new Float32Array(N);
|
||||
const delta = new Float32Array(N);
|
||||
|
||||
// Priority-flood pit resolution: ensure every land cell drains to ocean
|
||||
// before hydraulic erosion begins. Carves canyons through spill points.
|
||||
if (hIters > 0) {
|
||||
priorityFloodCarve(mesh, r_elevation, r_isOcean, GLACIAL_INITIAL_CARVE);
|
||||
}
|
||||
|
||||
// ---- Glacial precomputation (once — index is position-based) ----
|
||||
let glacIdx = null;
|
||||
let iceTarget = null;
|
||||
let iceFlow = null;
|
||||
let numIceUpstream = null;
|
||||
|
||||
if (gIters > 0 && glacialStrength > 0) {
|
||||
function smoothstep(x, edge0, edge1) {
|
||||
const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
glacIdx = new Float32Array(N);
|
||||
// At strength=1 glaciation starts at ~50° latitude; at 0.5 it starts at ~70°
|
||||
const thresholdLat = Math.PI / 2 - glacialStrength * Math.PI / GLACIAL_LAT_DIVISOR;
|
||||
|
||||
for (let r = 0; r < N; r++) {
|
||||
if (r_isOcean[r]) continue;
|
||||
const y = r_xyz[3 * r + 1];
|
||||
const polarDist = Math.abs(Math.asin(Math.max(-1, Math.min(1, y))));
|
||||
const latFactor = smoothstep(polarDist, thresholdLat, Math.PI / 2);
|
||||
const elevFactor = smoothstep(r_elevation[r], GLACIAL_ELEV_LOW, GLACIAL_ELEV_HIGH);
|
||||
const latScale = smoothstep(polarDist, Math.PI / 8, Math.PI / 3);
|
||||
glacIdx[r] = Math.max(latFactor, elevFactor * GLACIAL_ELEV_FACTOR_SCALE * (GLACIAL_ELEV_FACTOR_LAT_BASE + GLACIAL_ELEV_FACTOR_LAT_SCALE * latScale)) * glacialStrength;
|
||||
}
|
||||
|
||||
iceTarget = new Int32Array(N);
|
||||
iceFlow = new Float32Array(N);
|
||||
numIceUpstream = new Uint8Array(N);
|
||||
}
|
||||
|
||||
// Per-iteration glacial rates (scaled so total effect ≈ same regardless of iter count)
|
||||
const gScale = gIters > 0 ? 1.0 / gIters : 0;
|
||||
const gCarveRate = GLACIAL_CARVE_RATE * gScale;
|
||||
const gConvergenceBonus = GLACIAL_CONVERGENCE_BONUS * gScale;
|
||||
const gDepositAmount = GLACIAL_DEPOSIT_AMOUNT * gScale;
|
||||
const gFjordCarve = GLACIAL_FJORD_CARVE * gScale;
|
||||
const gFlowThreshold = GLACIAL_FLOW_THRESHOLD;
|
||||
const gFjordThreshold = GLACIAL_FJORD_THRESHOLD;
|
||||
|
||||
// Mid-loop drainage fix: at 75% of iterations, run a carve-biased
|
||||
// priority-flood to cut outlets through basins created by glaciation.
|
||||
const midFloodIter = Math.round(totalIters * GLACIAL_MID_FLOOD_FRAC);
|
||||
let midFloodDone = false;
|
||||
|
||||
// Pre-allocate thermal erosion buffers (max neighbor degree)
|
||||
let maxDeg = 0;
|
||||
for (let r = 0; r < N; r++) {
|
||||
const deg = adjOffset[r + 1] - adjOffset[r];
|
||||
if (deg > maxDeg) maxDeg = deg;
|
||||
}
|
||||
const excNb = new Int32Array(maxDeg);
|
||||
const excVal = new Float32Array(maxDeg);
|
||||
const excAdjIdx = new Int32Array(maxDeg);
|
||||
const excSlope = new Float32Array(maxDeg);
|
||||
|
||||
for (let iter = 0; iter < totalIters; iter++) {
|
||||
|
||||
if (!midFloodDone && iter >= midFloodIter) {
|
||||
midFloodDone = true;
|
||||
priorityFloodCarve(mesh, r_elevation, r_isOcean, GLACIAL_MID_FLOOD_CARVE);
|
||||
}
|
||||
|
||||
// Sort land cells by descending elevation — needed by glacial ice flow
|
||||
// and hydraulic flow accumulation. If glacial runs this iteration and
|
||||
// hydraulic follows, glacial modifies elevations so we re-sort before hydraulic.
|
||||
const glacialThisIter = iter < gIters && glacIdx;
|
||||
const hydraulicThisIter = iter < hIters;
|
||||
if (glacialThisIter || hydraulicThisIter) {
|
||||
landCells.sort((a, b) => r_elevation[b] - r_elevation[a]);
|
||||
}
|
||||
|
||||
// ---- Glacial step ----
|
||||
if (glacialThisIter) {
|
||||
|
||||
// Rebuild ice drainage from current elevations
|
||||
iceTarget.fill(-1);
|
||||
numIceUpstream.fill(0);
|
||||
|
||||
for (let i = 0; i < landCount; i++) {
|
||||
const r = landCells[i];
|
||||
if (glacIdx[r] <= 0) continue;
|
||||
const h = r_elevation[r];
|
||||
let bestNb = -1, bestDrop = 0;
|
||||
for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) {
|
||||
const nb = adjList[j];
|
||||
const drop = h - r_elevation[nb];
|
||||
if (drop > bestDrop) { bestDrop = drop; bestNb = nb; }
|
||||
}
|
||||
if (bestNb >= 0) iceTarget[r] = bestNb;
|
||||
}
|
||||
|
||||
// Accumulate ice flow downstream
|
||||
for (let r = 0; r < N; r++) iceFlow[r] = glacIdx[r];
|
||||
for (let i = 0; i < landCount; i++) {
|
||||
const r = landCells[i];
|
||||
const target = iceTarget[r];
|
||||
if (target >= 0 && iceFlow[r] > 0) {
|
||||
iceFlow[target] += iceFlow[r];
|
||||
numIceUpstream[target]++;
|
||||
}
|
||||
}
|
||||
|
||||
// Carving: deepening + widening + over-deepening
|
||||
for (let i = 0; i < landCount; i++) {
|
||||
const r = landCells[i];
|
||||
if (iceFlow[r] <= gFlowThreshold) continue;
|
||||
|
||||
const deepening = gCarveRate * Math.pow(iceFlow[r], 0.6) * glacialStrength;
|
||||
r_elevation[r] -= deepening;
|
||||
|
||||
// Valley widening for U-shape
|
||||
for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) {
|
||||
const nb = adjList[j];
|
||||
if (r_isOcean[nb]) continue;
|
||||
const d = neighborDist[j] || 1e-6;
|
||||
const slope = Math.abs(r_elevation[r] - r_elevation[nb]) / d;
|
||||
r_elevation[nb] -= deepening * GLACIAL_WIDENING_FRAC * Math.max(0, 1 - slope);
|
||||
}
|
||||
|
||||
// Over-deepening at convergence zones
|
||||
if (numIceUpstream[r] >= 2) {
|
||||
r_elevation[r] -= gConvergenceBonus * Math.pow(iceFlow[r], 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
// Moraine deposition at glacier termini
|
||||
for (let i = 0; i < landCount; i++) {
|
||||
const r = landCells[i];
|
||||
if (iceFlow[r] <= gFlowThreshold) continue;
|
||||
const target = iceTarget[r];
|
||||
if (target < 0 || r_isOcean[target]) continue;
|
||||
if (glacIdx[target] < glacIdx[r] * GLACIAL_TERMINUS_RATIO) {
|
||||
r_elevation[target] += gDepositAmount * Math.pow(iceFlow[r], 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
// Fjord enhancement on coastal glaciated cells
|
||||
for (let r = 0; r < N; r++) {
|
||||
if (r_isOcean[r]) continue;
|
||||
if (glacIdx[r] <= GLACIAL_FJORD_ICE_MIN || iceFlow[r] <= gFjordThreshold) continue;
|
||||
let isCoastal = false;
|
||||
for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) {
|
||||
if (r_isOcean[adjList[j]]) { isCoastal = true; break; }
|
||||
}
|
||||
if (isCoastal) {
|
||||
r_elevation[r] -= gFjordCarve * Math.pow(iceFlow[r], 0.5);
|
||||
if (r_elevation[r] < 0) r_elevation[r] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp: land stays land
|
||||
for (let r = 0; r < N; r++) {
|
||||
if (!r_isOcean[r] && r_elevation[r] < 0) r_elevation[r] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Hydraulic step ----
|
||||
if (hydraulicThisIter) {
|
||||
// Re-sort if glacial step modified elevations this iteration
|
||||
if (glacialThisIter) {
|
||||
landCells.sort((a, b) => r_elevation[b] - r_elevation[a]);
|
||||
}
|
||||
// Build drainage graph (steepest descent)
|
||||
drainTarget.fill(-1);
|
||||
|
||||
for (let i = 0; i < landCount; i++) {
|
||||
const r = landCells[i];
|
||||
const h = r_elevation[r];
|
||||
|
||||
let bestNb = -1, bestDrop = -Infinity, bestJ = -1;
|
||||
for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) {
|
||||
const nb = adjList[j];
|
||||
const drop = h - r_elevation[nb];
|
||||
if (drop > bestDrop) {
|
||||
bestDrop = drop;
|
||||
bestNb = nb;
|
||||
bestJ = j;
|
||||
}
|
||||
}
|
||||
|
||||
// Pit handling: drain to least-steep-ascent neighbor
|
||||
if (bestDrop <= 0) {
|
||||
let minAscent = Infinity;
|
||||
for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) {
|
||||
const nb = adjList[j];
|
||||
const ascent = r_elevation[nb] - h;
|
||||
if (ascent < minAscent) {
|
||||
minAscent = ascent;
|
||||
bestNb = nb;
|
||||
bestJ = j;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestNb >= 0) {
|
||||
drainTarget[r] = bestNb;
|
||||
cellDist[r] = neighborDist[bestJ] || 1e-6;
|
||||
}
|
||||
}
|
||||
|
||||
// Flow accumulation (already sorted descending at top of iteration)
|
||||
flow.fill(0);
|
||||
for (let i = 0; i < landCount; i++) flow[landCells[i]] = 1;
|
||||
|
||||
for (let i = 0; i < landCount; i++) {
|
||||
const r = landCells[i];
|
||||
const target = drainTarget[r];
|
||||
if (target >= 0) flow[target] += flow[r];
|
||||
}
|
||||
|
||||
// Implicit stream power solve (ascending elevation order) + sediment deposition
|
||||
for (let i = landCount - 1; i >= 0; i--) {
|
||||
const r = landCells[i];
|
||||
const target = drainTarget[r];
|
||||
if (target < 0 || cellDist[r] <= 0) continue;
|
||||
|
||||
const factor = K * Math.pow(flow[r], m) * dt / cellDist[r];
|
||||
const h_receiver = Math.max(r_elevation[target], 0);
|
||||
let h_new = (r_elevation[r] + factor * h_receiver) / (1 + factor);
|
||||
|
||||
if (h_new < h_receiver) h_new = h_receiver;
|
||||
if (h_new < 0) h_new = 0;
|
||||
|
||||
// Sediment deposition: deposit fraction of eroded material at receiver
|
||||
const eroded = r_elevation[r] - h_new;
|
||||
if (eroded > 0 && !r_isOcean[target]) {
|
||||
const drainOfTarget = drainTarget[target];
|
||||
let receiverSlope = 0;
|
||||
if (drainOfTarget >= 0 && cellDist[target] > 0) {
|
||||
receiverSlope = Math.abs(r_elevation[target] - r_elevation[drainOfTarget]) / cellDist[target];
|
||||
}
|
||||
const depositFrac = HYDRAULIC_DEPOSIT_FRAC / (1 + receiverSlope * HYDRAULIC_SLOPE_SENSITIVITY);
|
||||
const deposit = eroded * depositFrac;
|
||||
r_elevation[target] += deposit;
|
||||
if (r_elevation[target] > h_new) r_elevation[target] = h_new;
|
||||
}
|
||||
|
||||
r_elevation[r] = h_new;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Thermal step ----
|
||||
if (iter < tIters) {
|
||||
delta.fill(0);
|
||||
|
||||
for (let i = 0; i < landCount; i++) {
|
||||
const r = landCells[i];
|
||||
const h = r_elevation[r];
|
||||
|
||||
let totalExcess = 0;
|
||||
let excCount = 0;
|
||||
|
||||
for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) {
|
||||
const nb = adjList[j];
|
||||
if (r_isOcean[nb]) continue;
|
||||
const nh = r_elevation[nb];
|
||||
if (nh >= h) continue;
|
||||
|
||||
const d = neighborDist[j] || 1e-6;
|
||||
|
||||
const slope = (h - nh) / d;
|
||||
if (slope > talusSlope) {
|
||||
const excess = (slope - talusSlope) * d;
|
||||
excNb[excCount] = nb;
|
||||
excVal[excCount] = excess;
|
||||
excAdjIdx[excCount] = j;
|
||||
excCount++;
|
||||
totalExcess += excess;
|
||||
}
|
||||
}
|
||||
|
||||
if (totalExcess <= 0) continue;
|
||||
|
||||
// Slope-weighted distribution: steeper neighbors get more debris
|
||||
let totalSlopeWeighted = 0;
|
||||
for (let k = 0; k < excCount; k++) {
|
||||
const d = neighborDist[excAdjIdx[k]] || 1e-6;
|
||||
excSlope[k] = (h - r_elevation[excNb[k]]) / d;
|
||||
totalSlopeWeighted += excVal[k] * excSlope[k];
|
||||
}
|
||||
|
||||
const transfer = kThermal * totalExcess * THERMAL_TRANSFER_FRAC;
|
||||
if (totalSlopeWeighted > 0) {
|
||||
for (let k = 0; k < excCount; k++) {
|
||||
const share = (excVal[k] * excSlope[k] / totalSlopeWeighted) * transfer;
|
||||
delta[r] -= share;
|
||||
delta[excNb[k]] += share;
|
||||
}
|
||||
} else {
|
||||
for (let k = 0; k < excCount; k++) {
|
||||
const share = (excVal[k] / totalExcess) * transfer;
|
||||
delta[r] -= share;
|
||||
delta[excNb[k]] += share;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < landCount; i++) {
|
||||
r_elevation[landCells[i]] += delta[landCells[i]];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Post-loop: light Laplacian smooth on glaciated cells to blend carving edges
|
||||
if (glacIdx) {
|
||||
const tmp = new Float32Array(r_elevation);
|
||||
for (let r = 0; r < N; r++) {
|
||||
if (r_isOcean[r] || glacIdx[r] <= 0) continue;
|
||||
let sum = 0, count = 0;
|
||||
for (let j = adjOffset[r], jEnd = adjOffset[r + 1]; j < jEnd; j++) {
|
||||
if (!r_isOcean[adjList[j]]) { sum += r_elevation[adjList[j]]; count++; }
|
||||
}
|
||||
if (count > 0) {
|
||||
const avg = sum / count;
|
||||
tmp[r] = r_elevation[r] + (avg - r_elevation[r]) * GLACIAL_POST_SMOOTH;
|
||||
}
|
||||
}
|
||||
for (let r = 0; r < N; r++) {
|
||||
if (!r_isOcean[r] && glacIdx[r] > 0) r_elevation[r] = tmp[r];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ridge sharpening — pushes cells that sit above their neighborhood average
|
||||
* further upward, accentuating ridgelines without creating unrealistic spikes.
|
||||
*/
|
||||
export function sharpenRidges(mesh, r_elevation, r_isOcean, iterations, strength) {
|
||||
const N = mesh.numRegions;
|
||||
const { adjOffset, adjList } = mesh;
|
||||
|
||||
// Pre-build land cell list to skip ~40% ocean cells each iteration
|
||||
const landCells = [];
|
||||
for (let r = 0; r < N; r++) {
|
||||
if (!r_isOcean[r]) landCells.push(r);
|
||||
}
|
||||
const landCount = landCells.length;
|
||||
|
||||
const tmp = new Float32Array(N);
|
||||
const original = new Float32Array(r_elevation);
|
||||
|
||||
for (let iter = 0; iter < iterations; iter++) {
|
||||
for (let li = 0; li < landCount; li++) {
|
||||
const r = landCells[li];
|
||||
const h = r_elevation[r];
|
||||
let sum = 0;
|
||||
const count = adjOffset[r + 1] - adjOffset[r];
|
||||
for (let i = adjOffset[r], iEnd = adjOffset[r + 1]; i < iEnd; i++) {
|
||||
sum += r_elevation[adjList[i]];
|
||||
}
|
||||
if (count === 0) { tmp[r] = h; continue; }
|
||||
|
||||
const avg = sum / count;
|
||||
if (h > avg) {
|
||||
// Ridge sharpening: push peaks up
|
||||
let h_new = h + (h - avg) * strength;
|
||||
// Clamp: don't exceed 1.5x original elevation
|
||||
const cap = original[r] * RIDGE_SHARPEN_CAP;
|
||||
if (h_new > cap) h_new = cap;
|
||||
tmp[r] = h_new;
|
||||
} else if (h < avg) {
|
||||
// Valley deepening: push valleys down (weaker than ridge sharpening)
|
||||
const VALLEY_FACTOR = VALLEY_DEEPEN_FACTOR;
|
||||
let h_new = h - (avg - h) * strength * VALLEY_FACTOR;
|
||||
// Floor cap: don't go below 0.5x original (symmetric to 1.5x ceiling)
|
||||
const floor = original[r] * VALLEY_FLOOR_FRAC;
|
||||
if (original[r] > 0 && h_new < floor) h_new = floor;
|
||||
// Don't push land below sea level
|
||||
if (original[r] > 0 && h_new < VALLEY_FLOOR_MIN) h_new = VALLEY_FLOOR_MIN;
|
||||
tmp[r] = h_new;
|
||||
} else {
|
||||
tmp[r] = h;
|
||||
}
|
||||
}
|
||||
for (let li = 0; li < landCount; li++) r_elevation[landCells[li]] = tmp[landCells[li]];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Soil creep — simple Laplacian diffusion on land cells.
|
||||
* Unlike bilateral smoothing, this doesn't preserve ridges — it uniformly
|
||||
* rounds off hillslopes. Coastline cells are locked.
|
||||
*/
|
||||
export function applySoilCreep(mesh, r_elevation, r_isOcean, iterations, strength) {
|
||||
const N = mesh.numRegions;
|
||||
const { adjOffset, adjList } = mesh;
|
||||
|
||||
// Pre-build interior land cell list: skip ocean cells and coastline-locked cells
|
||||
const interiorLand = [];
|
||||
for (let r = 0; r < N; r++) {
|
||||
if (r_isOcean[r]) continue;
|
||||
let coastal = false;
|
||||
for (let i = adjOffset[r], iEnd = adjOffset[r + 1]; i < iEnd; i++) {
|
||||
if (r_isOcean[adjList[i]]) { coastal = true; break; }
|
||||
}
|
||||
if (!coastal) interiorLand.push(r);
|
||||
}
|
||||
const ilCount = interiorLand.length;
|
||||
|
||||
const tmp = new Float32Array(N);
|
||||
|
||||
for (let iter = 0; iter < iterations; iter++) {
|
||||
for (let li = 0; li < ilCount; li++) {
|
||||
const r = interiorLand[li];
|
||||
const h = r_elevation[r];
|
||||
let sum = 0, count = 0;
|
||||
for (let i = adjOffset[r], iEnd = adjOffset[r + 1]; i < iEnd; i++) {
|
||||
if (!r_isOcean[adjList[i]]) {
|
||||
sum += r_elevation[adjList[i]];
|
||||
count++;
|
||||
}
|
||||
}
|
||||
if (count === 0) { tmp[r] = h; continue; }
|
||||
|
||||
const avg = sum / count;
|
||||
tmp[r] = h + (avg - h) * strength;
|
||||
}
|
||||
for (let li = 0; li < ilCount; li++) r_elevation[interiorLand[li]] = tmp[interiorLand[li]];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
// Exporting a planet as Unreal landscape tiles.
|
||||
//
|
||||
// Unreal's landscape importer will not take either of Orogen's two existing exports. The preview is a
|
||||
// picture. The heightmap is one flat 8192 x 4096 PNG with no scale attached to it - the ramp is absolute,
|
||||
// so the shades mean metres, but nothing in the file says how wide the planet is, and without that there is
|
||||
// no answer to "how many metres is a pixel". What the importer wants is per-tile 16-bit greyscale PNGs at
|
||||
// exactly 255*N+1 vertices, one Landscape actor each, plus a separate 8-bit weightmap per paint layer, at
|
||||
// the sample spacing the game actually uses. This module writes that, straight out of the planet.
|
||||
//
|
||||
// Three things about it are the whole design.
|
||||
//
|
||||
// **The scale is an input, not a guess.** A sphere mesh has no metres on it; `planet_circumference_km` is
|
||||
// what turns the window's degrees into ground. It is asked for rather than derived because it cannot be
|
||||
// derived, and because it is the number that decides how much land a window holds. The report prints what
|
||||
// the choice bought, including the east-west stretch at the window's edges, so a window that does not fit
|
||||
// on the planet says so instead of quietly producing 30 km of ground on a 31.8 km world.
|
||||
//
|
||||
// **The window is sampled once, then cut.** The planet is rendered into one float raster over the window,
|
||||
// and every tile is resampled out of that raster by its *global* vertex position. This is what closes the
|
||||
// seams: a vertex column shared by two neighbours is computed from the same source coordinates twice and
|
||||
// comes out bit-identical, so nothing has to blend or stitch. Rendering each tile under its own camera
|
||||
// would have been one step shorter and would have put a rasteriser's floating-point luck on every seam,
|
||||
// where one 16-bit step is 11 cm of crack.
|
||||
//
|
||||
// **The intermediate is float and it is the window.** The old path read a whole-planet 16-bit PNG and spent
|
||||
// its resolution on the whole planet; this spends all of it on the window, and carries kilometres as
|
||||
// float32 rather than quantised to a -5000..6000 m ramp. Both matter less than they sound, because the
|
||||
// sphere mesh only resolves a couple of hundred metres and no amount of sampling invents what is not there
|
||||
// - the detail below that is still the Go generator's job. What they do buy is that nothing downstream has
|
||||
// to know a magic number.
|
||||
//
|
||||
// The resampler is the Catmull-Rom from generate_region_tiles.py, clamped to its two central taps for the
|
||||
// same reason: a plain cubic overshoots at a step, the steps here are coastlines, and unclamped every shore
|
||||
// gets a raised lip on the land side and a trench on the sea side.
|
||||
|
||||
import { renderHeightWindowKm } from './unreal-render.js';
|
||||
import { encodeGray16, encodeGray8 } from './png-write.js';
|
||||
|
||||
// One vertex of the neighbours on every side, sampled before the paint layers are derived and thrown away
|
||||
// after. The layers read slope, a one-sided difference at an array edge is not what the neighbouring tile
|
||||
// computes for that same vertex, and without this every tile boundary is a one-vertex line of different
|
||||
// paint. One vertex is all a central difference needs.
|
||||
const LAYER_MARGIN = 1;
|
||||
|
||||
// Pixels of the intermediate raster rendered beyond the window on each side, so the resampler's outer taps
|
||||
// and the layer margin read real ground instead of a clamped edge. Catmull-Rom reaches two pixels.
|
||||
const RASTER_PAD = 4;
|
||||
|
||||
export const LAYER_NAMES = ['Base_Layer', 'Layer_02', 'Layer_03']; // rock, meadow, high rock
|
||||
|
||||
// The defaults are a window that actually fits on this project's planet, which is a smaller one than it
|
||||
// looks. Planet.json is 100 km round, so the whole globe is 3183 km2 of surface; the 936 km2 square the
|
||||
// numpy region pipeline cuts is 29% of it, and that is why that window reads as 110 degrees on a side and
|
||||
// stretches by three quarters at its edge. Four tiles by two is 20.4 x 10.2 km, 208 km2, and about 5%
|
||||
// stretch at the edge - a window a sphere this size can actually hold flat.
|
||||
export const DEFAULTS = {
|
||||
level: '/Game/Maps/L_Region',
|
||||
planet_circumference_km: 100,
|
||||
centre: { lon_deg: 0, lat_deg: 0 },
|
||||
tiles: { columns: 4, rows: 2, vertices: 2551 },
|
||||
quad_cm: 200,
|
||||
sea_level_m: 0,
|
||||
spawn_pad_m: 150,
|
||||
streaming_grid_components: 5,
|
||||
elevation_m: { min: -1024, max: 6144 },
|
||||
sea_scale: 0.17,
|
||||
source_metres_per_pixel: 8,
|
||||
layers: {
|
||||
rock_slope_start: 0.55,
|
||||
rock_slope_full: 1.05,
|
||||
high_altitude_start_m: 1400,
|
||||
high_altitude_full_m: 2000,
|
||||
breakup_m: 18,
|
||||
breakup_cells: 24,
|
||||
breakup_seed: 7,
|
||||
},
|
||||
};
|
||||
|
||||
// ── Geometry ────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Everything that follows from the options, with nothing rendered yet. Cheap, so the UI can call it on
|
||||
* every keystroke to show what a setting buys - this is the export's equivalent of `--scout`.
|
||||
*/
|
||||
export function planRegion(opts) {
|
||||
const o = mergeDefaults(opts);
|
||||
const vertices = o.tiles.vertices;
|
||||
if ((vertices - 1) % 255 !== 0) {
|
||||
throw new Error(`tiles.vertices must be 255 * N + 1 (2551, 2041, 1021 ...), not ${vertices}`);
|
||||
}
|
||||
const quadsPerTile = vertices - 1;
|
||||
const quadM = o.quad_cm / 100;
|
||||
const quadsX = o.tiles.columns * quadsPerTile;
|
||||
const quadsY = o.tiles.rows * quadsPerTile;
|
||||
const widthM = quadsX * quadM;
|
||||
const heightM = quadsY * quadM;
|
||||
|
||||
const radiusM = o.planet_circumference_km * 1000 / (2 * Math.PI);
|
||||
const centreLat = o.centre.lat_deg * Math.PI / 180;
|
||||
const centreLon = o.centre.lon_deg * Math.PI / 180;
|
||||
const latSpan = heightM / radiusM;
|
||||
// Cosine-corrected at the centre latitude, so ground metres are right there rather than only at the
|
||||
// equator. A flat reading of an equirectangular map stretches east-west by 1/cos(latitude); this puts
|
||||
// the error at zero in the middle of the window and splits it between the north and south edges.
|
||||
const lonSpan = widthM / (radiusM * Math.cos(centreLat));
|
||||
|
||||
const latMin = centreLat - latSpan / 2;
|
||||
const latMax = centreLat + latSpan / 2;
|
||||
|
||||
const innerW = Math.max(2, Math.round(widthM / o.source_metres_per_pixel) + 1);
|
||||
const innerH = Math.max(2, Math.round(heightM / o.source_metres_per_pixel) + 1);
|
||||
|
||||
// What the flat reading costs at the window's edges: 1 at the centre latitude by construction.
|
||||
const stretchAt = lat => Math.cos(centreLat) / Math.cos(Math.max(-1.55, Math.min(1.55, lat)));
|
||||
|
||||
const warnings = [];
|
||||
if (latSpan >= Math.PI) {
|
||||
warnings.push(`the window is ${(latSpan * 180 / Math.PI).toFixed(0)} degrees of latitude tall, which `
|
||||
+ `is more than the planet has. Raise planet_circumference_km or use fewer tiles.`);
|
||||
}
|
||||
if (lonSpan >= 2 * Math.PI) {
|
||||
warnings.push(`the window wraps the planet more than once at this latitude. Raise `
|
||||
+ `planet_circumference_km or use fewer tiles.`);
|
||||
}
|
||||
if (Math.abs(latMax) > 1.4 || Math.abs(latMin) > 1.4) {
|
||||
warnings.push('the window reaches past 80 degrees of latitude, where an equirectangular reading '
|
||||
+ 'stretches without bound. Move the centre towards the equator.');
|
||||
}
|
||||
const worstStretch = Math.max(stretchAt(latMin), stretchAt(latMax));
|
||||
if (worstStretch > 1.1 && warnings.length === 0) {
|
||||
warnings.push(`the ground is stretched east-west by up to ${((worstStretch - 1) * 100).toFixed(1)}% `
|
||||
+ 'at the window\'s edge. A window this tall on a planet this small cannot avoid it; a bigger '
|
||||
+ 'planet_circumference_km or fewer rows would.');
|
||||
}
|
||||
|
||||
return {
|
||||
options: o,
|
||||
quadsPerTile, quadM, quadsX, quadsY, widthM, heightM,
|
||||
areaKm2: widthM * heightM / 1e6,
|
||||
tileSideM: quadsPerTile * quadM,
|
||||
tileCount: o.tiles.columns * o.tiles.rows,
|
||||
componentsPerTile: (quadsPerTile / 255) ** 2,
|
||||
radiusM, centreLat, centreLon, latSpan, lonSpan, latMin, latMax,
|
||||
lonMin: centreLon - lonSpan / 2,
|
||||
lonMax: centreLon + lonSpan / 2,
|
||||
innerW, innerH,
|
||||
rasterW: innerW + 2 * RASTER_PAD,
|
||||
rasterH: innerH + 2 * RASTER_PAD,
|
||||
metresPerPixel: widthM / (innerW - 1),
|
||||
stretchNorth: stretchAt(latMax),
|
||||
stretchSouth: stretchAt(latMin),
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeDefaults(opts) {
|
||||
const o = { ...DEFAULTS, ...(opts || {}) };
|
||||
o.centre = { ...DEFAULTS.centre, ...(opts && opts.centre) };
|
||||
o.tiles = { ...DEFAULTS.tiles, ...(opts && opts.tiles) };
|
||||
o.elevation_m = { ...DEFAULTS.elevation_m, ...(opts && opts.elevation_m) };
|
||||
o.layers = { ...DEFAULTS.layers, ...(opts && opts.layers) };
|
||||
return o;
|
||||
}
|
||||
|
||||
// ── Resampling ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Catmull-Rom weights for taps at -1, 0, +1, +2. */
|
||||
function cubicWeights(t) {
|
||||
const t2 = t * t, t3 = t2 * t;
|
||||
return [
|
||||
-0.5 * t3 + t2 - 0.5 * t,
|
||||
1.5 * t3 - 2.5 * t2 + 1.0,
|
||||
-1.5 * t3 + 2.0 * t2 + 0.5 * t,
|
||||
0.5 * t3 - 0.5 * t2,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* One separable pass of clamped Catmull-Rom along x: `src` is srcW wide and `rows` tall, `coords` are
|
||||
* float source columns. Held between the two central taps, which is what stops it ringing at a coastline.
|
||||
*/
|
||||
function resampleX(src, srcW, rows, coords) {
|
||||
const outW = coords.length;
|
||||
const out = new Float32Array(rows * outW);
|
||||
const clampIdx = i => (i < 0 ? 0 : i >= srcW ? srcW - 1 : i);
|
||||
for (let o = 0; o < outW; o++) {
|
||||
const c = coords[o];
|
||||
const i = Math.floor(c);
|
||||
const w = cubicWeights(c - i);
|
||||
const i0 = clampIdx(i - 1), i1 = clampIdx(i), i2 = clampIdx(i + 1), i3 = clampIdx(i + 2);
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const base = r * srcW;
|
||||
const a = src[base + i0], b = src[base + i1], c2 = src[base + i2], d = src[base + i3];
|
||||
let v = a * w[0] + b * w[1] + c2 * w[2] + d * w[3];
|
||||
const lo = b < c2 ? b : c2, hi = b < c2 ? c2 : b;
|
||||
out[r * outW + o] = v < lo ? lo : v > hi ? hi : v;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The same along y: `src` is width wide and srcH tall, `coords` are float source rows. */
|
||||
function resampleY(src, width, srcH, coords) {
|
||||
const outH = coords.length;
|
||||
const out = new Float32Array(outH * width);
|
||||
const clampIdx = j => (j < 0 ? 0 : j >= srcH ? srcH - 1 : j);
|
||||
for (let o = 0; o < outH; o++) {
|
||||
const c = coords[o];
|
||||
const j = Math.floor(c);
|
||||
const w = cubicWeights(c - j);
|
||||
const r0 = clampIdx(j - 1) * width, r1 = clampIdx(j) * width;
|
||||
const r2 = clampIdx(j + 1) * width, r3 = clampIdx(j + 2) * width;
|
||||
const dst = o * width;
|
||||
for (let x = 0; x < width; x++) {
|
||||
const a = src[r0 + x], b = src[r1 + x], c2 = src[r2 + x], d = src[r3 + x];
|
||||
let v = a * w[0] + b * w[1] + c2 * w[2] + d * w[3];
|
||||
const lo = b < c2 ? b : c2, hi = b < c2 ? c2 : b;
|
||||
out[dst + x] = v < lo ? lo : v > hi ? hi : v;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Break-up noise ──────────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Value-noise fBm on a periodic lattice, sampled at *global* window coordinates so a tile boundary is not
|
||||
// a discontinuity in the paint. The lattice values come from a hash of (seed, octave, cell) rather than
|
||||
// from a stream of random numbers, which is what makes a single tile computable without generating the
|
||||
// ones before it. This is the same shape as heightmap_noise.fbm_at but not the same numbers: numpy's PCG64
|
||||
// stream cannot be reproduced here, and it does not need to be - the two pipelines are alternatives, never
|
||||
// mixed, and this noise only decides where a paint boundary wobbles.
|
||||
|
||||
function hash01(seed, octave, cells, i, j) {
|
||||
let h = (seed ^ Math.imul(octave + 1, 0x9E3779B1)) >>> 0;
|
||||
h = Math.imul(h ^ Math.imul(i, 0x27D4EB2D), 0x165667B1);
|
||||
h = Math.imul(h ^ Math.imul(j, 0x85EBCA77), 0xC2B2AE3D);
|
||||
h = Math.imul(h ^ cells, 0x27D4EB2F);
|
||||
h ^= h >>> 15; h = Math.imul(h, 0x2545F491); h ^= h >>> 13;
|
||||
return (h >>> 0) / 4294967296;
|
||||
}
|
||||
|
||||
const smoothstep = t => t * t * (3 - 2 * t);
|
||||
|
||||
function fbmAt(u, v, seed, baseCells, octaves = 4, gain = 0.5) {
|
||||
let total = 0, amplitude = 1, cells = baseCells, norm = 0;
|
||||
for (let o = 0; o < octaves; o++) {
|
||||
const su = u * cells, sv = v * cells;
|
||||
const i0 = Math.floor(su), j0 = Math.floor(sv);
|
||||
const tu = smoothstep(su - i0), tv = smoothstep(sv - j0);
|
||||
const ia = ((i0 % cells) + cells) % cells, ja = ((j0 % cells) + cells) % cells;
|
||||
const ib = (ia + 1) % cells, jb = (ja + 1) % cells;
|
||||
const top = hash01(seed, o, cells, ia, ja) * (1 - tu) + hash01(seed, o, cells, ib, ja) * tu;
|
||||
const bottom = hash01(seed, o, cells, ia, jb) * (1 - tu) + hash01(seed, o, cells, ib, jb) * tu;
|
||||
total += (top * (1 - tv) + bottom * tv) * amplitude;
|
||||
norm += amplitude;
|
||||
amplitude *= gain;
|
||||
cells *= 2;
|
||||
}
|
||||
return total / norm;
|
||||
}
|
||||
|
||||
// ── One tile ────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Global vertex indices along one axis for a tile, with `margin` extra on each side. */
|
||||
function tileVertices(plan, tile, margin) {
|
||||
const n = plan.options.tiles.vertices + 2 * margin;
|
||||
const out = new Float64Array(n);
|
||||
for (let k = 0; k < n; k++) out[k] = tile * plan.quadsPerTile + (k - margin);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Global vertex indices to raster pixel coordinates. RASTER_PAD is where the window's first vertex sits. */
|
||||
function toRasterCoords(plan, vertices, axis) {
|
||||
const inner = axis === 0 ? plan.innerW : plan.innerH;
|
||||
const quads = axis === 0 ? plan.quadsX : plan.quadsY;
|
||||
const out = new Float64Array(vertices.length);
|
||||
for (let k = 0; k < vertices.length; k++) {
|
||||
out[k] = RASTER_PAD + vertices[k] * (inner - 1) / quads;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** One tile's height in metres, sampled out of the window raster by global position. */
|
||||
function tileMetres(plan, raster, tx, ty, margin) {
|
||||
const vx = tileVertices(plan, tx, margin);
|
||||
const vy = tileVertices(plan, ty, margin);
|
||||
const sx = toRasterCoords(plan, vx, 0);
|
||||
const sy = toRasterCoords(plan, vy, 1);
|
||||
|
||||
// Only the raster rows this tile reaches, so a tile costs a band rather than the whole window.
|
||||
const row0 = Math.max(0, Math.floor(sy[0]) - 1);
|
||||
const row1 = Math.min(plan.rasterH, Math.floor(sy[sy.length - 1]) + 3);
|
||||
const bandRows = row1 - row0;
|
||||
const band = raster.subarray(row0 * plan.rasterW, row1 * plan.rasterW);
|
||||
|
||||
const afterX = resampleX(band, plan.rasterW, bandRows, sx);
|
||||
const shifted = new Float64Array(sy.length);
|
||||
for (let k = 0; k < sy.length; k++) shifted[k] = sy[k] - row0;
|
||||
const km = resampleY(afterX, sx.length, bandRows, shifted);
|
||||
|
||||
const out = new Float32Array(km.length);
|
||||
const seaScale = plan.options.sea_scale;
|
||||
for (let k = 0; k < km.length; k++) {
|
||||
let m = km[k] * 1000;
|
||||
if (m < 0) m *= seaScale;
|
||||
out[k] = m;
|
||||
}
|
||||
return { metres: out, vx, vy, width: sx.length, height: sy.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* The flat disc at the centre of the *window* for the player starts, blended over a second radius. It is
|
||||
* computed from global position, so where it crosses a tile boundary the two tiles agree on it.
|
||||
*/
|
||||
function applySpawnPad(plan, tile, padMetres) {
|
||||
const radius = plan.options.spawn_pad_m;
|
||||
if (radius <= 0) return;
|
||||
const { metres, vx, vy, width, height } = tile;
|
||||
const quadM = plan.quadM;
|
||||
for (let j = 0; j < height; j++) {
|
||||
const dy = (vy[j] - plan.quadsY / 2) * quadM;
|
||||
for (let i = 0; i < width; i++) {
|
||||
const dx = (vx[i] - plan.quadsX / 2) * quadM;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
const t = Math.max(0, Math.min(1, 1 - (dist - radius) / radius));
|
||||
if (t <= 0) continue;
|
||||
const w = smoothstep(t);
|
||||
const at = j * width + i;
|
||||
metres[at] = metres[at] * (1 - w) + padMetres * w;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pack's three paint layers from height and slope: meadow everywhere, rock by slope, high rock by
|
||||
* altitude, with an fBm break-up so neither boundary is a contour line. There is no erosion on this path,
|
||||
* so unlike L_World's version there is no wear, curvature or deposit term. `tile` carries LAYER_MARGIN
|
||||
* vertices of its neighbours on every side; the layers are computed over the lot and the margin cropped at
|
||||
* the end, so the slope at a tile's edge is the central difference its neighbour computes there too.
|
||||
*/
|
||||
function deriveLayers(plan, tile) {
|
||||
const rules = plan.options.layers;
|
||||
const { metres, vx, vy, width, height } = tile;
|
||||
const quadM = plan.quadM;
|
||||
const margin = LAYER_MARGIN;
|
||||
const outW = width - 2 * margin, outH = height - 2 * margin;
|
||||
|
||||
// Both axes divided by the *longer* one, so the noise stays square on the ground and, because neither
|
||||
// coordinate then exceeds 1, it never repeats across the window.
|
||||
const span = Math.max(plan.quadsX, plan.quadsY);
|
||||
const breakupM = rules.breakup_m;
|
||||
const slopeBreakupScale = breakupM ? 0.12 / breakupM : 0;
|
||||
|
||||
const layers = {};
|
||||
for (const name of LAYER_NAMES) layers[name] = new Uint8Array(outW * outH);
|
||||
|
||||
for (let j = margin; j < height - margin; j++) {
|
||||
for (let i = margin; i < width - margin; i++) {
|
||||
const at = j * width + i;
|
||||
// Central differences, which is why the margin is here.
|
||||
const gx = (metres[at + 1] - metres[at - 1]) / (2 * quadM);
|
||||
const gy = (metres[at + width] - metres[at - width]) / (2 * quadM);
|
||||
const slope = Math.hypot(gx, gy);
|
||||
|
||||
const noise = fbmAt(vx[i] / span, vy[j] / span, rules.breakup_seed | 0, rules.breakup_cells | 0);
|
||||
const breakup = (noise - 0.5) * 2 * breakupM;
|
||||
|
||||
let rock = smoothstep(Math.max(0, Math.min(1,
|
||||
(slope + breakup * slopeBreakupScale - rules.rock_slope_start)
|
||||
/ (rules.rock_slope_full - rules.rock_slope_start))));
|
||||
let high = smoothstep(Math.max(0, Math.min(1,
|
||||
(metres[at] + breakup - rules.high_altitude_start_m)
|
||||
/ (rules.high_altitude_full_m - rules.high_altitude_start_m))));
|
||||
high = high * (1 - rock * 0.5);
|
||||
const meadow = Math.max(0, Math.min(1, 1 - rock - high));
|
||||
|
||||
const total = Math.max(meadow + rock + high, 1e-6);
|
||||
const out = (j - margin) * outW + (i - margin);
|
||||
layers.Base_Layer[out] = Math.round(rock / total * 255);
|
||||
layers.Layer_02[out] = Math.round(meadow / total * 255);
|
||||
layers.Layer_03[out] = Math.round(high / total * 255);
|
||||
}
|
||||
}
|
||||
return { layers, width: outW, height: outH };
|
||||
}
|
||||
|
||||
/** Metres to the 16-bit code the manifest's elevation_m range defines. */
|
||||
function encodeHeights(plan, tile) {
|
||||
const { metres, width, height } = tile;
|
||||
const margin = LAYER_MARGIN;
|
||||
const outW = width - 2 * margin, outH = height - 2 * margin;
|
||||
const lo = plan.options.elevation_m.min, hi = plan.options.elevation_m.max;
|
||||
const span = hi - lo;
|
||||
const out = new Uint16Array(outW * outH);
|
||||
let clipped = 0;
|
||||
// Measured over the cropped tile, not over `metres`, which still carries the margin ring. On an outside
|
||||
// tile that ring is sampled beyond the window, so a range taken across it can quote ground that is not
|
||||
// in the world - and this number is what the manifest prints as "the ground came out X..Y m".
|
||||
let minM = Infinity, maxM = -Infinity;
|
||||
for (let j = 0; j < outH; j++) {
|
||||
for (let i = 0; i < outW; i++) {
|
||||
const m = metres[(j + margin) * width + (i + margin)];
|
||||
if (m < minM) minM = m;
|
||||
if (m > maxM) maxM = m;
|
||||
if (m < lo || m > hi) clipped++;
|
||||
const bounded = m < lo ? lo : m > hi ? hi : m;
|
||||
const v = Math.round((bounded - lo) / span * 65535);
|
||||
out[j * outW + i] = v < 0 ? 0 : v > 65535 ? 65535 : v;
|
||||
}
|
||||
}
|
||||
return { heights: out, width: outW, height: outH, clipped: clipped / (outW * outH), minM, maxM };
|
||||
}
|
||||
|
||||
// ── Writing ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function writeBlob(dirHandle, name, blob) {
|
||||
const handle = await dirHandle.getFileHandle(name, { create: true });
|
||||
const writable = await handle.createWritable();
|
||||
await writable.write(blob);
|
||||
await writable.close();
|
||||
}
|
||||
|
||||
async function fileExists(dirHandle, name) {
|
||||
try {
|
||||
await dirHandle.getFileHandle(name);
|
||||
return true;
|
||||
} catch {
|
||||
return false; // NotFoundError, and anything else here means we cannot claim it is there
|
||||
}
|
||||
}
|
||||
|
||||
function tileName(plan, tx, ty) {
|
||||
const level = plan.options.level.split('/').pop();
|
||||
return `${level}_x${tx}_y${ty}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The manifest, in the shape RawContent/World/Region.json has - so the Unreal side reads this export with
|
||||
* region_manifest.py exactly as it reads a hand-written one, and nothing downstream needs to know which
|
||||
* tool cut the tiles.
|
||||
*
|
||||
* The `source` block is kept, and made honest. generate_region_tiles.py is what reads it, and it will not
|
||||
* run against these tiles because they are already there; what it records is where the ground came from
|
||||
* and at what scale, which used to be a number somebody chose and wrote in a comment.
|
||||
*/
|
||||
function regionManifest(plan, meta) {
|
||||
const o = plan.options;
|
||||
const deg = r => +(r * 180 / Math.PI).toFixed(6);
|
||||
return {
|
||||
_comment: 'Written by World Orogen\'s Unreal landscape export. The tiles in RegionTiles/ are a '
|
||||
+ 'product of this file and the planet named below; generate_region_tiles.py is not in this '
|
||||
+ 'path and does not need to run. Every key is explained in Scripts/Authoring/region_manifest.py.',
|
||||
level: o.level,
|
||||
_comment_tiles: `${o.tiles.columns} x ${o.tiles.rows} landscapes of ${o.tiles.vertices} vertices. `
|
||||
+ `${plan.quadsPerTile} quads is ${plan.quadsPerTile / 255} x 255, so the engine gives each tile `
|
||||
+ `${plan.componentsPerTile} components of 255 quads: ${plan.tileCount * plan.componentsPerTile} `
|
||||
+ `over the window. Neighbours share their edge vertices, so the grid is ${plan.quadsX + 1} x `
|
||||
+ `${plan.quadsY + 1} vertices, ${(plan.widthM / 1000).toFixed(2)} x `
|
||||
+ `${(plan.heightM / 1000).toFixed(2)} km, ${plan.areaKm2.toFixed(0)} km2 of map.`,
|
||||
tiles: { ...o.tiles },
|
||||
quad_cm: o.quad_cm,
|
||||
sea_level_m: o.sea_level_m,
|
||||
spawn_pad_m: o.spawn_pad_m,
|
||||
streaming_grid_components: o.streaming_grid_components,
|
||||
elevation_m: { ...o.elevation_m },
|
||||
_comment_elevation: `The ground came out ${meta.minM.toFixed(0)}..${meta.maxM.toFixed(0)} m, which `
|
||||
+ `uses ${(meta.rampUsed * 100).toFixed(0)}% of the 16-bit ramp at `
|
||||
+ `${((o.elevation_m.max - o.elevation_m.min) / 65535 * 100).toFixed(1)} cm a step. `
|
||||
+ `${meta.clipped === 0 ? 'Nothing clips.' : (meta.clipped * 100).toFixed(3) + '% of vertices clip - widen elevation_m.'}`,
|
||||
source: {
|
||||
_comment: 'Rendered directly out of World Orogen rather than cut from a PNG, so the scale is '
|
||||
+ 'recorded rather than chosen after the fact. metres_per_pixel is what one pixel of the '
|
||||
+ 'intermediate float raster was worth; the tiles themselves are at quad_cm.',
|
||||
kind: 'orogen_render',
|
||||
planet: meta.planetCode || null,
|
||||
planet_circumference_km: o.planet_circumference_km,
|
||||
centre: { lon_deg: o.centre.lon_deg, lat_deg: o.centre.lat_deg },
|
||||
window_deg: {
|
||||
lon_min: deg(plan.lonMin), lon_max: deg(plan.lonMax),
|
||||
lat_min: deg(plan.latMin), lat_max: deg(plan.latMax),
|
||||
},
|
||||
projection: 'equirectangular, cosine-corrected at the centre latitude',
|
||||
east_west_stretch: { north: +plan.stretchNorth.toFixed(4), south: +plan.stretchSouth.toFixed(4) },
|
||||
metres_per_pixel: +plan.metresPerPixel.toFixed(6),
|
||||
// The window in pixels of the intermediate raster, so region_manifest.py's metres_per_pixel()
|
||||
// has the same shape of answer here as it does for a manifest that names a PNG. The raster is
|
||||
// rendered `pad` pixels wider on every side than the window, for the resampler's outer taps.
|
||||
window: { x: RASTER_PAD, y: RASTER_PAD, width: plan.innerW, height: plan.innerH },
|
||||
raster: { width: plan.rasterW, height: plan.rasterH, pad: RASTER_PAD },
|
||||
elevation_m: { min: -5000, max: 6000 },
|
||||
sea_scale: o.sea_scale,
|
||||
exported: new Date().toISOString(),
|
||||
},
|
||||
layers: { ...o.layers },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the window and writes the whole tile set, plus Region.json, into a directory the user picks.
|
||||
*
|
||||
* `dirHandle` should be the project's RawContent/World: the tiles go into RegionTiles/ beneath it and the
|
||||
* manifest beside it, which is the layout create_region_world.py already reads. One tile is held in memory
|
||||
* at a time; the window raster is the only large allocation and it is float32 over the window, not the
|
||||
* planet.
|
||||
*/
|
||||
export async function exportUnrealRegion(opts, dirHandle, onProgress = () => {}) {
|
||||
const plan = planRegion(opts);
|
||||
const o = plan.options;
|
||||
|
||||
onProgress(0.02, 'Sampling the planet');
|
||||
const raster = await renderHeightWindowKm({
|
||||
lonMin: plan.lonMin - RASTER_PAD * plan.lonSpan / (plan.innerW - 1),
|
||||
lonMax: plan.lonMax + RASTER_PAD * plan.lonSpan / (plan.innerW - 1),
|
||||
latMin: plan.latMin - RASTER_PAD * plan.latSpan / (plan.innerH - 1),
|
||||
latMax: plan.latMax + RASTER_PAD * plan.latSpan / (plan.innerH - 1),
|
||||
width: plan.rasterW,
|
||||
height: plan.rasterH,
|
||||
onProgress: (f, label) => onProgress(0.02 + f * 0.18, label),
|
||||
});
|
||||
|
||||
// The pad's height is read at the window's exact centre, once, so every tile it touches lifts to the
|
||||
// same level. Never below the sea: a pad in the water is not a place to stand.
|
||||
const cx = RASTER_PAD + (plan.innerW - 1) / 2;
|
||||
const cy = RASTER_PAD + (plan.innerH - 1) / 2;
|
||||
const centreKm = resampleY(
|
||||
resampleX(raster, plan.rasterW, plan.rasterH, Float64Array.from([cx])),
|
||||
1, plan.rasterH, Float64Array.from([cy]))[0];
|
||||
let padMetres = centreKm * 1000;
|
||||
if (padMetres < 0) padMetres *= o.sea_scale;
|
||||
padMetres = Math.max(padMetres, o.sea_level_m + 30);
|
||||
|
||||
const tilesDir = await dirHandle.getDirectoryHandle('RegionTiles', { create: true });
|
||||
const meta = { minM: Infinity, maxM: -Infinity, clipped: 0, planetCode: opts && opts.planet_code };
|
||||
const total = plan.tileCount;
|
||||
let done = 0;
|
||||
|
||||
for (let ty = 0; ty < o.tiles.rows; ty++) {
|
||||
for (let tx = 0; tx < o.tiles.columns; tx++) {
|
||||
const name = tileName(plan, tx, ty);
|
||||
onProgress(0.2 + done / total * 0.8, `${name} (${done + 1}/${total})`);
|
||||
|
||||
const tile = tileMetres(plan, raster, tx, ty, LAYER_MARGIN);
|
||||
applySpawnPad(plan, tile, padMetres);
|
||||
|
||||
const { heights, width, height, clipped, minM, maxM } = encodeHeights(plan, tile);
|
||||
if (minM < meta.minM) meta.minM = minM;
|
||||
if (maxM > meta.maxM) meta.maxM = maxM;
|
||||
meta.clipped += clipped / total;
|
||||
|
||||
await writeBlob(tilesDir, `${name}_Height.png`, await encodeGray16(width, height, heights));
|
||||
|
||||
const derived = deriveLayers(plan, tile);
|
||||
for (const layerName of LAYER_NAMES) {
|
||||
await writeBlob(tilesDir, `${name}_${layerName}.png`,
|
||||
await encodeGray8(derived.width, derived.height, derived.layers[layerName]));
|
||||
}
|
||||
|
||||
done++;
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
}
|
||||
}
|
||||
|
||||
meta.rampUsed = (meta.maxM - meta.minM) / (o.elevation_m.max - o.elevation_m.min);
|
||||
const manifest = regionManifest(plan, meta);
|
||||
|
||||
// An existing Region.json is never replaced. The one in this project is hand-written and most of it is
|
||||
// commentary explaining why each number is what it is; a generated file would throw all of that away,
|
||||
// and the same rule already governs the terrain studio, which saves by patching a legend's *text* so its
|
||||
// reasoning survives. The tiles are the product here - the manifest is a description of what was cut -
|
||||
// so the generated one lands beside it under a name of its own and the caller is told which it got.
|
||||
const manifestName = (await fileExists(dirHandle, 'Region.json'))
|
||||
? 'Region.generated.json' : 'Region.json';
|
||||
await writeBlob(dirHandle, manifestName,
|
||||
new Blob([JSON.stringify(manifest, null, 2) + '\n'], { type: 'application/json' }));
|
||||
|
||||
onProgress(1, 'Done');
|
||||
return { plan, meta, manifest, manifestName };
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// Sampling the planet over a window, for the Unreal landscape export.
|
||||
//
|
||||
// The map exports in planet-mesh.js answer one question: "draw the whole planet at width W". Unreal needs
|
||||
// a different one answered - "what is the ground, in metres, over *this* rectangle of the planet, at
|
||||
// whatever sample spacing I ask for" - and this module is that question and nothing else. Keeping it
|
||||
// separate is what lets unreal-export.js know about tiles and weightmaps without planet-mesh.js knowing
|
||||
// about either.
|
||||
//
|
||||
// Heights come back as kilometres through the same fixed -5..6 km ramp `heightmapColor` uses, rather than
|
||||
// as kilometres written straight into the vertex colours. The ramp is code that is already proven by the
|
||||
// 16-bit export; the difference here is that the float render target is read as floats instead of being
|
||||
// quantised to 16 bits. A float32 over 0..1 resolves about 1e-7, which over an 11 km ramp is a millimetre,
|
||||
// three orders of magnitude finer than the 16-bit PNG's 17 cm - so nothing is lost coming back out, and
|
||||
// negative values never have to survive a vertex-colour path where three.js colour management could reach
|
||||
// them.
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { renderer } from './scene.js';
|
||||
import { state } from './state.js';
|
||||
import { elevToHeightKm } from './color-map.js';
|
||||
|
||||
export const RAMP_MIN_KM = -5;
|
||||
export const RAMP_SPAN_KM = 11;
|
||||
|
||||
/** The ramp `heightmapColor` paints with, as a number rather than a colour. Clamping is a formality:
|
||||
* elevToHeightKm cannot leave -5..6 by construction. */
|
||||
function toRamp(elevation) {
|
||||
const km = elevToHeightKm(elevation);
|
||||
return Math.max(0, Math.min(1, (km - RAMP_MIN_KM) / RAMP_SPAN_KM));
|
||||
}
|
||||
|
||||
// The same smooth triangle soup the 16-bit heightmap export builds: one triangle per mesh side, with the
|
||||
// two triangle-centre vertices carrying the average of the three regions they touch, so a cell interpolates
|
||||
// as a Gouraud gradient rather than reading as a flat hex.
|
||||
//
|
||||
// Two differences, both because this is sampled rather than looked at. Nothing is clamped into x in
|
||||
// [-2, 2]: that clamp squashes the triangles straddling the date line, which is invisible in a whole-planet
|
||||
// image because the wrapped copy covers it, and is a torn seam in a window that happens to sit there. And
|
||||
// the caller draws the result three times, a full map apart, so a window crossing the date line sees real
|
||||
// geometry on both sides instead of the edge of the mesh.
|
||||
function buildHeightMapMesh(curData) {
|
||||
const { mesh, r_xyz, t_xyz, r_elevation } = curData;
|
||||
const { numSides, numTriangles } = mesh;
|
||||
const PI = Math.PI;
|
||||
const sx = 2 / PI;
|
||||
|
||||
const t_elev = new Float32Array(numTriangles);
|
||||
const tris = mesh.triangles;
|
||||
for (let t = 0; t < numTriangles; t++) {
|
||||
const s0 = 3 * t;
|
||||
t_elev[t] = (r_elevation[tris[s0]] + r_elevation[tris[s0 + 1]] + r_elevation[tris[s0 + 2]]) / 3;
|
||||
}
|
||||
|
||||
const posArr = new Float32Array(numSides * 18);
|
||||
const colArr = new Float32Array(numSides * 18);
|
||||
let triCount = 0;
|
||||
|
||||
const emit = (lonA, latA, lonB, latB, lonC, latC, vA, vB, vC) => {
|
||||
const off = triCount * 9;
|
||||
posArr[off] = lonA * sx; posArr[off + 1] = latA * sx; posArr[off + 2] = 0;
|
||||
posArr[off + 3] = lonB * sx; posArr[off + 4] = latB * sx; posArr[off + 5] = 0;
|
||||
posArr[off + 6] = lonC * sx; posArr[off + 7] = latC * sx; posArr[off + 8] = 0;
|
||||
colArr[off] = colArr[off + 1] = colArr[off + 2] = vA;
|
||||
colArr[off + 3] = colArr[off + 4] = colArr[off + 5] = vB;
|
||||
colArr[off + 6] = colArr[off + 7] = colArr[off + 8] = vC;
|
||||
triCount++;
|
||||
};
|
||||
|
||||
for (let s = 0; s < numSides; s++) {
|
||||
const it = mesh.s_inner_t(s);
|
||||
const ot = mesh.s_outer_t(s);
|
||||
const br = mesh.s_begin_r(s);
|
||||
|
||||
const v0 = toRamp(t_elev[it]);
|
||||
const v1 = toRamp(t_elev[ot]);
|
||||
const v2 = toRamp(r_elevation[br]);
|
||||
|
||||
const x0 = t_xyz[3 * it], y0 = t_xyz[3 * it + 1], z0 = t_xyz[3 * it + 2];
|
||||
const x1 = t_xyz[3 * ot], y1 = t_xyz[3 * ot + 1], z1 = t_xyz[3 * ot + 2];
|
||||
const x2 = r_xyz[3 * br], y2 = r_xyz[3 * br + 1], z2 = r_xyz[3 * br + 2];
|
||||
|
||||
let lon0 = Math.atan2(x0, z0), lat0 = Math.asin(Math.max(-1, Math.min(1, y0)));
|
||||
let lon1 = Math.atan2(x1, z1), lat1 = Math.asin(Math.max(-1, Math.min(1, y1)));
|
||||
let lon2 = Math.atan2(x2, z2), lat2 = Math.asin(Math.max(-1, Math.min(1, y2)));
|
||||
|
||||
if (Math.max(lon0, lon1, lon2) - Math.min(lon0, lon1, lon2) > PI) {
|
||||
if (lon0 < 0) lon0 += 2 * PI;
|
||||
if (lon1 < 0) lon1 += 2 * PI;
|
||||
if (lon2 < 0) lon2 += 2 * PI;
|
||||
emit(lon0, lat0, lon1, lat1, lon2, lat2, v0, v1, v2);
|
||||
emit(lon0 - 2 * PI, lat0, lon1 - 2 * PI, lat1, lon2 - 2 * PI, lat2, v0, v1, v2);
|
||||
} else {
|
||||
emit(lon0, lat0, lon1, lat1, lon2, lat2, v0, v1, v2);
|
||||
}
|
||||
}
|
||||
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(posArr.buffer, 0, triCount * 9), 3));
|
||||
geo.setAttribute('color', new THREE.BufferAttribute(new Float32Array(colArr.buffer, 0, triCount * 9), 3));
|
||||
return new THREE.Mesh(geo, new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.DoubleSide }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a longitude/latitude rectangle of the current planet into kilometres above sea level.
|
||||
*
|
||||
* The raster's *pixel centres* span the rectangle exactly - pixel 0 sits on lonMin, pixel width-1 on
|
||||
* lonMax - because that is the convention the resampler downstream reads it with, so the frustum is
|
||||
* widened by half a pixel on each side to put them there. Row 0 is the northern edge, as in an image.
|
||||
*
|
||||
* @returns {Promise<Float32Array>} width * height kilometres, row-major from the north.
|
||||
*/
|
||||
export async function renderHeightWindowKm({ lonMin, lonMax, latMin, latMax, width, height, onProgress }) {
|
||||
if (!state.curData) throw new Error('no planet loaded to export');
|
||||
if (width < 2 || height < 2) throw new Error('a height window needs at least 2 x 2 samples');
|
||||
|
||||
const sx = 2 / Math.PI;
|
||||
const mapMesh = buildHeightMapMesh(state.curData);
|
||||
const offScene = new THREE.Scene();
|
||||
offScene.background = new THREE.Color(0x000000);
|
||||
// Three copies, a full map apart, so a window crossing the date line is covered on both sides of it.
|
||||
for (const shift of [-4, 0, 4]) {
|
||||
const copy = new THREE.Mesh(mapMesh.geometry, mapMesh.material);
|
||||
copy.position.x = shift;
|
||||
offScene.add(copy);
|
||||
}
|
||||
|
||||
const halfU = (lonMax - lonMin) * sx / (width - 1) / 2;
|
||||
const halfV = (latMax - latMin) * sx / (height - 1) / 2;
|
||||
const mx0 = lonMin * sx - halfU, mx1 = lonMax * sx + halfU;
|
||||
const my0 = latMin * sx - halfV, my1 = latMax * sx + halfV;
|
||||
|
||||
const out = new Float32Array(width * height);
|
||||
const step = Math.min(2048, renderer.capabilities.maxTextureSize);
|
||||
const tilesX = Math.ceil(width / step);
|
||||
const tilesY = Math.ceil(height / step);
|
||||
const total = tilesX * tilesY;
|
||||
let done = 0;
|
||||
|
||||
const prevColorSpace = renderer.outputColorSpace;
|
||||
renderer.outputColorSpace = THREE.LinearSRGBColorSpace;
|
||||
try {
|
||||
for (let ty = 0; ty < tilesY; ty++) {
|
||||
for (let tx = 0; tx < tilesX; tx++) {
|
||||
const px0 = tx * step, py0 = ty * step;
|
||||
const pw = Math.min(step, width - px0);
|
||||
const ph = Math.min(step, height - py0);
|
||||
|
||||
const cam = new THREE.OrthographicCamera(
|
||||
mx0 + (mx1 - mx0) * px0 / width,
|
||||
mx0 + (mx1 - mx0) * (px0 + pw) / width,
|
||||
my1 - (my1 - my0) * py0 / height,
|
||||
my1 - (my1 - my0) * (py0 + ph) / height,
|
||||
0.1, 10);
|
||||
cam.position.set(0, 0, 5);
|
||||
cam.lookAt(0, 0, 0);
|
||||
|
||||
const target = new THREE.WebGLRenderTarget(pw, ph, { type: THREE.FloatType });
|
||||
renderer.setRenderTarget(target);
|
||||
renderer.render(offScene, cam);
|
||||
const pixels = new Float32Array(pw * ph * 4);
|
||||
renderer.readRenderTargetPixels(target, 0, 0, pw, ph, pixels);
|
||||
renderer.setRenderTarget(null);
|
||||
target.dispose();
|
||||
|
||||
for (let y = 0; y < ph; y++) {
|
||||
const src = (ph - 1 - y) * pw; // the readback is bottom-up
|
||||
const dst = (py0 + y) * width + px0;
|
||||
for (let x = 0; x < pw; x++) {
|
||||
out[dst + x] = pixels[(src + x) * 4] * RAMP_SPAN_KM + RAMP_MIN_KM;
|
||||
}
|
||||
}
|
||||
|
||||
done++;
|
||||
if (onProgress) onProgress(done / total, 'Sampling the planet');
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
renderer.outputColorSpace = prevColorSpace;
|
||||
renderer.setRenderTarget(null);
|
||||
mapMesh.geometry.dispose();
|
||||
mapMesh.material.dispose();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
// The Unreal landscape export's panel.
|
||||
//
|
||||
// Built in JavaScript rather than written into index.html and import.html, because it is the same panel on
|
||||
// both pages and two copies of a form with fourteen fields drift within a week.
|
||||
//
|
||||
// The panel's job is not to collect the numbers - it is to show what a number *buys* before two hours are
|
||||
// spent on it. Every field re-plans on the keystroke and the readout underneath says how much ground the
|
||||
// window holds, how many landscape components that is, how finely the planet is being sampled and what the
|
||||
// flat reading costs at the window's edges. That is the same service `terrain plan` does for a legend and
|
||||
// `--scout` does for a region: the expensive step should never be how you find out you set a number wrong.
|
||||
|
||||
import { planRegion, exportUnrealRegion, DEFAULTS } from './unreal-export.js';
|
||||
|
||||
const FIELDS = [
|
||||
{ key: 'level', label: 'Level', type: 'text', width: 'wide',
|
||||
hint: 'The tiles are named after its last segment: L_Region_x0_y0_Height.png' },
|
||||
{ key: 'planet_circumference_km', label: 'Planet circumference', unit: 'km', type: 'number', step: 1,
|
||||
hint: 'The sphere carries no metres. This is what turns the window\'s degrees into ground, and it is '
|
||||
+ 'the number that decides how much land a window can hold. RawContent/World/Planet.json is where '
|
||||
+ 'this project\'s own value lives; at 100 km round the whole globe is 3183 km2 of surface, so a '
|
||||
+ 'window of a few hundred km2 is already a large piece of it.' },
|
||||
{ key: 'centre.lon_deg', label: 'Centre longitude', unit: '°', type: 'number', step: 0.1 },
|
||||
{ key: 'centre.lat_deg', label: 'Centre latitude', unit: '°', type: 'number', step: 0.1,
|
||||
hint: 'Keep the window near the equator: an equirectangular reading stretches east-west by '
|
||||
+ '1/cos(latitude), without bound at the poles.' },
|
||||
{ key: 'tiles.columns', label: 'Tiles across', type: 'number', step: 1, min: 1 },
|
||||
{ key: 'tiles.rows', label: 'Tiles down', type: 'number', step: 1, min: 1 },
|
||||
{ key: 'tiles.vertices', label: 'Vertices a tile', type: 'select',
|
||||
options: [[1021, '1021 (4 x 4 components)'], [2041, '2041 (8 x 8)'], [2551, '2551 (10 x 10)'],
|
||||
[3061, '3061 (12 x 12)']],
|
||||
hint: '255 * N + 1, so the engine gives each tile N x N components of 255 quads. The component count '
|
||||
+ 'is what costs, not the vertex count.' },
|
||||
{ key: 'quad_cm', label: 'Quad size', unit: 'cm', type: 'number', step: 1,
|
||||
hint: 'Metres between vertices, in centimetres. 200 is a 2 m quad.' },
|
||||
{ key: 'elevation_m.min', label: 'Elevation floor', unit: 'm', type: 'number', step: 1 },
|
||||
{ key: 'elevation_m.max', label: 'Elevation ceiling', unit: 'm', type: 'number', step: 1,
|
||||
hint: 'What 0 and 65535 mean. Too narrow clips; too wide only costs height precision, and the export '
|
||||
+ 'reports both.' },
|
||||
{ key: 'sea_scale', label: 'Sea scale', type: 'number', step: 0.01,
|
||||
hint: 'Multiplies everything below sea level. Orogen\'s abyss is 5 km down on a whole-planet ramp, '
|
||||
+ 'which over a small window is either a clipped plateau or an elevation range so wide the land '
|
||||
+ 'loses its precision. Land is untouched.' },
|
||||
{ key: 'source_metres_per_pixel', label: 'Sample spacing', unit: 'm', type: 'number', step: 0.5,
|
||||
hint: 'How finely the planet is rendered before the tiles are cut from it. The mesh only resolves a '
|
||||
+ 'couple of hundred metres, so anything below about 25 m here is already lossless.' },
|
||||
];
|
||||
|
||||
const get = (obj, path) => path.split('.').reduce((o, k) => (o == null ? o : o[k]), obj);
|
||||
function set(obj, path, value) {
|
||||
const parts = path.split('.');
|
||||
const last = parts.pop();
|
||||
const target = parts.reduce((o, k) => (o[k] = o[k] || {}), obj);
|
||||
target[last] = value;
|
||||
}
|
||||
|
||||
function clone(o) { return JSON.parse(JSON.stringify(o)); }
|
||||
|
||||
const STORE_KEY = 'orogen.unrealExport';
|
||||
|
||||
function loadSettings() {
|
||||
const settings = clone(DEFAULTS);
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(STORE_KEY) || '{}');
|
||||
for (const { key } of FIELDS) {
|
||||
const v = get(saved, key);
|
||||
if (v !== undefined && v !== null) set(settings, key, v);
|
||||
}
|
||||
} catch { /* a stale or blocked store is not a reason to refuse to open */ }
|
||||
return settings;
|
||||
}
|
||||
|
||||
function saveSettings(settings) {
|
||||
try {
|
||||
const out = {};
|
||||
for (const { key } of FIELDS) set(out, key, get(settings, key));
|
||||
localStorage.setItem(STORE_KEY, JSON.stringify(out));
|
||||
} catch { /* private windows and blocked site data are fine; the panel just forgets */ }
|
||||
}
|
||||
|
||||
function el(tag, attrs = {}, ...children) {
|
||||
const node = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
if (k === 'class') node.className = v;
|
||||
else if (k === 'text') node.textContent = v;
|
||||
else if (v !== undefined && v !== null) node.setAttribute(k, v);
|
||||
}
|
||||
for (const child of children) if (child) node.appendChild(child);
|
||||
return node;
|
||||
}
|
||||
|
||||
let panel = null;
|
||||
|
||||
function build() {
|
||||
const settings = loadSettings();
|
||||
|
||||
const overlay = el('div', { id: 'unrealOverlay', class: 'hidden' });
|
||||
const card = el('div', { id: 'unrealCard' });
|
||||
const close = el('button', { id: 'unrealClose', type: 'button', 'aria-label': 'Close', text: '×' });
|
||||
card.appendChild(close);
|
||||
card.appendChild(el('h3', { text: 'Export Unreal Landscape' }));
|
||||
card.appendChild(el('p', { class: 'unreal-blurb', text:
|
||||
'Renders a window of this planet straight into the tile set Unreal\'s landscape importer wants: a '
|
||||
+ '16-bit height and three 8-bit weightmaps per tile, plus the Region.json that describes them.' }));
|
||||
|
||||
const grid = el('div', { class: 'unreal-grid' });
|
||||
const inputs = {};
|
||||
for (const field of FIELDS) {
|
||||
const wrap = el('div', { class: 'cg' + (field.width === 'wide' ? ' unreal-wide' : '') });
|
||||
const label = el('label', { text: field.label });
|
||||
if (field.unit) label.appendChild(el('span', { class: 'v', text: field.unit }));
|
||||
if (field.hint) label.setAttribute('title', field.hint);
|
||||
wrap.appendChild(label);
|
||||
|
||||
let input;
|
||||
if (field.type === 'select') {
|
||||
input = el('select');
|
||||
for (const [value, text] of field.options) input.appendChild(el('option', { value, text }));
|
||||
} else if (field.type === 'number') {
|
||||
// Deliberately not <input type="number">. That control formats and parses in the *browser's*
|
||||
// locale, so on a machine whose decimal separator is a comma a sea scale of 0.17 is shown as
|
||||
// "0,17" and `.value` comes back as the empty string - the setting silently becomes NaN and the
|
||||
// export writes a whole tile set of nothing. A text box with inputmode="decimal" gets the same
|
||||
// numeric keyboard on a phone and leaves the parsing here, where both separators are accepted.
|
||||
input = el('input', { type: 'text', inputmode: 'decimal', autocomplete: 'off', spellcheck: 'false' });
|
||||
} else {
|
||||
input = el('input', { type: field.type, autocomplete: 'off', spellcheck: 'false' });
|
||||
}
|
||||
input.value = get(settings, field.key);
|
||||
input.addEventListener('input', () => {
|
||||
const raw = input.value;
|
||||
if (field.type === 'text') {
|
||||
set(settings, field.key, raw);
|
||||
} else if (field.type === 'select') {
|
||||
set(settings, field.key, Number(raw));
|
||||
} else {
|
||||
const parsed = Number(String(raw).trim().replace(',', '.'));
|
||||
input.classList.toggle('unreal-bad', raw.trim() !== '' && !Number.isFinite(parsed));
|
||||
if (!Number.isFinite(parsed)) return; // keep the last good value while it is being typed
|
||||
set(settings, field.key, parsed);
|
||||
}
|
||||
saveSettings(settings);
|
||||
refresh();
|
||||
});
|
||||
inputs[field.key] = input;
|
||||
wrap.appendChild(input);
|
||||
grid.appendChild(wrap);
|
||||
}
|
||||
card.appendChild(grid);
|
||||
|
||||
const readout = el('div', { class: 'unreal-readout' });
|
||||
card.appendChild(readout);
|
||||
|
||||
const status = el('div', { class: 'unreal-status' });
|
||||
card.appendChild(status);
|
||||
|
||||
const cancel = el('button', { class: 'btn-ghost', type: 'button', text: 'Close' });
|
||||
const go = el('button', { class: 'btn-primary', type: 'button', text: 'Choose folder & export' });
|
||||
const actions = el('div', { class: 'export-actions' }, cancel, go);
|
||||
card.appendChild(actions);
|
||||
|
||||
overlay.appendChild(card);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
function refresh() {
|
||||
let plan;
|
||||
try {
|
||||
plan = planRegion(settings);
|
||||
} catch (err) {
|
||||
readout.innerHTML = '';
|
||||
readout.appendChild(el('div', { class: 'unreal-warn', text: err.message }));
|
||||
go.disabled = true;
|
||||
return null;
|
||||
}
|
||||
go.disabled = false;
|
||||
const rows = [
|
||||
['Ground', `${(plan.widthM / 1000).toFixed(2)} × ${(plan.heightM / 1000).toFixed(2)} km, `
|
||||
+ `${plan.areaKm2.toFixed(0)} km²`],
|
||||
['Tiles', `${plan.tileCount} of ${(plan.tileSideM / 1000).toFixed(2)} km, `
|
||||
+ `${plan.tileCount * plan.componentsPerTile} components`],
|
||||
['Files', `${plan.tileCount * 4} PNGs, about `
|
||||
+ `${(plan.tileCount * (settings.tiles.vertices ** 2) * 5 / 1e9).toFixed(1)} GB uncompressed`],
|
||||
['Window', `${(plan.lonSpan * 180 / Math.PI).toFixed(2)}° × `
|
||||
+ `${(plan.latSpan * 180 / Math.PI).toFixed(2)}° of the planet`],
|
||||
['Sampled at', `${plan.metresPerPixel.toFixed(2)} m a pixel `
|
||||
+ `(${plan.rasterW} × ${plan.rasterH})`],
|
||||
['E-W stretch', `${((plan.stretchNorth - 1) * 100).toFixed(1)}% north, `
|
||||
+ `${((plan.stretchSouth - 1) * 100).toFixed(1)}% south`],
|
||||
];
|
||||
readout.innerHTML = '';
|
||||
for (const [name, value] of rows) {
|
||||
readout.appendChild(el('div', { class: 'unreal-row' },
|
||||
el('span', { class: 'unreal-key', text: name }),
|
||||
el('span', { class: 'unreal-val', text: value })));
|
||||
}
|
||||
for (const warning of plan.warnings) {
|
||||
readout.appendChild(el('div', { class: 'unreal-warn', text: warning }));
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
function setStatus(text, kind = '') {
|
||||
status.textContent = text;
|
||||
status.className = 'unreal-status' + (kind ? ' ' + kind : '');
|
||||
}
|
||||
|
||||
go.addEventListener('click', async () => {
|
||||
if (!window.showDirectoryPicker) {
|
||||
setStatus('This browser cannot write to a folder. The export needs the File System Access API: '
|
||||
+ 'use Chrome or Edge on a desktop.', 'unreal-warn');
|
||||
return;
|
||||
}
|
||||
let dir;
|
||||
try {
|
||||
dir = await window.showDirectoryPicker({ mode: 'readwrite', id: 'orogen-unreal-region' });
|
||||
} catch {
|
||||
return; // the picker was dismissed, which is not an error
|
||||
}
|
||||
go.disabled = true;
|
||||
cancel.disabled = true;
|
||||
try {
|
||||
const result = await exportUnrealRegion(settings, dir, (fraction, label) => {
|
||||
setStatus(`${Math.round(fraction * 100)}% — ${label}`);
|
||||
});
|
||||
const { meta } = result;
|
||||
const manifestNote = result.manifestName === 'Region.json'
|
||||
? 'Region.json written beside RegionTiles/.'
|
||||
: 'A Region.json was already there and was left alone — the new one is '
|
||||
+ 'Region.generated.json. Rename it over the old one when you are ready.';
|
||||
setStatus(`Done. ${result.plan.tileCount} tiles, ground `
|
||||
+ `${meta.minM.toFixed(0)}..${meta.maxM.toFixed(0)} m, `
|
||||
+ `${(meta.rampUsed * 100).toFixed(0)}% of the 16-bit ramp used`
|
||||
+ `${meta.clipped > 0 ? `, ${(meta.clipped * 100).toFixed(3)}% clipped — widen the elevation range` : ', nothing clipped'}`
|
||||
+ `. ${manifestNote}`, meta.clipped > 0 ? 'unreal-warn' : 'unreal-ok');
|
||||
} catch (err) {
|
||||
setStatus(`Failed: ${err.message}`, 'unreal-warn');
|
||||
throw err;
|
||||
} finally {
|
||||
go.disabled = false;
|
||||
cancel.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
const hide = () => overlay.classList.add('hidden');
|
||||
close.addEventListener('click', hide);
|
||||
cancel.addEventListener('click', hide);
|
||||
overlay.addEventListener('click', e => { if (e.target === overlay) hide(); });
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape' && !overlay.classList.contains('hidden')) hide();
|
||||
});
|
||||
|
||||
panel = {
|
||||
overlay,
|
||||
open() { overlay.classList.remove('hidden'); refresh(); },
|
||||
settings,
|
||||
refresh,
|
||||
};
|
||||
refresh();
|
||||
return panel;
|
||||
}
|
||||
|
||||
export function openUnrealExport() {
|
||||
(panel || build()).open();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the button that opens the panel to whichever export card the page has. Called by both entry points;
|
||||
* does nothing if the page has no export card or the button is already there.
|
||||
*/
|
||||
export function installUnrealExportButton() {
|
||||
const actions = document.querySelector('#exportCard .export-actions');
|
||||
if (!actions || document.getElementById('unrealExportBtn')) return;
|
||||
const button = el('button', { id: 'unrealExportBtn', class: 'btn-ghost', type: 'button',
|
||||
text: 'Unreal Landscape…' });
|
||||
button.addEventListener('click', () => {
|
||||
document.getElementById('exportOverlay').classList.add('hidden');
|
||||
openUnrealExport();
|
||||
});
|
||||
actions.insertBefore(button, actions.firstChild);
|
||||
|
||||
// A handle for headless runs, the same way the painted import exposes window.orogenPainted.
|
||||
window.orogenUnreal = {
|
||||
plan: opts => planRegion({ ...(panel ? panel.settings : DEFAULTS), ...(opts || {}) }),
|
||||
exportTo: (opts, dirHandle, onProgress) => exportUnrealRegion(opts, dirHandle, onProgress),
|
||||
open: openUnrealExport,
|
||||
get settings() { return panel ? panel.settings : loadSettings(); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,768 @@
|
||||
// Wind simulation: pressure-driven seasonal wind with longitude-varying ITCZ.
|
||||
// Computes pressure fields and wind vectors for summer and winter seasons.
|
||||
|
||||
import { elevToHeightKm } from './color-map.js';
|
||||
import { smoothField, percentile } from './climate-util.js';
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
const RAD = 180 / Math.PI;
|
||||
|
||||
// ── Periodic cubic spline interpolation ──────────────────────────────────────
|
||||
|
||||
function buildPeriodicSpline(xs, ys) {
|
||||
// xs: sorted longitude samples (radians), ys: ITCZ latitude values
|
||||
// Returns spline data for evaluateSpline()
|
||||
const n = xs.length;
|
||||
const period = 2 * Math.PI;
|
||||
|
||||
// Build tridiagonal system for periodic natural cubic spline
|
||||
const h = new Float64Array(n);
|
||||
const alpha = new Float64Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const next = (i + 1) % n;
|
||||
h[i] = (xs[next] - xs[i] + period) % period;
|
||||
if (h[i] === 0) h[i] = period / n;
|
||||
}
|
||||
for (let i = 0; i < n; i++) {
|
||||
const prev = (i - 1 + n) % n;
|
||||
const next = (i + 1) % n;
|
||||
alpha[i] = (3 / h[i]) * (ys[next] - ys[i]) - (3 / h[prev]) * (ys[i] - ys[prev]);
|
||||
}
|
||||
|
||||
// Solve with Thomas-like algorithm for periodic system
|
||||
// Simplified: use iterative relaxation (fast enough for n=72)
|
||||
const c = new Float64Array(n);
|
||||
for (let iter = 0; iter < 20; iter++) {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const prev = (i - 1 + n) % n;
|
||||
const next = (i + 1) % n;
|
||||
c[i] = (alpha[i] - h[prev] * c[prev] - h[i] * c[next]) /
|
||||
(2 * (h[prev] + h[i]));
|
||||
}
|
||||
}
|
||||
|
||||
const b = new Float64Array(n);
|
||||
const d = new Float64Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const next = (i + 1) % n;
|
||||
b[i] = (ys[next] - ys[i]) / h[i] - h[i] * (c[next] + 2 * c[i]) / 3;
|
||||
d[i] = (c[next] - c[i]) / (3 * h[i]);
|
||||
}
|
||||
|
||||
return { xs, ys, b, c, d, h, n, period };
|
||||
}
|
||||
|
||||
function evaluateSpline(spline, lon) {
|
||||
const { xs, ys, b, c, d, n, period } = spline;
|
||||
// Normalize lon to [xs[0], xs[0] + period)
|
||||
let t = ((lon - xs[0]) % period + period) % period + xs[0];
|
||||
|
||||
// Direct index calculation — segments are equally spaced
|
||||
const segStep = period / n;
|
||||
let seg = Math.floor((t - xs[0]) / segStep);
|
||||
if (seg < 0) seg = 0;
|
||||
else if (seg >= n) seg = n - 1;
|
||||
|
||||
const dx = t - xs[seg];
|
||||
return ys[seg] + b[seg] * dx + c[seg] * dx * dx + d[seg] * dx * dx * dx;
|
||||
}
|
||||
|
||||
// ── Smoothstep utility ───────────────────────────────────────────────────────
|
||||
|
||||
export function smoothstep(edge0, edge1, x) {
|
||||
if (edge0 === edge1) return x >= edge1 ? 1 : 0;
|
||||
const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
// ── ITCZ computation ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a spatial index binning regions by latitude/longitude for fast
|
||||
* geographic sampling. Returns a function landFracAndElev(lat, lon, radius)
|
||||
* that returns { landFrac, avgElev } by scanning nearby bins.
|
||||
*/
|
||||
function buildGeoIndex(r_lat, r_lon, r_sinLat, r_cosLat, r_elevation, r_isLand, numRegions) {
|
||||
const LAT_BINS = 36; // 5° each
|
||||
const LON_BINS = 72; // 5° each
|
||||
const numBins = LAT_BINS * LON_BINS;
|
||||
|
||||
// CSR (compressed sparse row) format: count regions per bin, then prefix-sum
|
||||
// Cache bin index per region to avoid recomputing in the fill pass
|
||||
const r_bin = new Uint32Array(numRegions);
|
||||
const binCount = new Uint32Array(numBins);
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
const latBin = Math.max(0, Math.min(LAT_BINS - 1,
|
||||
Math.floor((r_lat[r] + Math.PI / 2) / Math.PI * LAT_BINS)));
|
||||
const lonBin = Math.max(0, Math.min(LON_BINS - 1,
|
||||
Math.floor((r_lon[r] + Math.PI) / (2 * Math.PI) * LON_BINS)));
|
||||
const bin = latBin * LON_BINS + lonBin;
|
||||
r_bin[r] = bin;
|
||||
binCount[bin]++;
|
||||
}
|
||||
|
||||
const binOffset = new Uint32Array(numBins + 1);
|
||||
for (let i = 0; i < numBins; i++) {
|
||||
binOffset[i + 1] = binOffset[i] + binCount[i];
|
||||
}
|
||||
|
||||
const indices = new Uint32Array(numRegions);
|
||||
const fillPos = new Uint32Array(numBins);
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
const bin = r_bin[r];
|
||||
indices[binOffset[bin] + fillPos[bin]] = r;
|
||||
fillPos[bin]++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample land fraction and average elevation in a circular region.
|
||||
* @param {number} lat - center latitude (radians)
|
||||
* @param {number} lon - center longitude (radians)
|
||||
* @param {number} radius - great-circle radius (radians)
|
||||
*/
|
||||
return function sample(lat, lon, radius) {
|
||||
const latMin = lat - radius, latMax = lat + radius;
|
||||
const bMin = Math.max(0, Math.floor((latMin + Math.PI / 2) / Math.PI * LAT_BINS));
|
||||
const bMax = Math.min(LAT_BINS - 1, Math.floor((latMax + Math.PI / 2) / Math.PI * LAT_BINS));
|
||||
|
||||
// Longitude span widens near equator
|
||||
const cosLat = Math.cos(lat) || 0.01;
|
||||
const lonSpan = radius / cosLat;
|
||||
const lMin = Math.floor((lon - lonSpan + Math.PI) / (2 * Math.PI) * LON_BINS);
|
||||
const lMax = Math.floor((lon + lonSpan + Math.PI) / (2 * Math.PI) * LON_BINS);
|
||||
|
||||
let landCount = 0, totalCount = 0, elevSum = 0;
|
||||
const cosRadius = Math.cos(radius);
|
||||
const sinLat0 = Math.sin(lat), cosLat0 = Math.cos(lat);
|
||||
|
||||
for (let bi = bMin; bi <= bMax; bi++) {
|
||||
for (let li = lMin; li <= lMax; li++) {
|
||||
const lj = ((li % LON_BINS) + LON_BINS) % LON_BINS;
|
||||
const bin = bi * LON_BINS + lj;
|
||||
const start = binOffset[bin];
|
||||
const end = binOffset[bin + 1];
|
||||
for (let k = start; k < end; k++) {
|
||||
const r = indices[k];
|
||||
const sinLat1 = r_sinLat[r];
|
||||
const cosLat1 = r_cosLat[r];
|
||||
const dlon = r_lon[r] - lon;
|
||||
const cosDist = sinLat0 * sinLat1 + cosLat0 * cosLat1 * Math.cos(dlon);
|
||||
if (cosDist >= cosRadius) {
|
||||
totalCount++;
|
||||
if (r_isLand[r]) landCount++;
|
||||
elevSum += Math.max(0, r_elevation[r]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (totalCount === 0) return { landFrac: 0, avgElev: 0 };
|
||||
return { landFrac: landCount / totalCount, avgElev: elevSum / totalCount };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute ITCZ latitude at sampled longitudes for a given season.
|
||||
* Uses a thermal equator search: scans latitudes from -30° to +30°,
|
||||
* computes a heating score at each, and picks the peak.
|
||||
*
|
||||
* Heating score combines:
|
||||
* - Solar insolation (cosine of latitude offset from subsolar point)
|
||||
* - Land thermal boost (land heats faster than ocean)
|
||||
* - Elevation boost (plateaus heat more intensely — thinner atmosphere)
|
||||
* - Cross-equatorial anchoring (winter-hemisphere land pulls ITCZ equatorward)
|
||||
*
|
||||
* @param {function} geoSample - from buildGeoIndex
|
||||
* @param {string} season - 'summer' (NH) or 'winter' (NH)
|
||||
* @param {number} tiltRad - axial tilt in radians
|
||||
* @returns {{ spline, lons: Float64Array, lats: Float64Array }}
|
||||
*/
|
||||
function computeITCZ(geoSample, season, tiltRad) {
|
||||
const NUM_LON = 72;
|
||||
// Two sampling radii: local (5°) for precise land detection, wide (30°) for continental scale
|
||||
const localRadius = 5 * DEG;
|
||||
const wideRadius = 30 * DEG;
|
||||
|
||||
// +1 = NH summer, -1 = SH summer (NH winter)
|
||||
const sign = season === 'summer' ? 1 : -1;
|
||||
|
||||
// Subsolar latitude: where the sun is directly overhead this season
|
||||
// Full tilt in summer hemisphere (e.g. +23.5° for NH summer)
|
||||
const subsolarLat = sign * tiltRad;
|
||||
|
||||
// Scan range: -30° to +30° in 2.5° steps
|
||||
const SCAN_MIN = -30;
|
||||
const SCAN_MAX = 30;
|
||||
const SCAN_STEP = 2.5;
|
||||
const numScans = Math.round((SCAN_MAX - SCAN_MIN) / SCAN_STEP) + 1;
|
||||
|
||||
const lons = new Float64Array(NUM_LON);
|
||||
const rawLats = new Float64Array(NUM_LON);
|
||||
|
||||
for (let i = 0; i < NUM_LON; i++) {
|
||||
const lon = -Math.PI + (i + 0.5) * (2 * Math.PI / NUM_LON);
|
||||
lons[i] = lon;
|
||||
|
||||
let bestScore = -Infinity;
|
||||
let bestLat = sign * 5 * DEG; // fallback
|
||||
|
||||
for (let si = 0; si < numScans; si++) {
|
||||
const latDeg = SCAN_MIN + si * SCAN_STEP;
|
||||
const lat = latDeg * DEG;
|
||||
const local = geoSample(lat, lon, localRadius);
|
||||
const wide = geoSample(lat, lon, wideRadius);
|
||||
|
||||
// (a) Solar insolation: peaks at subsolar latitude, broad Gaussian falloff.
|
||||
// σ = 25° gives a wide heating dome — the ITCZ doesn't track the
|
||||
// subsolar point 1:1, it lags and is damped by ocean thermal inertia.
|
||||
const dSolar = (lat - subsolarLat) * RAD; // degrees from subsolar
|
||||
const solarScore = Math.exp(-0.5 * (dSolar / 25) ** 2);
|
||||
|
||||
// (b) Land thermal boost: uses multi-scale sampling.
|
||||
// Only truly continental-scale landmasses pull the ITCZ significantly.
|
||||
// Islands, thin peninsulas, and coastlines near ocean register low at
|
||||
// the wide (30°) radius and get suppressed by the steep ramp.
|
||||
const localLand = local.landFrac;
|
||||
const wideLand = wide.landFrac;
|
||||
|
||||
// Also sample poleward of this latitude: a massive continent extending
|
||||
// poleward (like Asia beyond 20°N) creates an enormous heat reservoir
|
||||
// that pulls the ITCZ toward it even if the scan point itself is at
|
||||
// the continent's edge. Sample 15° poleward in the summer hemisphere.
|
||||
const polewardLat = lat + sign * 15 * DEG;
|
||||
const poleward = geoSample(polewardLat, lon, wideRadius);
|
||||
// Combined land signal: max of local-wide and poleward-wide.
|
||||
// Poleward land contributes at 70% strength (heat diffuses equatorward).
|
||||
const effectiveWideLand = Math.max(wideLand, poleward.landFrac * 0.7);
|
||||
|
||||
// Wide-scale land must exceed ~20% before any real pull kicks in.
|
||||
const continentalScale = smoothstep(0.20, 0.45, effectiveWideLand);
|
||||
// Square it so moderate land fractions still contribute little.
|
||||
const scaledLand = continentalScale * continentalScale;
|
||||
// Local land gate: require >25% local land fraction to activate.
|
||||
// At 5° radius (~560 km), ocean near thin islands stays well below this.
|
||||
const landGate = smoothstep(0.25, 0.55, localLand);
|
||||
// Strong max boost so massive continents pull ITCZ toward 25-30°
|
||||
const landBoost = landGate * scaledLand * 1.0;
|
||||
|
||||
// (c) Elevation boost: high plateaus heat more intensely
|
||||
// (thinner atmosphere, stronger surface insolation).
|
||||
// Also scaled by continental size — isolated volcanic peaks don't pull ITCZ.
|
||||
const elevKm = elevToHeightKm(Math.max(0, wide.avgElev));
|
||||
const elevBoost = Math.min(0.30, elevKm * 0.12) * scaledLand;
|
||||
|
||||
// (d) Cross-equatorial anchoring: if this latitude is in the
|
||||
// winter hemisphere but there's significant land, it anchors
|
||||
// the ITCZ closer to the equator (resists poleward migration).
|
||||
const isWinterHemi = (sign > 0 && latDeg < 0) || (sign < 0 && latDeg > 0);
|
||||
const anchorBoost = isWinterHemi ? landBoost * 0.4 : 0;
|
||||
|
||||
// (e) Ocean baseline: slight poleward bias in summer hemisphere
|
||||
// even over open ocean (~6-8° from equator on average).
|
||||
const isSummerHemi = !isWinterHemi;
|
||||
const oceanBias = isSummerHemi && localLand < 0.1
|
||||
? 0.08 * Math.exp(-0.5 * ((Math.abs(latDeg) - 7) / 5) ** 2)
|
||||
: 0;
|
||||
|
||||
const score = solarScore + landBoost + elevBoost + anchorBoost + oceanBias;
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestLat = lat;
|
||||
}
|
||||
}
|
||||
|
||||
rawLats[i] = bestLat;
|
||||
}
|
||||
|
||||
// Pull extreme outliers toward the zonal mean before longitude smoothing.
|
||||
// The ITCZ is a planetary-scale feature — individual longitude columns
|
||||
// shouldn't deviate too far from the overall trend.
|
||||
const lats = new Float64Array(rawLats);
|
||||
const tmp = new Float64Array(NUM_LON);
|
||||
// Wide periodic moving average (kernel = 5 neighbors) for heavy smoothing,
|
||||
// then narrow (kernel = 3) for fine cleanup. More passes = smoother ITCZ.
|
||||
// Wide kernel: weights [0.1, 0.2, 0.4, 0.2, 0.1] over 5 neighbors
|
||||
for (let pass = 0; pass < 4; pass++) {
|
||||
for (let i = 0; i < NUM_LON; i++) {
|
||||
const p2 = (i - 2 + NUM_LON) % NUM_LON;
|
||||
const p1 = (i - 1 + NUM_LON) % NUM_LON;
|
||||
const n1 = (i + 1) % NUM_LON;
|
||||
const n2 = (i + 2) % NUM_LON;
|
||||
tmp[i] = 0.1 * lats[p2] + 0.2 * lats[p1] + 0.4 * lats[i] + 0.2 * lats[n1] + 0.1 * lats[n2];
|
||||
}
|
||||
lats.set(tmp);
|
||||
}
|
||||
// Narrow cleanup passes
|
||||
for (let pass = 0; pass < 3; pass++) {
|
||||
for (let i = 0; i < NUM_LON; i++) {
|
||||
const p = (i - 1 + NUM_LON) % NUM_LON;
|
||||
const n = (i + 1) % NUM_LON;
|
||||
tmp[i] = 0.25 * lats[p] + 0.5 * lats[i] + 0.25 * lats[n];
|
||||
}
|
||||
lats.set(tmp);
|
||||
}
|
||||
|
||||
// Clamp to ±30° (ITCZ never migrates beyond the tropics)
|
||||
for (let i = 0; i < NUM_LON; i++) {
|
||||
lats[i] = Math.max(-30 * DEG, Math.min(30 * DEG, lats[i]));
|
||||
}
|
||||
|
||||
const spline = buildPeriodicSpline(lons, lats);
|
||||
return { spline, lons, lats };
|
||||
}
|
||||
|
||||
// ── Pressure field ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute pressure at a single region.
|
||||
*/
|
||||
function regionPressure(lat, lon, itczSpline, season, landFrac, elevation, noiseFn, px, py, pz) {
|
||||
const itczLat = evaluateSpline(itczSpline, lon);
|
||||
const latDeg = lat * RAD;
|
||||
const seasonSign = season === 'summer' ? 1 : -1;
|
||||
|
||||
let p = 1013; // baseline hPa
|
||||
|
||||
// (a) ITCZ low — follows thermal equator
|
||||
const dItcz = (lat - itczLat) * RAD; // degrees from ITCZ
|
||||
p -= 15 * Math.exp(-0.5 * (dItcz / 8) ** 2);
|
||||
|
||||
// (b) Subtropical highs — shift with season, weaker over hot land
|
||||
const shiftDeg = seasonSign * 5;
|
||||
const nhSubHigh = 30 + shiftDeg;
|
||||
const shSubHigh = -(30 - shiftDeg);
|
||||
const highIntensity = 12 * (1 - 0.3 * landFrac);
|
||||
p += highIntensity * Math.exp(-0.5 * ((latDeg - nhSubHigh) / 10) ** 2);
|
||||
p += highIntensity * Math.exp(-0.5 * ((latDeg - shSubHigh) / 10) ** 2);
|
||||
|
||||
// (c) Subpolar lows
|
||||
p -= 10 * Math.exp(-0.5 * ((latDeg - 60) / 10) ** 2);
|
||||
p -= 10 * Math.exp(-0.5 * ((latDeg + 60) / 10) ** 2);
|
||||
|
||||
// (d) Polar highs
|
||||
p += 8 * Math.exp(-0.5 * ((latDeg - 85) / 8) ** 2);
|
||||
p += 8 * Math.exp(-0.5 * ((latDeg + 85) / 8) ** 2);
|
||||
|
||||
// (e) Land/sea thermal modifier
|
||||
// landFrac here is actually continentality (0 at coast → ~1 deep interior).
|
||||
// Only continental-scale landmasses produce meaningful thermal pressure:
|
||||
// small islands (continentality < 0.2) → 0, ramps to full at 0.5+.
|
||||
const continentalScale = smoothstep(0.2, 0.5, landFrac);
|
||||
if (continentalScale > 0.001) {
|
||||
// Continental thermal effect profile:
|
||||
// 0 at 0-15°, rises to ~0.75 at 30°, plateau ~1.0 at 45-60°, falls to ~0.5 at 75°, 0 at 90°
|
||||
const absLatDeg = Math.abs(lat) * RAD;
|
||||
const latFactor = absLatDeg < 15 ? 0
|
||||
: absLatDeg < 30 ? 0.75 * smoothstep(15, 30, absLatDeg)
|
||||
: absLatDeg < 45 ? 0.75 + 0.25 * smoothstep(30, 45, absLatDeg)
|
||||
: absLatDeg < 60 ? 1
|
||||
: absLatDeg < 90 ? smoothstep(90, 60, absLatDeg)
|
||||
: 0;
|
||||
const isSummerHemisphere = (seasonSign > 0 && lat > 0) || (seasonSign < 0 && lat < 0);
|
||||
if (isSummerHemisphere) {
|
||||
// Thermal low over hot continent
|
||||
p -= 10 * latFactor * continentalScale;
|
||||
} else {
|
||||
// Thermal high over cold continent (stronger — Siberian/Canadian highs)
|
||||
p += 14 * latFactor * continentalScale;
|
||||
}
|
||||
}
|
||||
|
||||
// (f) Elevation (barometric) — mild effect; real weather maps use
|
||||
// sea-level-reduced pressure so elevation doesn't dominate zonal bands
|
||||
p -= 3 * elevToHeightKm(Math.max(0, elevation));
|
||||
|
||||
// (g) Noise perturbation
|
||||
if (noiseFn) {
|
||||
p += noiseFn.fbm(px * 2, py * 2, pz * 2, 3) * 2;
|
||||
}
|
||||
|
||||
return p;
|
||||
}
|
||||
|
||||
|
||||
// ── Pressure gradient on mesh ────────────────────────────────────────────────
|
||||
|
||||
export function computeGradients(mesh, r_xyz, r_pressure,
|
||||
r_eastX, r_eastY, r_eastZ, r_northX, r_northY, r_northZ,
|
||||
r_gradE, r_gradN) {
|
||||
const { adjOffset, adjList, numRegions } = mesh;
|
||||
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
const px = r_xyz[3 * r], py = r_xyz[3 * r + 1], pz = r_xyz[3 * r + 2];
|
||||
const ex = r_eastX[r], ey = r_eastY[r], ez = r_eastZ[r];
|
||||
const nx = r_northX[r], ny = r_northY[r], nz = r_northZ[r];
|
||||
const pHere = r_pressure[r];
|
||||
|
||||
let sumEP = 0, sumEE = 0, sumNP = 0, sumNN = 0;
|
||||
const end = adjOffset[r + 1];
|
||||
|
||||
for (let ni = adjOffset[r]; ni < end; ni++) {
|
||||
const nb = adjList[ni];
|
||||
const dx = r_xyz[3 * nb] - px;
|
||||
const dy = r_xyz[3 * nb + 1] - py;
|
||||
const dz = r_xyz[3 * nb + 2] - pz;
|
||||
|
||||
const de = dx * ex + dy * ey + dz * ez;
|
||||
const dn = dx * nx + dy * ny + dz * nz;
|
||||
const dp = r_pressure[nb] - pHere;
|
||||
|
||||
sumEP += de * dp;
|
||||
sumEE += de * de;
|
||||
sumNP += dn * dp;
|
||||
sumNN += dn * dn;
|
||||
}
|
||||
|
||||
r_gradE[r] = sumEE > 1e-12 ? sumEP / sumEE : 0;
|
||||
r_gradN[r] = sumNN > 1e-12 ? sumNP / sumNN : 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pressure gradient → wind ─────────────────────────────────────────────────
|
||||
|
||||
function pressureToWind(r_gradE, r_gradN, r_sinLat,
|
||||
r_windE, r_windN, r_windSpeed, numRegions) {
|
||||
const sin5 = Math.sin(5 * DEG);
|
||||
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
// PGF: from high to low = negative gradient
|
||||
const pgfE = -r_gradE[r];
|
||||
const pgfN = -r_gradN[r];
|
||||
|
||||
const sinLat = r_sinLat[r];
|
||||
const absSinLat = Math.abs(sinLat);
|
||||
|
||||
// Geostrophic deflection: 0° at equator → 70° at ≥5° latitude
|
||||
const geoAngle = 70 * DEG * smoothstep(0, sin5, absSinLat);
|
||||
|
||||
// Surface friction turns wind 20° back toward low pressure
|
||||
const frictionAngle = 20 * DEG;
|
||||
|
||||
// Net rotation: NH = clockwise (negative), SH = counterclockwise (positive)
|
||||
// The rotation matrix [cosθ,-sinθ; sinθ,cosθ] is counterclockwise for +θ,
|
||||
// so NH right-deflection needs negative angle, SH left-deflection needs positive.
|
||||
const sign = sinLat >= 0 ? -1 : 1;
|
||||
const totalAngle = sign * (geoAngle - frictionAngle);
|
||||
|
||||
const cosA = Math.cos(totalAngle);
|
||||
const sinA = Math.sin(totalAngle);
|
||||
|
||||
// Rotate PGF vector and apply friction speed reduction
|
||||
const we = (pgfE * cosA - pgfN * sinA) * 0.6;
|
||||
const wn = (pgfE * sinA + pgfN * cosA) * 0.6;
|
||||
|
||||
r_windE[r] = we;
|
||||
r_windN[r] = wn;
|
||||
r_windSpeed[r] = Math.sqrt(we * we + wn * wn);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main entry point ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute seasonal pressure fields and wind vectors.
|
||||
*
|
||||
* @param {SphereMesh} mesh
|
||||
* @param {Float32Array} r_xyz - per-region 3D positions (3 * numRegions)
|
||||
* @param {Float32Array} r_elevation - per-region elevation
|
||||
* @param {Set} plateIsOcean - ocean plate seed set
|
||||
* @param {Int32Array} r_plate - per-region plate ID
|
||||
* @param {SimplexNoise} noise - seeded noise instance
|
||||
* @param {number} [axialTilt=23.5] - axial tilt in degrees
|
||||
* @returns {object} pressure and wind arrays for both seasons
|
||||
*/
|
||||
export function computeWind(mesh, r_xyz, r_elevation, plateIsOcean, r_plate, noise, axialTilt = 23.5) {
|
||||
const numRegions = mesh.numRegions;
|
||||
const avgEdgeKm = (Math.PI * 6371) / Math.sqrt(numRegions);
|
||||
const tiltRad = axialTilt * DEG;
|
||||
const timing = [];
|
||||
|
||||
// ── Step 0: Precompute per-region properties ──
|
||||
|
||||
let t0 = performance.now();
|
||||
|
||||
const r_lat = new Float32Array(numRegions);
|
||||
const r_lon = new Float32Array(numRegions);
|
||||
const r_sinLat = new Float32Array(numRegions);
|
||||
const r_cosLat = new Float32Array(numRegions);
|
||||
const r_isLand = new Uint8Array(numRegions);
|
||||
|
||||
// Tangent frame arrays
|
||||
const r_eastX = new Float32Array(numRegions);
|
||||
const r_eastY = new Float32Array(numRegions);
|
||||
const r_eastZ = new Float32Array(numRegions);
|
||||
const r_northX = new Float32Array(numRegions);
|
||||
const r_northY = new Float32Array(numRegions);
|
||||
const r_northZ = new Float32Array(numRegions);
|
||||
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
const x = r_xyz[3 * r], y = r_xyz[3 * r + 1], z = r_xyz[3 * r + 2];
|
||||
|
||||
// Y-up convention (matches map projection)
|
||||
r_lat[r] = Math.asin(Math.max(-1, Math.min(1, y)));
|
||||
r_lon[r] = Math.atan2(x, z);
|
||||
r_sinLat[r] = y;
|
||||
r_cosLat[r] = Math.sqrt(1 - y * y) || 0.01;
|
||||
r_isLand[r] = r_elevation[r] > 0 ? 1 : 0;
|
||||
|
||||
// East = normalize(Ŷ × P) = normalize(z, 0, -x)
|
||||
let ex = z, ey = 0, ez = -x;
|
||||
let elen = Math.sqrt(ex * ex + ez * ez);
|
||||
if (elen < 1e-10) { ex = 1; ez = 0; elen = 1; } // pole fallback
|
||||
ex /= elen; ez /= elen;
|
||||
|
||||
// North = P × East
|
||||
let nx = y * ez - z * ey;
|
||||
let ny = z * ex - x * ez;
|
||||
let nz = x * ey - y * ex;
|
||||
const nlen = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1;
|
||||
nx /= nlen; ny /= nlen; nz /= nlen;
|
||||
|
||||
r_eastX[r] = ex; r_eastY[r] = ey; r_eastZ[r] = ez;
|
||||
r_northX[r] = nx; r_northY[r] = ny; r_northZ[r] = nz;
|
||||
}
|
||||
|
||||
timing.push({ stage: 'Wind: precompute lat/lon/tangent', ms: performance.now() - t0 });
|
||||
|
||||
// ── Step 1: Build geographic index + compute ITCZ ──
|
||||
|
||||
t0 = performance.now();
|
||||
const geoSample = buildGeoIndex(r_lat, r_lon, r_sinLat, r_cosLat, r_elevation, r_isLand, numRegions);
|
||||
const itczSummer = computeITCZ(geoSample, 'summer', tiltRad);
|
||||
const itczWinter = computeITCZ(geoSample, 'winter', tiltRad);
|
||||
timing.push({ stage: 'Wind: ITCZ computation', ms: performance.now() - t0 });
|
||||
|
||||
// ── Step 2–5: Compute pressure & wind for each season ──
|
||||
|
||||
const seasons = [
|
||||
{ name: 'summer', itcz: itczSummer },
|
||||
{ name: 'winter', itcz: itczWinter }
|
||||
];
|
||||
|
||||
const result = {};
|
||||
|
||||
// Precompute continentality via BFS coast distance.
|
||||
// Laplacian smoothing of binary r_isLand converges too fast — interior
|
||||
// cells hit 0.95+ within a few hundred km. Instead, compute actual
|
||||
// hop distance from coast through land, convert to km, and map with
|
||||
// smoothstep for a wide, tunable gradient.
|
||||
// 0 km (coast): cont ≈ 0.0
|
||||
// 500 km: cont ≈ 0.16
|
||||
// 1000 km: cont ≈ 0.50
|
||||
// 1500 km: cont ≈ 0.84
|
||||
// 2000 km+: cont ≈ 1.0
|
||||
// Ocean cells near coast get a small value (~0.05–0.15) via a few
|
||||
// smoothing passes, giving a natural land/sea thermal gradient.
|
||||
t0 = performance.now();
|
||||
const { adjOffset, adjList } = mesh;
|
||||
|
||||
// Find the main ocean: largest connected component of non-land cells.
|
||||
// Inland seas / small lakes don't count as "ocean" for continentality.
|
||||
const r_oceanLabel = new Int32Array(numRegions);
|
||||
r_oceanLabel.fill(-1);
|
||||
let mainOceanLabel = -1, mainOceanSize = 0;
|
||||
let nextLabel = 0;
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
if (r_isLand[r] || r_oceanLabel[r] >= 0) continue;
|
||||
const label = nextLabel++;
|
||||
let size = 0;
|
||||
const floodQueue = [r];
|
||||
r_oceanLabel[r] = label;
|
||||
let fHead = 0;
|
||||
while (fHead < floodQueue.length) {
|
||||
const cur = floodQueue[fHead++];
|
||||
size++;
|
||||
const end = adjOffset[cur + 1];
|
||||
for (let ni = adjOffset[cur]; ni < end; ni++) {
|
||||
const nb = adjList[ni];
|
||||
if (!r_isLand[nb] && r_oceanLabel[nb] === -1) {
|
||||
r_oceanLabel[nb] = label;
|
||||
floodQueue.push(nb);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (size > mainOceanSize) {
|
||||
mainOceanSize = size;
|
||||
mainOceanLabel = label;
|
||||
}
|
||||
}
|
||||
|
||||
// BFS coast distance through land, seeded only from main-ocean coastline
|
||||
const r_coastDist = new Int32Array(numRegions);
|
||||
r_coastDist.fill(-1);
|
||||
const bfsQueue = [];
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
if (!r_isLand[r]) continue;
|
||||
const end = adjOffset[r + 1];
|
||||
for (let ni = adjOffset[r]; ni < end; ni++) {
|
||||
const nb = adjList[ni];
|
||||
if (!r_isLand[nb] && r_oceanLabel[nb] === mainOceanLabel) {
|
||||
r_coastDist[r] = 0;
|
||||
bfsQueue.push(r);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let head = 0;
|
||||
while (head < bfsQueue.length) {
|
||||
const r = bfsQueue[head++];
|
||||
const d = r_coastDist[r] + 1;
|
||||
const end = adjOffset[r + 1];
|
||||
for (let ni = adjOffset[r]; ni < end; ni++) {
|
||||
const nb = adjList[ni];
|
||||
if (r_isLand[nb] && r_coastDist[nb] === -1) {
|
||||
r_coastDist[nb] = d;
|
||||
bfsQueue.push(nb);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Map BFS distance to continentality [0, 1]
|
||||
const CONT_RANGE_KM = 2000; // distance at which cont reaches ~1.0
|
||||
const r_continentality = new Float32Array(numRegions);
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
if (r_isLand[r] && r_coastDist[r] >= 0) {
|
||||
const distKm = r_coastDist[r] * avgEdgeKm;
|
||||
r_continentality[r] = smoothstep(0, CONT_RANGE_KM, distKm);
|
||||
}
|
||||
// Ocean cells stay at 0; a few smooth passes below will bleed
|
||||
// small values onto nearshore ocean for thermal gradient.
|
||||
}
|
||||
// Light smoothing (~100 km) to soften BFS stepping artifacts and
|
||||
// bleed a small thermal signal onto nearshore ocean cells.
|
||||
const contSmoothPasses = Math.max(1, Math.round(100 / avgEdgeKm));
|
||||
smoothField(mesh, r_continentality, contSmoothPasses);
|
||||
|
||||
// Plate-based continentality: uses plate type (continental vs oceanic)
|
||||
// instead of actual land/ocean. Same BFS approach for wide gradient.
|
||||
const r_plateContinentality = new Float32Array(numRegions);
|
||||
// BFS through continental-plate cells
|
||||
const r_plateDist = new Int32Array(numRegions);
|
||||
r_plateDist.fill(-1);
|
||||
const plateBfsQueue = [];
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
if (plateIsOcean.has(r_plate[r])) continue; // skip oceanic plate cells
|
||||
const end = adjOffset[r + 1];
|
||||
for (let ni = adjOffset[r]; ni < end; ni++) {
|
||||
if (plateIsOcean.has(r_plate[adjList[ni]])) {
|
||||
r_plateDist[r] = 0;
|
||||
plateBfsQueue.push(r);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
head = 0;
|
||||
while (head < plateBfsQueue.length) {
|
||||
const r = plateBfsQueue[head++];
|
||||
const d = r_plateDist[r] + 1;
|
||||
const end = adjOffset[r + 1];
|
||||
for (let ni = adjOffset[r]; ni < end; ni++) {
|
||||
const nb = adjList[ni];
|
||||
if (!plateIsOcean.has(r_plate[nb]) && r_plateDist[nb] === -1) {
|
||||
r_plateDist[nb] = d;
|
||||
plateBfsQueue.push(nb);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
if (!plateIsOcean.has(r_plate[r]) && r_plateDist[r] >= 0) {
|
||||
const distKm = r_plateDist[r] * avgEdgeKm;
|
||||
r_plateContinentality[r] = smoothstep(0, CONT_RANGE_KM, distKm);
|
||||
}
|
||||
}
|
||||
smoothField(mesh, r_plateContinentality, contSmoothPasses);
|
||||
timing.push({ stage: 'Wind: continentality BFS', ms: performance.now() - t0 });
|
||||
|
||||
// Shared gradient scratch arrays
|
||||
const r_gradE = new Float32Array(numRegions);
|
||||
const r_gradN = new Float32Array(numRegions);
|
||||
|
||||
// Smooth pressure field ~75 km (scale-invariant) — constant across seasons
|
||||
const pressSmoothPasses = Math.max(1, Math.round(75 / avgEdgeKm));
|
||||
|
||||
for (const { name, itcz } of seasons) {
|
||||
// Step 2: Pressure field
|
||||
t0 = performance.now();
|
||||
const r_pressure = new Float32Array(numRegions);
|
||||
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
r_pressure[r] = regionPressure(
|
||||
r_lat[r], r_lon[r], itcz.spline, name,
|
||||
r_continentality[r], r_elevation[r], noise,
|
||||
r_xyz[3 * r], r_xyz[3 * r + 1], r_xyz[3 * r + 2]
|
||||
);
|
||||
}
|
||||
smoothField(mesh, r_pressure, pressSmoothPasses);
|
||||
timing.push({ stage: `Wind: pressure field (${name})`, ms: performance.now() - t0 });
|
||||
|
||||
// Step 3: Gradient
|
||||
t0 = performance.now();
|
||||
r_gradE.fill(0);
|
||||
r_gradN.fill(0);
|
||||
computeGradients(mesh, r_xyz, r_pressure,
|
||||
r_eastX, r_eastY, r_eastZ, r_northX, r_northY, r_northZ,
|
||||
r_gradE, r_gradN);
|
||||
timing.push({ stage: `Wind: gradient (${name})`, ms: performance.now() - t0 });
|
||||
|
||||
// Step 4: Wind
|
||||
t0 = performance.now();
|
||||
const r_windE = new Float32Array(numRegions);
|
||||
const r_windN = new Float32Array(numRegions);
|
||||
const r_windSpeed = new Float32Array(numRegions);
|
||||
pressureToWind(r_gradE, r_gradN, r_sinLat,
|
||||
r_windE, r_windN, r_windSpeed, numRegions);
|
||||
|
||||
// Step 5: Normalize wind speed to 0-1
|
||||
const maxSpeed = percentile(r_windSpeed, 0.95);
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
r_windSpeed[r] = Math.min(1, r_windSpeed[r] / maxSpeed);
|
||||
}
|
||||
timing.push({ stage: `Wind: pressure→wind (${name})`, ms: performance.now() - t0 });
|
||||
|
||||
// Store pressure as deviation from 1013 for visualization (blue=low, red=high)
|
||||
const r_pressureDev = new Float32Array(numRegions);
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
r_pressureDev[r] = r_pressure[r] - 1013;
|
||||
}
|
||||
|
||||
const S = name === 'summer' ? 'Summer' : 'Winter';
|
||||
result[`r_pressure_${name}`] = r_pressureDev;
|
||||
result[`r_wind_east_${name}`] = r_windE;
|
||||
result[`r_wind_north_${name}`] = r_windN;
|
||||
result[`r_wind_speed_${name}`] = r_windSpeed;
|
||||
}
|
||||
|
||||
// Pre-evaluate ITCZ splines at 360 longitude points for visualization
|
||||
const ITCZ_SAMPLES = 360;
|
||||
const itczLons = new Float32Array(ITCZ_SAMPLES);
|
||||
const itczLatsSummer = new Float32Array(ITCZ_SAMPLES);
|
||||
const itczLatsWinter = new Float32Array(ITCZ_SAMPLES);
|
||||
for (let i = 0; i < ITCZ_SAMPLES; i++) {
|
||||
const lon = -Math.PI + (i + 0.5) * (2 * Math.PI / ITCZ_SAMPLES);
|
||||
itczLons[i] = lon;
|
||||
itczLatsSummer[i] = evaluateSpline(itczSummer.spline, lon);
|
||||
itczLatsWinter[i] = evaluateSpline(itczWinter.spline, lon);
|
||||
}
|
||||
result.itczLons = itczLons;
|
||||
result.itczLatsSummer = itczLatsSummer;
|
||||
result.itczLatsWinter = itczLatsWinter;
|
||||
|
||||
// Expose precomputed geographic data for downstream modules (ocean.js)
|
||||
result.r_lat = r_lat;
|
||||
result.r_lon = r_lon;
|
||||
result.r_sinLat = r_sinLat;
|
||||
result.r_isLand = r_isLand;
|
||||
result.r_continentality = r_continentality;
|
||||
result.r_coastDistLand = r_coastDist;
|
||||
result.r_plateContinentality = r_plateContinentality;
|
||||
result.r_eastX = r_eastX;
|
||||
result.r_eastY = r_eastY;
|
||||
result.r_eastZ = r_eastZ;
|
||||
result.r_northX = r_northX;
|
||||
result.r_northY = r_northY;
|
||||
result.r_northZ = r_northZ;
|
||||
|
||||
result._windTiming = timing;
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user