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
+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;
}