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