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

2410 lines
105 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Planet mesh construction: Voronoi geometry, map projection, overlays.
import * as THREE from 'three';
import { renderer, scene, waterMesh, atmosMesh, starsMesh } from './scene.js';
import { state } from './state.js';
import { elevationToColor, elevToHeightKm, biomeColor } from './color-map.js';
import { makeRng } from './rng.js';
import { KOPPEN_CLASSES } from './koppen.js';
import { PAINTED_LAYERS, PAINTED_EXPORT_TYPES, preparePaintedLayer } from './painted-layers.js';
import { updateOverlayMeshes, compositeOverlaySheet } from './painted-overlay-view.js';
import { encodeGray16 } from './png-write.js';
// Clipping planes for map wrap — keep everything within x ∈ [-2, 2]
renderer.localClippingEnabled = true;
const MAP_CLIP_PLANES = [
new THREE.Plane(new THREE.Vector3(1, 0, 0), 2), // x >= -2
new THREE.Plane(new THREE.Vector3(-1, 0, 0), 2), // x <= 2
];
// Precompute smoothed biome colors: each region blends with its neighbors' average.
// Uses mesh adjacency (~6 neighbors per region) so it's inherently scale-independent.
// Cached on state to avoid redundant computation across render paths.
let _biomeCache = null;
let _biomeCacheKey = null;
function getCachedBiomeSmoothed(mesh, koppenArr, r_elevation) {
if (_biomeCache && _biomeCacheKey === koppenArr) return _biomeCache;
_biomeCache = smoothBiomeColors(mesh, koppenArr, r_elevation);
_biomeCacheKey = koppenArr;
return _biomeCache;
}
function smoothBiomeColors(mesh, koppenArr, r_elevation) {
const n = mesh.numRegions;
const raw = new Float32Array(n * 3);
for (let r = 0; r < n; r++) {
const [cr, cg, cb] = biomeColor(koppenArr[r], r_elevation[r]);
raw[r * 3] = cr; raw[r * 3 + 1] = cg; raw[r * 3 + 2] = cb;
}
const out = new Float32Array(n * 3);
const alpha = 0.35;
const { adjOffset, adjList } = mesh;
for (let r = 0; r < n; r++) {
const start = adjOffset[r];
const end = adjOffset[r + 1];
const count = end - start;
if (count === 0) {
out[r * 3] = raw[r * 3]; out[r * 3 + 1] = raw[r * 3 + 1]; out[r * 3 + 2] = raw[r * 3 + 2];
continue;
}
let avgR = 0, avgG = 0, avgB = 0;
for (let i = start; i < end; i++) {
const nr = adjList[i];
avgR += raw[nr * 3]; avgG += raw[nr * 3 + 1]; avgB += raw[nr * 3 + 2];
}
avgR /= count; avgG /= count; avgB /= count;
out[r * 3] = raw[r * 3] * (1 - alpha) + avgR * alpha;
out[r * 3 + 1] = raw[r * 3 + 1] * (1 - alpha) + avgG * alpha;
out[r * 3 + 2] = raw[r * 3 + 2] * (1 - alpha) + avgB * alpha;
}
return out;
}
// Grayscale heightmap: black (lowest) → white (highest), in physical height space
// Absolute-scale heightmap: fixed range -5 km (deep ocean) → 6 km (tallest peak)
// so the same physical height always maps to the same shade regardless of planet.
function heightmapColor(elevation) {
const h = elevToHeightKm(elevation);
const t = Math.max(0, Math.min(1, (h + 5) / 11)); // -5 → 0, 6 → 1
return [t, t, t];
}
// Land heightmap: ocean = black, land = 0 → 6 km absolute scale
function landHeightmapColor(elevation) {
if (elevation <= 0) return [0, 0, 0];
const t = Math.max(0, Math.min(1, elevToHeightKm(elevation) / 6));
return [t, t, t];
}
// Land mask: white = land, black = ocean
function landMaskColor(elevation) {
return elevation > 0 ? [1, 1, 1] : [0, 0, 0];
}
// The 16-bit greyscale PNG encoder moved to png-write.js when the Unreal landscape export needed an
// 8-bit one beside it; this alias keeps the name the export paths below already use.
const encode16BitGrayscalePNG = encodeGray16;
// ────────────────────────────────────────────────────────────────────
// Diverging color map: blue (negative) → white (zero) → red (positive)
function debugValueToColor(v, minV, maxV) {
const range = Math.max(Math.abs(minV), Math.abs(maxV)) || 1;
const t = Math.max(-1, Math.min(1, v / range)); // normalise to [-1, 1]
if (t < 0) {
const s = -t; // 0→1
return [1 - s * 0.7, 1 - s * 0.7, 1]; // white → blue
} else {
const s = t; // 0→1
return [1, 1 - s * 0.75, 1 - s * 0.75]; // white → red
}
}
// Precipitation debug color: brown (dry) → green (moderate) → blue (wet)
function precipitationColor(value) {
// value is 0–1 (p95-normalized)
const t = Math.max(0, Math.min(1, value));
if (t < 0.25) {
// Very dry: tan/brown
const s = t / 0.25;
return [0.76 - s * 0.16, 0.60 - s * 0.05, 0.42 - s * 0.12];
} else if (t < 0.5) {
// Dry to moderate: brown → green
const s = (t - 0.25) / 0.25;
return [0.60 - s * 0.30, 0.55 + s * 0.20, 0.30 - s * 0.05];
} else if (t < 0.75) {
// Moderate to wet: green → teal
const s = (t - 0.5) / 0.25;
return [0.30 - s * 0.15, 0.75 - s * 0.10, 0.25 + s * 0.40];
} else {
// Wet to very wet: teal → deep blue
const s = (t - 0.75) / 0.25;
return [0.15 - s * 0.05, 0.65 - s * 0.35, 0.65 + s * 0.20];
}
}
// Rain shadow diverging color: blue (windward boost) ↔ neutral gray ↔ red-brown (leeward shadow)
// Input is signed: positive = windward, negative = leeward shadow (propagated downwind)
function rainShadowColor(value) {
if (value > 0.01) {
// Windward: gray → blue (saturates at 0.5)
const t = Math.min(1, value / 0.5);
return [0.55 - t * 0.40, 0.55 - t * 0.10, 0.58 + t * 0.37];
} else if (value < -0.01) {
// Leeward shadow: gray → red-brown (saturates at -0.5)
const t = Math.min(1, -value / 0.5);
return [0.55 + t * 0.35, 0.55 - t * 0.35, 0.58 - t * 0.45];
}
return [0.55, 0.55, 0.58]; // neutral gray (ocean / flat)
}
// Continentality debug color: ocean (blue) → coast (green) → interior (orange/red)
// Input is 0–1: 0 = open ocean, ~0.3-0.5 = coast, 0.95+ = deep interior.
function continentalityColor(value) {
const t = Math.max(0, Math.min(1, value));
if (t < 0.15) {
// Ocean: dark blue → lighter blue
const s = t / 0.15;
return [0.05 + s * 0.10, 0.10 + s * 0.20, 0.40 + s * 0.20];
} else if (t < 0.4) {
// Coastal: blue → green
const s = (t - 0.15) / 0.25;
return [0.15 - s * 0.05, 0.30 + s * 0.45, 0.60 - s * 0.35];
} else if (t < 0.7) {
// Moderate interior: green → yellow
const s = (t - 0.4) / 0.3;
return [0.10 + s * 0.80, 0.75 - s * 0.05, 0.25 - s * 0.15];
} else if (t < 0.9) {
// Deep interior: yellow → orange
const s = (t - 0.7) / 0.2;
return [0.90 + s * 0.05, 0.70 - s * 0.40, 0.10 - s * 0.05];
} else {
// Super-continent core: orange → dark red
const s = (t - 0.9) / 0.1;
return [0.95 - s * 0.25, 0.30 - s * 0.20, 0.05];
}
}
// Temperature debug color: discrete bands matching real climate map style.
// Input is 0-1 normalized from -45 to +45 C. Convert back to C for thresholds.
function temperatureColor(value) {
const T = -45 + Math.max(0, Math.min(1, value)) * 90;
if (T < -38) return [0.78, 0.78, 0.78]; // White-gray
if (T < 0) return [0.00, 0.00, 0.50]; // Dark blue
if (T < 10) return [0.53, 0.81, 0.92]; // Light blue
if (T < 18) return [1.00, 1.00, 0.00]; // Yellow
if (T < 22) return [1.00, 0.65, 0.00]; // Orange
if (T < 32) return [1.00, 0.00, 0.00]; // Red
if (T < 40) return [0.55, 0.00, 0.00]; // Dark red
return [0.20, 0.00, 0.00]; // Darker red
}
// Köppen climate class color: returns [r,g,b] from KOPPEN_CLASSES lookup.
function koppenColor(classId) {
const c = KOPPEN_CLASSES[classId] || KOPPEN_CLASSES[0];
return c.color;
}
// Plate colours — green shades for land, blue for ocean.
export function computePlateColors(plateSeeds, plateIsOcean) {
state.plateColors = {};
for (const r of plateSeeds) {
const rng = makeRng(r);
if (plateIsOcean.has(r)) {
const h = 0.55 + rng() * 0.10;
const s = 0.40 + rng() * 0.30;
const l = 0.35 + rng() * 0.20;
state.plateColors[r] = new THREE.Color().setHSL(h, s, l);
} else {
const h = 0.25 + rng() * 0.15;
const s = 0.30 + rng() * 0.30;
const l = 0.30 + rng() * 0.20;
state.plateColors[r] = new THREE.Color().setHSL(h, s, l);
}
}
}
// Build equirectangular map mesh.
export function buildMapMesh() {
if (state.mapMesh) { scene.remove(state.mapMesh); state.mapMesh.geometry.dispose(); state.mapMesh.material.dispose(); state.mapMesh = null; }
if (!state.curData || !state.mapMode) return;
const { mesh, r_xyz, t_xyz, r_plate, r_elevation, t_elevation, mountain_r, coastline_r, ocean_r, r_stress, debugLayers } = state.curData;
const showPlates = document.getElementById('chkPlates').checked;
const showStress = false;
const waterLevel = 0;
const debugLayer = state.debugLayer || '';
let dbgArr = null, dbgMin = 0, dbgMax = 0;
const isHeightmap = debugLayer === 'heightmap';
const isLandHeightmap = debugLayer === 'landheightmap';
const isOceanCurrent = debugLayer === 'oceanCurrentSummer' || debugLayer === 'oceanCurrentWinter';
const oceanSeason = debugLayer === 'oceanCurrentWinter' ? 'winter' : 'summer';
const oceanWarmth = isOceanCurrent ? state.curData[`r_ocean_warmth_${oceanSeason}`] : null;
const oceanSpeed = isOceanCurrent ? state.curData[`r_ocean_speed_${oceanSeason}`] : null;
if (isOceanCurrent && (!oceanWarmth || !oceanSpeed)) {
console.warn(`[buildMapMesh] Ocean current layer "${debugLayer}" selected but data missing (warmth=${!!oceanWarmth}, speed=${!!oceanSpeed}). Hard-refresh (Ctrl+Shift+R) and generate a new planet.`);
}
const isPrecip = debugLayer === 'precipSummer' || debugLayer === 'precipWinter';
const precipArr = isPrecip ? (debugLayers && debugLayers[debugLayer]) : null;
const isRainShadow = debugLayer === 'rainShadowSummer' || debugLayer === 'rainShadowWinter';
const rainShadowArr = isRainShadow ? (debugLayers && debugLayers[debugLayer]) : null;
const isTemp = debugLayer === 'tempSummer' || debugLayer === 'tempWinter';
const tempArr = isTemp ? (debugLayers && debugLayers[debugLayer]) : null;
const isKoppen = debugLayer === 'koppen';
const isBiome = debugLayer === 'biome';
const koppenArr = (isKoppen || isBiome) ? (debugLayers && debugLayers.koppen) : null;
const isCont = debugLayer === 'continentality';
const contArr = isCont ? (debugLayers && debugLayers.continentality) : null;
const isPainted = PAINTED_LAYERS.has(debugLayer);
const paintedCtx = isPainted ? preparePaintedLayer(debugLayer, debugLayers && debugLayers[debugLayer], state.curData) : null;
if (!isHeightmap && !isLandHeightmap && !isOceanCurrent && !isPrecip && !isRainShadow && !isTemp && !isKoppen && !isBiome && !isCont && !isPainted && debugLayer && debugLayers && debugLayers[debugLayer]) {
dbgArr = debugLayers[debugLayer];
for (let r = 0; r < mesh.numRegions; r++) {
if (dbgArr[r] < dbgMin) dbgMin = dbgArr[r];
if (dbgArr[r] > dbgMax) dbgMax = dbgArr[r];
}
}
const { numSides } = mesh;
const PI = Math.PI;
const centerLon = state.mapCenterLon || 0;
// Offset longitude by center meridian and wrap to [-PI, PI]
function wrapLon(lon) {
let l = lon - centerLon;
if (l > PI) l -= 2 * PI;
else if (l < -PI) l += 2 * PI;
return l;
}
const biomeSmoothed = (isBiome && koppenArr) ? getCachedBiomeSmoothed(mesh, koppenArr, r_elevation) : null;
const isSmooth = isHeightmap || isLandHeightmap;
// Upper-bound allocation: wrapping sides produce 2 triangles, non-wrapping 1.
// Wraps are rare, so 2× is a conservative upper bound; trimmed after the loop.
const posArr = new Float32Array(numSides * 2 * 9);
const colArr = new Float32Array(numSides * 2 * 9);
const faceToSide = new Int32Array(numSides * 2);
let triCount = 0;
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 re = r_elevation[br] - waterLevel;
// Per-vertex colors for smooth heightmaps, flat for everything else
let c0r, c0g, c0b, c1r, c1g, c1b, c2r, c2g, c2b;
if (isSmooth) {
const colorFn = isLandHeightmap ? landHeightmapColor : heightmapColor;
const v0 = colorFn(t_elevation[it])[0];
const v1 = colorFn(t_elevation[ot])[0];
const v2 = colorFn(r_elevation[br])[0];
c0r = c0g = c0b = v0;
c1r = c1g = c1b = v1;
c2r = c2g = c2b = v2;
} else {
let cr, cg, cb;
if (paintedCtx) {
[cr, cg, cb] = paintedCtx.color(br);
} else if (isBiome && biomeSmoothed) {
cr = biomeSmoothed[br * 3]; cg = biomeSmoothed[br * 3 + 1]; cb = biomeSmoothed[br * 3 + 2];
} else if (isCont && contArr) {
[cr, cg, cb] = continentalityColor(contArr[br]);
} else if (isKoppen && koppenArr) {
[cr, cg, cb] = koppenColor(koppenArr[br]);
} else if (isTemp && tempArr) {
[cr, cg, cb] = temperatureColor(tempArr[br]);
} else if (isPrecip && precipArr) {
[cr, cg, cb] = precipitationColor(precipArr[br]);
} else if (isRainShadow && rainShadowArr) {
[cr, cg, cb] = rainShadowColor(rainShadowArr[br]);
} else if (isOceanCurrent && oceanWarmth && oceanSpeed) {
[cr, cg, cb] = oceanCurrentColor(oceanWarmth[br], oceanSpeed[br], r_elevation[br] <= 0);
} else if (isOceanCurrent) {
cr = 0.5; cg = 0; cb = 0.5;
} else if (dbgArr) {
[cr, cg, cb] = debugValueToColor(dbgArr[br], dbgMin, dbgMax);
} else if (showPlates) {
const pc = state.plateColors[r_plate[br]] || new THREE.Color(0.3,0.3,0.3);
cr = pc.r; cg = pc.g; cb = pc.b;
} else if (showStress) {
const sv = r_stress ? r_stress[br] : 0;
if (sv > 0.5) { cr=0.9; cg=0.1+sv*0.3; cb=0.1; }
else if (sv > 0.1) { cr=0.9; cg=0.5+sv*0.5; cb=0.2; }
else if (mountain_r.has(br)) { cr=0.8; cg=0.4; cb=0.1; }
else if (coastline_r.has(br)) { cr=0.9; cg=0.9; cb=0.2; }
else if (ocean_r.has(br)) { cr=0.1; cg=0.2; cb=0.7; }
else { cr=0.15; cg=0.15; cb=0.18; }
} else {
[cr, cg, cb] = elevationToColor(re);
}
c0r = c1r = c2r = cr;
c0g = c1g = c2g = cg;
c0b = c1b = c2b = cb;
}
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 = wrapLon(Math.atan2(x0, z0)), lat0 = Math.asin(Math.max(-1, Math.min(1, y0)));
let lon1 = wrapLon(Math.atan2(x1, z1)), lat1 = Math.asin(Math.max(-1, Math.min(1, y1)));
let lon2 = wrapLon(Math.atan2(x2, z2)), lat2 = Math.asin(Math.max(-1, Math.min(1, y2)));
const sx = 2 / PI;
const maxLon = Math.max(lon0, lon1, lon2);
const minLon = Math.min(lon0, lon1, lon2);
const wraps = (maxLon - minLon) > PI;
// Clamp projected coords to map bounds
const cx = (v) => Math.max(-2, Math.min(2, v));
const cy = (v) => Math.max(-1, Math.min(1, v));
if (wraps) {
if (lon0 < 0) lon0 += 2 * PI;
if (lon1 < 0) lon1 += 2 * PI;
if (lon2 < 0) lon2 += 2 * PI;
let off = triCount * 9;
posArr[off] = cx(lon0*sx); posArr[off+1] = cy(lat0*sx); posArr[off+2] = 0;
posArr[off+3] = cx(lon1*sx); posArr[off+4] = cy(lat1*sx); posArr[off+5] = 0;
posArr[off+6] = cx(lon2*sx); posArr[off+7] = cy(lat2*sx); posArr[off+8] = 0;
colArr[off]=c0r; colArr[off+1]=c0g; colArr[off+2]=c0b;
colArr[off+3]=c1r; colArr[off+4]=c1g; colArr[off+5]=c1b;
colArr[off+6]=c2r; colArr[off+7]=c2g; colArr[off+8]=c2b;
faceToSide[triCount] = s;
triCount++;
off = triCount * 9;
posArr[off] = cx((lon0-2*PI)*sx); posArr[off+1] = cy(lat0*sx); posArr[off+2] = 0;
posArr[off+3] = cx((lon1-2*PI)*sx); posArr[off+4] = cy(lat1*sx); posArr[off+5] = 0;
posArr[off+6] = cx((lon2-2*PI)*sx); posArr[off+7] = cy(lat2*sx); posArr[off+8] = 0;
colArr[off]=c0r; colArr[off+1]=c0g; colArr[off+2]=c0b;
colArr[off+3]=c1r; colArr[off+4]=c1g; colArr[off+5]=c1b;
colArr[off+6]=c2r; colArr[off+7]=c2g; colArr[off+8]=c2b;
faceToSide[triCount] = s;
triCount++;
} else {
const off = triCount * 9;
posArr[off] = cx(lon0*sx); posArr[off+1] = cy(lat0*sx); posArr[off+2] = 0;
posArr[off+3] = cx(lon1*sx); posArr[off+4] = cy(lat1*sx); posArr[off+5] = 0;
posArr[off+6] = cx(lon2*sx); posArr[off+7] = cy(lat2*sx); posArr[off+8] = 0;
colArr[off]=c0r; colArr[off+1]=c0g; colArr[off+2]=c0b;
colArr[off+3]=c1r; colArr[off+4]=c1g; colArr[off+5]=c1b;
colArr[off+6]=c2r; colArr[off+7]=c2g; colArr[off+8]=c2b;
faceToSide[triCount] = s;
triCount++;
}
}
const finalPos = posArr.subarray(0, triCount * 9);
const finalCol = colArr.subarray(0, triCount * 9);
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(finalPos), 3));
geo.setAttribute('color', new THREE.BufferAttribute(new Float32Array(finalCol), 3));
const mat = new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.DoubleSide, clippingPlanes: MAP_CLIP_PLANES });
state.mapMesh = new THREE.Mesh(geo, mat);
state.mapMesh.visible = state.mapMode;
state.mapMesh._builtCenterLon = state.mapCenterLon || 0;
state.mapFaceToSide = faceToSide.subarray(0, triCount);
state._mapHoverBackup = null;
state._mapKoppenHoverBackup = null;
state._mapPendingBackup = null;
// Wrap clones: children inherit parent visibility + transform
const cloneL = new THREE.Mesh(geo, mat); cloneL.position.x = -4;
const cloneR = new THREE.Mesh(geo, mat); cloneR.position.x = 4;
state.mapMesh.add(cloneL, cloneR);
updateOverlayMeshes();
scene.add(state.mapMesh);
updateSuperPlateBorders();
buildMapGrid();
}
// Build lat/lon grid overlay for map view.
function buildMapGrid() {
if (state.mapGridMesh) {
scene.remove(state.mapGridMesh);
state.mapGridMesh.geometry.dispose();
state.mapGridMesh.material.dispose();
state.mapGridMesh = null;
}
const spacing = state.gridSpacing;
const sx = 2 / Math.PI;
const Z = 0.001;
const PI = Math.PI;
const centerLonDeg = (state.mapCenterLon || 0) * 180 / PI;
const positions = [];
for (let deg = -90; deg <= 90; deg += spacing) {
const y = (deg * Math.PI / 180) * sx;
positions.push(-2, y, Z, 2, y, Z);
}
for (let deg = -180; deg <= 180; deg += spacing) {
let offsetDeg = deg - centerLonDeg;
// Wrap to [-180, 180]
if (offsetDeg > 180) offsetDeg -= 360;
else if (offsetDeg < -180) offsetDeg += 360;
const x = (offsetDeg * Math.PI / 180) * sx;
positions.push(x, -1, Z, x, 1, Z);
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
const gridMat = new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.12, clippingPlanes: MAP_CLIP_PLANES });
state.mapGridMesh = new THREE.LineSegments(geo, gridMat);
state.mapGridMesh.visible = state.mapMode && state.gridEnabled;
// Wrap clones for smooth longitude scrolling
const gCloneL = new THREE.LineSegments(geo, gridMat); gCloneL.position.x = -4;
const gCloneR = new THREE.LineSegments(geo, gridMat); gCloneR.position.x = 4;
state.mapGridMesh.add(gCloneL, gCloneR);
scene.add(state.mapGridMesh);
}
// Build lat/lon grid on the 3D globe.
function buildGlobeGrid() {
if (state.globeGridMesh) {
scene.remove(state.globeGridMesh);
state.globeGridMesh.geometry.dispose();
state.globeGridMesh.material.dispose();
state.globeGridMesh = null;
}
const spacing = state.gridSpacing;
const R = 1.002; // slightly above water sphere
const SEG = 120; // segments per circle
const positions = [];
// Latitude lines
for (let deg = -90; deg <= 90; deg += spacing) {
if (deg === -90 || deg === 90) continue; // poles are points, skip
const lat = deg * Math.PI / 180;
const cosLat = Math.cos(lat);
const y = Math.sin(lat) * R;
for (let i = 0; i < SEG; i++) {
const lon0 = (i / SEG) * Math.PI * 2;
const lon1 = ((i + 1) / SEG) * Math.PI * 2;
positions.push(
Math.sin(lon0) * cosLat * R, y, Math.cos(lon0) * cosLat * R,
Math.sin(lon1) * cosLat * R, y, Math.cos(lon1) * cosLat * R
);
}
}
// Longitude lines (semicircles pole to pole)
for (let deg = -180; deg < 180; deg += spacing) {
const lon = deg * Math.PI / 180;
const sinLon = Math.sin(lon);
const cosLon = Math.cos(lon);
for (let i = 0; i < SEG; i++) {
const lat0 = -Math.PI / 2 + (i / SEG) * Math.PI;
const lat1 = -Math.PI / 2 + ((i + 1) / SEG) * Math.PI;
positions.push(
sinLon * Math.cos(lat0) * R, Math.sin(lat0) * R, cosLon * Math.cos(lat0) * R,
sinLon * Math.cos(lat1) * R, Math.sin(lat1) * R, cosLon * Math.cos(lat1) * R
);
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
const gridMat = new THREE.ShaderMaterial({
uniforms: {
color: { value: new THREE.Color(0xffffff) },
opacity: { value: 0.12 }
},
vertexShader: `
void main() {
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
gl_Position.z -= 0.002 * gl_Position.w; // depth bias: render on top of nearby surfaces
}
`,
fragmentShader: `
uniform vec3 color;
uniform float opacity;
void main() {
gl_FragColor = vec4(color, opacity);
}
`,
transparent: true,
depthWrite: false
});
state.globeGridMesh = new THREE.LineSegments(geo, gridMat);
state.globeGridMesh.visible = !state.mapMode && state.gridEnabled;
scene.add(state.globeGridMesh);
}
// Rebuild both grids (call when spacing changes).
export function rebuildGrids() {
buildMapGrid();
buildGlobeGrid();
}
// Ocean current debug color: warmth × speed, with gray land.
function oceanCurrentColor(warmth, speed, isOcean) {
if (!isOcean) return [0.45, 0.45, 0.45]; // gray land
// speed is 0-1 (p95 normalized); ensure even low-speed areas are clearly visible
const intensity = Math.pow(Math.min(1, speed * 3), 0.6); // gamma curve for more visible low values
// Minimum brightness so all ocean is distinguishable from land and black background
const base = 0.12;
if (warmth > 0.05) {
// Warm (poleward) → dark red-orange to bright red
const w = Math.min(1, warmth * 1.5);
const t = base + (1 - base) * w * intensity;
return [t, base * 0.4 + t * 0.1, base * 0.3];
} else if (warmth < -0.05) {
// Cold (equatorward) → dark blue to bright blue
const w = Math.min(1, -warmth * 1.5);
const t = base + (1 - base) * w * intensity;
return [base * 0.3, base * 0.5 + t * 0.15, t];
} else {
// Neutral (zonal) → dark teal-gray
const t = base + intensity * 0.45;
return [t * 0.55, t * 0.7, t * 0.65];
}
}
// Build / destroy super plate boundary lines for both globe and map views.
// Called from buildMesh, buildMapMesh, and the Show Plates checkbox handler.
export function updateSuperPlateBorders() {
// Cleanup existing
if (state.superPlateBorderMesh) { scene.remove(state.superPlateBorderMesh); state.superPlateBorderMesh.geometry.dispose(); state.superPlateBorderMesh.material.dispose(); state.superPlateBorderMesh = null; }
if (state.mapSuperPlateBorderMesh) { scene.remove(state.mapSuperPlateBorderMesh); state.mapSuperPlateBorderMesh.geometry.dispose(); state.mapSuperPlateBorderMesh.material.dispose(); state.mapSuperPlateBorderMesh = null; }
if (!state.curData) return;
const showPlates = document.getElementById('chkPlates').checked;
if (!showPlates) return;
const { mesh, t_xyz, t_elevation, debugLayers } = state.curData;
if (!debugLayers || !debugLayers.superPlates) return;
const spArr = debugLayers.superPlates;
const { numSides } = mesh;
const PI = Math.PI;
const V = 0.04;
// Globe borders
if (!state.mapMode) {
const bp = [];
for (let s = 0; s < numSides; s++) {
const opp = mesh.halfedges[s];
if (s < opp) {
const r1 = mesh.s_begin_r(s);
const r2 = mesh.s_begin_r(opp);
if (spArr[r1] !== spArr[r2]) {
const it = mesh.s_inner_t(s), ot = mesh.s_outer_t(s);
const ite = t_elevation[it], ote = t_elevation[ot];
const d1 = 1.002 + (ite > 0 ? ite*V : ite*V*0.3);
const d2 = 1.002 + (ote > 0 ? ote*V : ote*V*0.3);
bp.push(
t_xyz[3*it]*d1, t_xyz[3*it+1]*d1, t_xyz[3*it+2]*d1,
t_xyz[3*ot]*d2, t_xyz[3*ot+1]*d2, t_xyz[3*ot+2]*d2
);
}
}
}
if (bp.length > 0) {
const bg = new THREE.BufferGeometry();
bg.setAttribute('position', new THREE.Float32BufferAttribute(bp, 3));
state.superPlateBorderMesh = new THREE.LineSegments(bg,
new THREE.LineBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.55 }));
scene.add(state.superPlateBorderMesh);
}
}
// Map borders
if (state.mapMode) {
const centerLon = state.mapCenterLon || 0;
function wrapLon(lon) {
let l = lon - centerLon;
if (l > PI) l -= 2 * PI;
else if (l < -PI) l += 2 * PI;
return l;
}
const sx = 2 / PI;
const bps = [];
for (let s = 0; s < numSides; s++) {
const opp = mesh.halfedges[s];
if (s < opp) {
const r1 = mesh.s_begin_r(s);
const r2 = mesh.s_begin_r(opp);
if (spArr[r1] !== spArr[r2]) {
const it = mesh.s_inner_t(s), ot = mesh.s_outer_t(s);
const lat1 = Math.asin(Math.max(-1, Math.min(1, t_xyz[3*it+1])));
const lon1 = wrapLon(Math.atan2(t_xyz[3*it], t_xyz[3*it+2]));
const lat2 = Math.asin(Math.max(-1, Math.min(1, t_xyz[3*ot+1])));
const lon2 = wrapLon(Math.atan2(t_xyz[3*ot], t_xyz[3*ot+2]));
if (Math.abs(lon1 - lon2) < PI * 0.5) {
bps.push(lon1 * sx, lat1 * sx, 0.002, lon2 * sx, lat2 * sx, 0.002);
}
}
}
}
if (bps.length > 0) {
const bg = new THREE.BufferGeometry();
bg.setAttribute('position', new THREE.Float32BufferAttribute(bps, 3));
const bMat = new THREE.LineBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.55, clippingPlanes: MAP_CLIP_PLANES });
state.mapSuperPlateBorderMesh = new THREE.LineSegments(bg, bMat);
const bcL = new THREE.LineSegments(bg, bMat); bcL.position.x = -4;
const bcR = new THREE.LineSegments(bg, bMat); bcR.position.x = 4;
state.mapSuperPlateBorderMesh.add(bcL, bcR);
scene.add(state.mapSuperPlateBorderMesh);
}
}
}
// Build Voronoi mesh — each half-edge produces one triangle.
export function buildMesh() {
if (!state.curData) return;
const { mesh, r_xyz, t_xyz, r_plate, r_elevation, t_elevation, mountain_r, coastline_r, ocean_r, r_stress, debugLayers } = state.curData;
const showPlates = document.getElementById('chkPlates').checked;
const showStress = false;
const waterLevel = 0;
const debugLayer = state.debugLayer || '';
// Precompute debug layer min/max if active
let dbgArr = null, dbgMin = 0, dbgMax = 0;
const isHeightmap = debugLayer === 'heightmap';
const isLandHeightmap = debugLayer === 'landheightmap';
const isOceanCurrent = debugLayer === 'oceanCurrentSummer' || debugLayer === 'oceanCurrentWinter';
const oceanSeason = debugLayer === 'oceanCurrentWinter' ? 'winter' : 'summer';
const oceanWarmth = isOceanCurrent ? state.curData[`r_ocean_warmth_${oceanSeason}`] : null;
const oceanSpeed = isOceanCurrent ? state.curData[`r_ocean_speed_${oceanSeason}`] : null;
if (isOceanCurrent && (!oceanWarmth || !oceanSpeed)) {
console.warn(`[buildMesh] Ocean current layer "${debugLayer}" selected but data missing (warmth=${!!oceanWarmth}, speed=${!!oceanSpeed}). Hard-refresh (Ctrl+Shift+R) and generate a new planet.`);
}
const isPrecip = debugLayer === 'precipSummer' || debugLayer === 'precipWinter';
const precipArr = isPrecip ? (debugLayers && debugLayers[debugLayer]) : null;
const isRainShadow = debugLayer === 'rainShadowSummer' || debugLayer === 'rainShadowWinter';
const rainShadowArr = isRainShadow ? (debugLayers && debugLayers[debugLayer]) : null;
const isTemp = debugLayer === 'tempSummer' || debugLayer === 'tempWinter';
const tempArr = isTemp ? (debugLayers && debugLayers[debugLayer]) : null;
const isKoppen = debugLayer === 'koppen';
const isBiome = debugLayer === 'biome';
const koppenArr = (isKoppen || isBiome) ? (debugLayers && debugLayers.koppen) : null;
const isCont = debugLayer === 'continentality';
const contArr = isCont ? (debugLayers && debugLayers.continentality) : null;
const isPainted = PAINTED_LAYERS.has(debugLayer);
const paintedCtx = isPainted ? preparePaintedLayer(debugLayer, debugLayers && debugLayers[debugLayer], state.curData) : null;
if (!isHeightmap && !isLandHeightmap && !isOceanCurrent && !isPrecip && !isRainShadow && !isTemp && !isKoppen && !isBiome && !isCont && !isPainted && debugLayer && debugLayers && debugLayers[debugLayer]) {
dbgArr = debugLayers[debugLayer];
for (let r = 0; r < mesh.numRegions; r++) {
if (dbgArr[r] < dbgMin) dbgMin = dbgArr[r];
if (dbgArr[r] > dbgMax) dbgMax = dbgArr[r];
}
}
if (state.planetMesh) { scene.remove(state.planetMesh); state.planetMesh.geometry.dispose(); state.planetMesh.material.dispose(); }
if (state.wireMesh) { scene.remove(state.wireMesh); state.wireMesh.geometry.dispose(); state.wireMesh.material.dispose(); }
const { numSides } = mesh;
const V = 0.04;
const pos = new Float32Array(numSides * 9);
const col = new Float32Array(numSides * 9);
const isSmooth = isHeightmap || isLandHeightmap;
// Track per-side winding swaps so updateMeshColors can assign per-vertex colors correctly
const sideSwapped = new Uint8Array(numSides);
const biomeSmoothed = (isBiome && koppenArr) ? getCachedBiomeSmoothed(mesh, koppenArr, r_elevation) : null;
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 re = r_elevation[br] - waterLevel;
const ite = t_elevation[it] - waterLevel;
const ote = t_elevation[ot] - waterLevel;
const rDisp = 1.0 + (re > 0 ? re * V : re * V * 0.3);
const itDisp = 1.0 + (ite > 0 ? ite * V : ite * V * 0.3);
const otDisp = 1.0 + (ote > 0 ? ote * V : ote * V * 0.3);
const off = s * 9;
let v0x = t_xyz[3*it] * itDisp,
v0y = t_xyz[3*it+1] * itDisp,
v0z = t_xyz[3*it+2] * itDisp;
let v1x = t_xyz[3*ot] * otDisp,
v1y = t_xyz[3*ot+1] * otDisp,
v1z = t_xyz[3*ot+2] * otDisp;
let v2x = r_xyz[3*br] * rDisp,
v2y = r_xyz[3*br+1] * rDisp,
v2z = r_xyz[3*br+2] * rDisp;
// Fix winding order
const e1x = v1x-v0x, e1y = v1y-v0y, e1z = v1z-v0z;
const e2x = v2x-v0x, e2y = v2y-v0y, e2z = v2z-v0z;
const nx = e1y*e2z - e1z*e2y;
const ny = e1z*e2x - e1x*e2z;
const nz = e1x*e2y - e1y*e2x;
const cnx = (v0x+v1x+v2x)/3, cny = (v0y+v1y+v2y)/3, cnz = (v0z+v1z+v2z)/3;
const swapped = nx*cnx + ny*cny + nz*cnz < 0;
if (swapped) {
let tx, ty, tz;
tx=v1x; ty=v1y; tz=v1z;
v1x=v2x; v1y=v2y; v1z=v2z;
v2x=tx; v2y=ty; v2z=tz;
}
sideSwapped[s] = swapped ? 1 : 0;
pos[off] = v0x; pos[off+1] = v0y; pos[off+2] = v0z;
pos[off+3] = v1x; pos[off+4] = v1y; pos[off+5] = v1z;
pos[off+6] = v2x; pos[off+7] = v2y; pos[off+8] = v2z;
if (isSmooth) {
// Smooth heightmap: per-vertex colors from averaged triangle elevations
const colorFn = isLandHeightmap ? landHeightmapColor : heightmapColor;
const c0 = colorFn(t_elevation[it])[0]; // inner_t (vertex 0, never swapped)
const cOt = colorFn(t_elevation[ot])[0]; // outer_t
const cBr = colorFn(r_elevation[br])[0]; // begin_r
// After winding fix, v1/v2 may have swapped (outer_t ↔ begin_r)
const c1 = swapped ? cBr : cOt;
const c2 = swapped ? cOt : cBr;
col[off] = col[off+1] = col[off+2] = c0;
col[off+3] = col[off+4] = col[off+5] = c1;
col[off+6] = col[off+7] = col[off+8] = c2;
} else {
let cr, cg, cb;
if (paintedCtx) {
[cr, cg, cb] = paintedCtx.color(br);
} else if (isBiome && biomeSmoothed) {
cr = biomeSmoothed[br * 3]; cg = biomeSmoothed[br * 3 + 1]; cb = biomeSmoothed[br * 3 + 2];
} else if (isCont && contArr) {
[cr, cg, cb] = continentalityColor(contArr[br]);
} else if (isKoppen && koppenArr) {
[cr, cg, cb] = koppenColor(koppenArr[br]);
} else if (isTemp && tempArr) {
[cr, cg, cb] = temperatureColor(tempArr[br]);
} else if (isPrecip && precipArr) {
[cr, cg, cb] = precipitationColor(precipArr[br]);
} else if (isRainShadow && rainShadowArr) {
[cr, cg, cb] = rainShadowColor(rainShadowArr[br]);
} else if (isOceanCurrent && oceanWarmth && oceanSpeed) {
[cr, cg, cb] = oceanCurrentColor(oceanWarmth[br], oceanSpeed[br], r_elevation[br] <= 0);
} else if (isOceanCurrent) {
cr = 0.5; cg = 0; cb = 0.5;
} else if (isLandHeightmap) {
[cr, cg, cb] = landHeightmapColor(r_elevation[br]);
} else if (isHeightmap) {
[cr, cg, cb] = heightmapColor(r_elevation[br]);
} else if (dbgArr) {
[cr, cg, cb] = debugValueToColor(dbgArr[br], dbgMin, dbgMax);
} else if (showPlates) {
const pc = state.plateColors[r_plate[br]] || new THREE.Color(0.3,0.3,0.3);
cr = pc.r; cg = pc.g; cb = pc.b;
} else if (showStress) {
const sv = r_stress ? r_stress[br] : 0;
if (sv > 0.5) { cr=0.9; cg=0.1+sv*0.3; cb=0.1; }
else if (sv > 0.1) { cr=0.9; cg=0.5+sv*0.5; cb=0.2; }
else if (mountain_r.has(br)) { cr=0.8; cg=0.4; cb=0.1; }
else if (coastline_r.has(br)) { cr=0.9; cg=0.9; cb=0.2; }
else if (ocean_r.has(br)) { cr=0.1; cg=0.2; cb=0.7; }
else { cr=0.15; cg=0.15; cb=0.18; }
} else {
[cr, cg, cb] = elevationToColor(re);
}
for (let j = 0; j < 3; j++) {
col[off+j*3] = cr;
col[off+j*3+1] = cg;
col[off+j*3+2] = cb;
}
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
geo.setAttribute('color', new THREE.BufferAttribute(col, 3));
state._hoverBackup = null;
state._koppenHoverBackup = null;
state._sideSwapped = sideSwapped;
const mat = new THREE.MeshLambertMaterial({ vertexColors: true });
mat.onBeforeCompile = (shader) => {
shader.vertexShader = shader.vertexShader.replace(
'#include <beginnormal_vertex>',
'vec3 objectNormal = normalize(position);'
);
};
state.planetMesh = new THREE.Mesh(geo, mat);
scene.add(state.planetMesh);
waterMesh.visible = !state.mapMode && !showPlates && !showStress && !debugLayer;
// Voronoi-edge wireframe
if (document.getElementById('chkWire').checked) {
const lp = [];
for (let s = 0; s < numSides; s++) {
if (s < mesh.halfedges[s]) {
const it = mesh.s_inner_t(s), ot = mesh.s_outer_t(s);
const ite = t_elevation[it], ote = t_elevation[ot];
const d1 = 1.001 + (ite > 0 ? ite*V : ite*V*0.3);
const d2 = 1.001 + (ote > 0 ? ote*V : ote*V*0.3);
lp.push(
t_xyz[3*it]*d1, t_xyz[3*it+1]*d1, t_xyz[3*it+2]*d1,
t_xyz[3*ot]*d2, t_xyz[3*ot+1]*d2, t_xyz[3*ot+2]*d2
);
}
}
const lg = new THREE.BufferGeometry();
lg.setAttribute('position', new THREE.Float32BufferAttribute(lp, 3));
state.wireMesh = new THREE.LineSegments(lg,
new THREE.LineBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.12 }));
scene.add(state.wireMesh);
}
updateSuperPlateBorders();
buildDriftArrows();
updatePendingHighlight();
updateHoverHighlight();
// Defer map mesh construction to reduce peak GPU memory — built on demand
// when switching to map view (see viewMode handler in main.js).
if (state.mapMode) buildMapMesh();
else updateOverlayMeshes();
buildGlobeGrid();
if (state.mapMode) {
state.planetMesh.visible = false;
waterMesh.visible = false;
atmosMesh.visible = false;
starsMesh.visible = false;
if (state.wireMesh) state.wireMesh.visible = false;
if (state.arrowGroup) state.arrowGroup.visible = false;
if (state.mapGridMesh) state.mapGridMesh.visible = state.gridEnabled;
if (state.globeGridMesh) state.globeGridMesh.visible = false;
if (state.oceanCurrentArrowGroup) {
state.oceanCurrentArrowGroup.traverse(c => {
if (c.name === 'oceanGlobe') c.visible = false;
if (c.name === 'oceanMap') c.visible = true;
});
}
} else {
state.planetMesh.visible = true;
atmosMesh.visible = true;
starsMesh.visible = true;
if (state.wireMesh) state.wireMesh.visible = true;
if (state.arrowGroup) state.arrowGroup.visible = true;
if (state.mapGridMesh) state.mapGridMesh.visible = false;
if (state.globeGridMesh) state.globeGridMesh.visible = state.gridEnabled;
if (state.oceanCurrentArrowGroup) {
state.oceanCurrentArrowGroup.traverse(c => {
if (c.name === 'oceanGlobe') c.visible = true;
if (c.name === 'oceanMap') c.visible = false;
});
}
}
}
// Update only color buffers for globe + map meshes (no geometry rebuild).
// Use this when switching display modes to avoid GPU memory spikes.
export function updateMeshColors() {
if (!state.curData || !state.planetMesh) return;
const { mesh, r_plate, r_elevation, t_elevation, mountain_r, coastline_r, ocean_r, r_stress, debugLayers } = state.curData;
const showPlates = document.getElementById('chkPlates').checked;
const showStress = false;
const waterLevel = 0;
const debugLayer = state.debugLayer || '';
// Precompute debug layer state
let dbgArr = null, dbgMin = 0, dbgMax = 0;
const isHeightmap = debugLayer === 'heightmap';
const isLandHeightmap = debugLayer === 'landheightmap';
const isOceanCurrent = debugLayer === 'oceanCurrentSummer' || debugLayer === 'oceanCurrentWinter';
const oceanSeason = debugLayer === 'oceanCurrentWinter' ? 'winter' : 'summer';
const oceanWarmth = isOceanCurrent ? state.curData[`r_ocean_warmth_${oceanSeason}`] : null;
const oceanSpeed = isOceanCurrent ? state.curData[`r_ocean_speed_${oceanSeason}`] : null;
const isPrecip = debugLayer === 'precipSummer' || debugLayer === 'precipWinter';
const precipArr = isPrecip ? (debugLayers && debugLayers[debugLayer]) : null;
const isRainShadow = debugLayer === 'rainShadowSummer' || debugLayer === 'rainShadowWinter';
const rainShadowArr = isRainShadow ? (debugLayers && debugLayers[debugLayer]) : null;
const isTemp = debugLayer === 'tempSummer' || debugLayer === 'tempWinter';
const tempArr = isTemp ? (debugLayers && debugLayers[debugLayer]) : null;
const isKoppen = debugLayer === 'koppen';
const isBiome = debugLayer === 'biome';
const koppenArr = (isKoppen || isBiome) ? (debugLayers && debugLayers.koppen) : null;
const isCont = debugLayer === 'continentality';
const contArr = isCont ? (debugLayers && debugLayers.continentality) : null;
const isPainted = PAINTED_LAYERS.has(debugLayer);
const paintedCtx = isPainted ? preparePaintedLayer(debugLayer, debugLayers && debugLayers[debugLayer], state.curData) : null;
if (!isHeightmap && !isLandHeightmap && !isOceanCurrent && !isPrecip && !isRainShadow && !isTemp && !isKoppen && !isBiome && !isCont && !isPainted && debugLayer && debugLayers && debugLayers[debugLayer]) {
dbgArr = debugLayers[debugLayer];
for (let r = 0; r < mesh.numRegions; r++) {
if (dbgArr[r] < dbgMin) dbgMin = dbgArr[r];
if (dbgArr[r] > dbgMax) dbgMax = dbgArr[r];
}
}
// Precompute smoothed biome colors (one-pass neighbor blend)
const biomeSmoothed = (isBiome && koppenArr) ? getCachedBiomeSmoothed(mesh, koppenArr, r_elevation) : null;
// Color helper — returns [r,g,b] for a given region
const getRegionColor = (br) => {
if (paintedCtx) return paintedCtx.color(br);
if (isBiome && biomeSmoothed) return [biomeSmoothed[br * 3], biomeSmoothed[br * 3 + 1], biomeSmoothed[br * 3 + 2]];
if (isCont && contArr) return continentalityColor(contArr[br]);
if (isKoppen && koppenArr) return koppenColor(koppenArr[br]);
if (isTemp && tempArr) return temperatureColor(tempArr[br]);
if (isPrecip && precipArr) return precipitationColor(precipArr[br]);
if (isRainShadow && rainShadowArr) return rainShadowColor(rainShadowArr[br]);
if (isOceanCurrent && oceanWarmth && oceanSpeed) return oceanCurrentColor(oceanWarmth[br], oceanSpeed[br], r_elevation[br] <= 0);
if (isOceanCurrent) return [0.5, 0, 0.5];
if (isLandHeightmap) return landHeightmapColor(r_elevation[br]);
if (isHeightmap) return heightmapColor(r_elevation[br]);
if (dbgArr) return debugValueToColor(dbgArr[br], dbgMin, dbgMax);
if (showPlates) {
const pc = state.plateColors[r_plate[br]] || new THREE.Color(0.3,0.3,0.3);
return [pc.r, pc.g, pc.b];
}
if (showStress) {
const sv = r_stress ? r_stress[br] : 0;
if (sv > 0.5) return [0.9, 0.1+sv*0.3, 0.1];
if (sv > 0.1) return [0.9, 0.5+sv*0.5, 0.2];
if (mountain_r.has(br)) return [0.8, 0.4, 0.1];
if (coastline_r.has(br)) return [0.9, 0.9, 0.2];
if (ocean_r.has(br)) return [0.1, 0.2, 0.7];
return [0.15, 0.15, 0.18];
}
return elevationToColor(r_elevation[br] - waterLevel);
};
// Update globe mesh colors in-place
const colorAttr = state.planetMesh.geometry.getAttribute('color');
const colors = colorAttr.array;
const { numSides } = mesh;
const isSmooth = isHeightmap || isLandHeightmap;
const sideSwapped = state._sideSwapped;
for (let s = 0; s < numSides; s++) {
const off = s * 9;
if (isSmooth) {
const it = mesh.s_inner_t(s);
const ot = mesh.s_outer_t(s);
const br = mesh.s_begin_r(s);
const colorFn = isLandHeightmap ? landHeightmapColor : heightmapColor;
const c0 = colorFn(t_elevation[it])[0];
const cOt = colorFn(t_elevation[ot])[0];
const cBr = colorFn(r_elevation[br])[0];
const swapped = sideSwapped && sideSwapped[s];
const c1 = swapped ? cBr : cOt;
const c2 = swapped ? cOt : cBr;
colors[off] = colors[off+1] = colors[off+2] = c0;
colors[off+3] = colors[off+4] = colors[off+5] = c1;
colors[off+6] = colors[off+7] = colors[off+8] = c2;
} else {
const br = mesh.s_begin_r(s);
const [cr, cg, cb] = getRegionColor(br);
for (let j = 0; j < 3; j++) {
colors[off + j*3] = cr;
colors[off + j*3 + 1] = cg;
colors[off + j*3 + 2] = cb;
}
}
}
colorAttr.needsUpdate = true;
state._hoverBackup = null;
state._koppenHoverBackup = null;
state._pendingBackup = null;
// Update map mesh colors in-place (if map exists)
if (state.mapMesh && state.mapFaceToSide) {
const mapColorAttr = state.mapMesh.geometry.getAttribute('color');
const mapColors = mapColorAttr.array;
const fts = state.mapFaceToSide;
for (let f = 0; f < fts.length; f++) {
const s = fts[f];
const off = f * 9;
if (isSmooth) {
const it = mesh.s_inner_t(s);
const ot = mesh.s_outer_t(s);
const br = mesh.s_begin_r(s);
const colorFn = isLandHeightmap ? landHeightmapColor : heightmapColor;
const v0 = colorFn(t_elevation[it])[0];
const v1 = colorFn(t_elevation[ot])[0];
const v2 = colorFn(r_elevation[br])[0];
mapColors[off] = mapColors[off+1] = mapColors[off+2] = v0;
mapColors[off+3] = mapColors[off+4] = mapColors[off+5] = v1;
mapColors[off+6] = mapColors[off+7] = mapColors[off+8] = v2;
} else {
const br = mesh.s_begin_r(s);
const [cr, cg, cb] = getRegionColor(br);
for (let j = 0; j < 3; j++) {
mapColors[off + j*3] = cr;
mapColors[off + j*3 + 1] = cg;
mapColors[off + j*3 + 2] = cb;
}
}
}
mapColorAttr.needsUpdate = true;
state._mapHoverBackup = null;
state._mapKoppenHoverBackup = null;
state._mapPendingBackup = null;
}
// Update water visibility
waterMesh.visible = !state.mapMode && !showPlates && !showStress && !debugLayer;
updatePendingHighlight();
updateMapPendingHighlight();
updateHoverHighlight();
updateMapHoverHighlight();
}
// Hover highlight — brighten hovered plate's cells (surgical save/restore).
export function updateHoverHighlight() {
if (!state.planetMesh || !state.curData) return;
const colorAttr = state.planetMesh.geometry.getAttribute('color');
const colors = colorAttr.array;
// Restore previously highlighted cells
if (state._hoverBackup) {
const { offsets, saved } = state._hoverBackup;
for (let i = 0; i < offsets.length; i++) {
const off = offsets[i] * 9;
for (let j = 0; j < 9; j++) colors[off + j] = saved[i * 9 + j];
}
state._hoverBackup = null;
}
// Apply new highlight
if (state.hoveredPlate >= 0) {
const { mesh, r_plate } = state.curData;
// Count cells for this plate
let count = 0;
for (let s = 0; s < mesh.numSides; s++) {
if (r_plate[mesh.s_begin_r(s)] === state.hoveredPlate) count++;
}
const offsets = new Int32Array(count);
const saved = new Float32Array(count * 9);
let idx = 0;
for (let s = 0; s < mesh.numSides; s++) {
if (r_plate[mesh.s_begin_r(s)] === state.hoveredPlate) {
offsets[idx] = s;
const off = s * 9;
for (let j = 0; j < 9; j++) saved[idx * 9 + j] = colors[off + j];
for (let j = 0; j < 3; j++) {
colors[off + j*3] = Math.min(1, colors[off + j*3] + 0.22);
colors[off + j*3 + 1] = Math.min(1, colors[off + j*3 + 1] + 0.22);
colors[off + j*3 + 2] = Math.min(1, colors[off + j*3 + 2] + 0.22);
}
idx++;
}
}
state._hoverBackup = { offsets, saved };
}
colorAttr.needsUpdate = true;
}
// Hover highlight for map mesh (surgical save/restore).
export function updateMapHoverHighlight() {
if (!state.mapMesh || !state.curData || !state.mapFaceToSide) return;
const colorAttr = state.mapMesh.geometry.getAttribute('color');
const colors = colorAttr.array;
// Restore previously highlighted cells
if (state._mapHoverBackup) {
const { offsets, saved } = state._mapHoverBackup;
for (let i = 0; i < offsets.length; i++) {
const off = offsets[i] * 9;
for (let j = 0; j < 9; j++) colors[off + j] = saved[i * 9 + j];
}
state._mapHoverBackup = null;
}
// Apply new highlight
if (state.hoveredPlate >= 0) {
const { mesh, r_plate } = state.curData;
const fts = state.mapFaceToSide;
// Count faces for this plate
let count = 0;
for (let f = 0; f < fts.length; f++) {
if (r_plate[mesh.s_begin_r(fts[f])] === state.hoveredPlate) count++;
}
const offsets = new Int32Array(count);
const saved = new Float32Array(count * 9);
let idx = 0;
for (let f = 0; f < fts.length; f++) {
if (r_plate[mesh.s_begin_r(fts[f])] === state.hoveredPlate) {
offsets[idx] = f;
const off = f * 9;
for (let j = 0; j < 9; j++) saved[idx * 9 + j] = colors[off + j];
for (let j = 0; j < 3; j++) {
colors[off + j*3] = Math.min(1, colors[off + j*3] + 0.22);
colors[off + j*3 + 1] = Math.min(1, colors[off + j*3 + 1] + 0.22);
colors[off + j*3 + 2] = Math.min(1, colors[off + j*3 + 2] + 0.22);
}
idx++;
}
}
state._mapHoverBackup = { offsets, saved };
}
colorAttr.needsUpdate = true;
}
// Köppen legend hover highlight — brighten cells matching hovered climate class (globe).
export function updateKoppenHoverHighlight() {
if (!state.planetMesh || !state.curData) return;
const colorAttr = state.planetMesh.geometry.getAttribute('color');
const colors = colorAttr.array;
// Restore previously highlighted cells
if (state._koppenHoverBackup) {
const { offsets, saved } = state._koppenHoverBackup;
for (let i = 0; i < offsets.length; i++) {
const off = offsets[i] * 9;
for (let j = 0; j < 9; j++) colors[off + j] = saved[i * 9 + j];
}
state._koppenHoverBackup = null;
}
if (state.hoveredKoppen >= 0) {
const { mesh, debugLayers } = state.curData;
const koppenArr = debugLayers && debugLayers.koppen;
if (!koppenArr) { colorAttr.needsUpdate = true; return; }
let count = 0;
for (let s = 0; s < mesh.numSides; s++) {
if (koppenArr[mesh.s_begin_r(s)] === state.hoveredKoppen) count++;
}
const offsets = new Int32Array(count);
const saved = new Float32Array(count * 9);
let idx = 0;
for (let s = 0; s < mesh.numSides; s++) {
if (koppenArr[mesh.s_begin_r(s)] === state.hoveredKoppen) {
offsets[idx] = s;
const off = s * 9;
for (let j = 0; j < 9; j++) saved[idx * 9 + j] = colors[off + j];
for (let j = 0; j < 3; j++) {
colors[off + j*3] = Math.min(1, colors[off + j*3] + 0.22);
colors[off + j*3 + 1] = Math.min(1, colors[off + j*3 + 1] + 0.22);
colors[off + j*3 + 2] = Math.min(1, colors[off + j*3 + 2] + 0.22);
}
idx++;
}
}
state._koppenHoverBackup = { offsets, saved };
}
colorAttr.needsUpdate = true;
}
// Köppen legend hover highlight for map mesh (surgical save/restore).
export function updateMapKoppenHoverHighlight() {
if (!state.mapMesh || !state.curData || !state.mapFaceToSide) return;
const colorAttr = state.mapMesh.geometry.getAttribute('color');
const colors = colorAttr.array;
// Restore previously highlighted cells
if (state._mapKoppenHoverBackup) {
const { offsets, saved } = state._mapKoppenHoverBackup;
for (let i = 0; i < offsets.length; i++) {
const off = offsets[i] * 9;
for (let j = 0; j < 9; j++) colors[off + j] = saved[i * 9 + j];
}
state._mapKoppenHoverBackup = null;
}
if (state.hoveredKoppen >= 0) {
const { mesh, debugLayers } = state.curData;
const koppenArr = debugLayers && debugLayers.koppen;
if (!koppenArr) { colorAttr.needsUpdate = true; return; }
const fts = state.mapFaceToSide;
let count = 0;
for (let f = 0; f < fts.length; f++) {
if (koppenArr[mesh.s_begin_r(fts[f])] === state.hoveredKoppen) count++;
}
const offsets = new Int32Array(count);
const saved = new Float32Array(count * 9);
let idx = 0;
for (let f = 0; f < fts.length; f++) {
if (koppenArr[mesh.s_begin_r(fts[f])] === state.hoveredKoppen) {
offsets[idx] = f;
const off = f * 9;
for (let j = 0; j < 9; j++) saved[idx * 9 + j] = colors[off + j];
for (let j = 0; j < 3; j++) {
colors[off + j*3] = Math.min(1, colors[off + j*3] + 0.22);
colors[off + j*3 + 1] = Math.min(1, colors[off + j*3 + 1] + 0.22);
colors[off + j*3 + 2] = Math.min(1, colors[off + j*3 + 2] + 0.22);
}
idx++;
}
}
state._mapKoppenHoverBackup = { offsets, saved };
}
colorAttr.needsUpdate = true;
}
// Pending-toggle highlight — tint plates queued for rebuild (surgical save/restore).
// Runs BEFORE hover highlight so hover saves/restores the already-tinted colors.
export function updatePendingHighlight() {
if (!state.planetMesh || !state.curData) return;
const colorAttr = state.planetMesh.geometry.getAttribute('color');
const colors = colorAttr.array;
// Restore previously highlighted cells
if (state._pendingBackup) {
const { offsets, saved } = state._pendingBackup;
for (let i = 0; i < offsets.length; i++) {
const off = offsets[i] * 9;
for (let j = 0; j < 9; j++) colors[off + j] = saved[i * 9 + j];
}
state._pendingBackup = null;
}
if (state.pendingToggles.size > 0) {
const { mesh, r_plate, plateIsOcean } = state.curData;
let count = 0;
for (let s = 0; s < mesh.numSides; s++) {
if (state.pendingToggles.has(r_plate[mesh.s_begin_r(s)])) count++;
}
const offsets = new Int32Array(count);
const saved = new Float32Array(count * 9);
let idx = 0;
for (let s = 0; s < mesh.numSides; s++) {
const pid = r_plate[mesh.s_begin_r(s)];
if (state.pendingToggles.has(pid)) {
offsets[idx] = s;
const off = s * 9;
for (let j = 0; j < 9; j++) saved[idx * 9 + j] = colors[off + j];
const isOcean = plateIsOcean.has(pid);
for (let j = 0; j < 3; j++) {
if (isOcean) {
// Ocean → Land pending: strong green tint
colors[off + j*3] = colors[off + j*3] * 0.7;
colors[off + j*3 + 1] = Math.min(1, colors[off + j*3 + 1] + 0.25);
colors[off + j*3 + 2] = colors[off + j*3 + 2] * 0.7;
} else {
// Land → Ocean pending: strong blue tint
colors[off + j*3] = colors[off + j*3] * 0.7;
colors[off + j*3 + 1] = colors[off + j*3 + 1] * 0.7;
colors[off + j*3 + 2] = Math.min(1, colors[off + j*3 + 2] + 0.25);
}
}
idx++;
}
}
state._pendingBackup = { offsets, saved };
}
colorAttr.needsUpdate = true;
}
// Pending-toggle highlight for map mesh (surgical save/restore).
export function updateMapPendingHighlight() {
if (!state.mapMesh || !state.curData || !state.mapFaceToSide) return;
const colorAttr = state.mapMesh.geometry.getAttribute('color');
const colors = colorAttr.array;
if (state._mapPendingBackup) {
const { offsets, saved } = state._mapPendingBackup;
for (let i = 0; i < offsets.length; i++) {
const off = offsets[i] * 9;
for (let j = 0; j < 9; j++) colors[off + j] = saved[i * 9 + j];
}
state._mapPendingBackup = null;
}
if (state.pendingToggles.size > 0) {
const { mesh, r_plate, plateIsOcean } = state.curData;
const fts = state.mapFaceToSide;
let count = 0;
for (let f = 0; f < fts.length; f++) {
if (state.pendingToggles.has(r_plate[mesh.s_begin_r(fts[f])])) count++;
}
const offsets = new Int32Array(count);
const saved = new Float32Array(count * 9);
let idx = 0;
for (let f = 0; f < fts.length; f++) {
const pid = r_plate[mesh.s_begin_r(fts[f])];
if (state.pendingToggles.has(pid)) {
offsets[idx] = f;
const off = f * 9;
for (let j = 0; j < 9; j++) saved[idx * 9 + j] = colors[off + j];
const isOcean = plateIsOcean.has(pid);
for (let j = 0; j < 3; j++) {
if (isOcean) {
colors[off + j*3] = colors[off + j*3] * 0.7;
colors[off + j*3 + 1] = Math.min(1, colors[off + j*3 + 1] + 0.25);
colors[off + j*3 + 2] = colors[off + j*3 + 2] * 0.7;
} else {
colors[off + j*3] = colors[off + j*3] * 0.7;
colors[off + j*3 + 1] = colors[off + j*3 + 1] * 0.7;
colors[off + j*3 + 2] = Math.min(1, colors[off + j*3 + 2] + 0.25);
}
}
idx++;
}
}
state._mapPendingBackup = { offsets, saved };
}
colorAttr.needsUpdate = true;
}
// Drift arrows — show plate movement directions.
export function buildDriftArrows() {
if (state.arrowGroup) {
state.arrowGroup.traverse(child => {
if (child.geometry) child.geometry.dispose();
if (child.material) child.material.dispose();
});
scene.remove(state.arrowGroup);
state.arrowGroup = null;
}
return;
state.arrowGroup = new THREE.Group();
const { r_xyz, plateSeeds, plateVec, plateIsOcean } = state.curData;
for (const seed of plateSeeds) {
const px = r_xyz[3*seed], py = r_xyz[3*seed+1], pz = r_xyz[3*seed+2];
const pos = new THREE.Vector3(px, py, pz).normalize();
const pv = plateVec[seed];
const vel = [
pv.omega * (pv.pole[1] * pz - pv.pole[2] * py),
pv.omega * (pv.pole[2] * px - pv.pole[0] * pz),
pv.omega * (pv.pole[0] * py - pv.pole[1] * px)
];
const drift = new THREE.Vector3(...vel);
const radial = drift.dot(pos);
const tangent = drift.clone().sub(pos.clone().multiplyScalar(radial));
if (tangent.length() < 0.001) continue;
tangent.normalize();
const origin = pos.clone().multiplyScalar(1.07);
const length = 0.18;
const color = plateIsOcean.has(seed) ? 0x66ccff : 0xffcc44;
const arrow = new THREE.ArrowHelper(tangent, origin, length, color, 0.055, 0.03);
state.arrowGroup.add(arrow);
}
scene.add(state.arrowGroup);
}
// Wind arrows — show wind direction/magnitude overlay.
export function buildWindArrows(season) {
// Clean up previous arrows
if (state.windArrowGroup) {
state.windArrowGroup.traverse(child => {
if (child.geometry) child.geometry.dispose();
if (child.material) child.material.dispose();
});
scene.remove(state.windArrowGroup);
state.windArrowGroup = null;
}
if (!season || !state.curData || !state.curData.r_wind_east_summer) return;
const { mesh, r_xyz,
r_wind_east_summer, r_wind_north_summer,
r_wind_east_winter, r_wind_north_winter } = state.curData;
const windE = season === 'winter' ? r_wind_east_winter : r_wind_east_summer;
const windN = season === 'winter' ? r_wind_north_winter : r_wind_north_summer;
if (!windE || !windN) return;
const PI = Math.PI;
const DEG = PI / 180;
const sx = 2 / PI;
const numRegions = mesh.numRegions;
// ── Bin regions into a lat/lon grid for even geographic sampling ──
const LAT_STEP = 3; // degrees
const LON_STEP = 3;
const latBands = Math.floor(180 / LAT_STEP); // 60
const lonBands = Math.floor(360 / LON_STEP); // 120
// For each grid cell, find the closest region to the cell center
const gridRegions = new Int32Array(latBands * lonBands).fill(-1);
const gridDist2 = new Float32Array(latBands * lonBands).fill(1e9);
for (let r = 0; r < numRegions; r++) {
const ry = r_xyz[3 * r + 1];
const lat = Math.asin(Math.max(-1, Math.min(1, ry)));
const lon = Math.atan2(r_xyz[3 * r], r_xyz[3 * r + 2]);
const li = Math.max(0, Math.min(latBands - 1,
Math.floor((lat + PI / 2) / (LAT_STEP * DEG))));
const lo = Math.max(0, Math.min(lonBands - 1,
Math.floor((lon + PI) / (LON_STEP * DEG))));
const cellLat = (-90 + li * LAT_STEP + LAT_STEP * 0.5) * DEG;
const cellLon = (-180 + lo * LON_STEP + LON_STEP * 0.5) * DEG;
const dlat = lat - cellLat, dlon = lon - cellLon;
const d2 = dlat * dlat + dlon * dlon;
const idx = li * lonBands + lo;
if (d2 < gridDist2[idx]) {
gridDist2[idx] = d2;
gridRegions[idx] = r;
}
}
const globePositions = [];
const globeColors = [];
const mapPositions = [];
const mapColors = [];
const HEAD_ANGLE = 25 * DEG;
const HEAD_FRAC = 0.35; // arrowhead length as fraction of shaft
const cosA = Math.cos(HEAD_ANGLE), sinA = Math.sin(HEAD_ANGLE);
for (let i = 0; i < gridRegions.length; i++) {
const r = gridRegions[i];
if (r < 0) continue;
const we = windE[r], wn = windN[r];
const speed = Math.sqrt(we * we + wn * wn);
if (speed < 0.001) continue;
const x = r_xyz[3 * r], y = r_xyz[3 * r + 1], z = r_xyz[3 * r + 2];
// Color: blue (slow) → yellow (medium) → red (fast)
const t = Math.min(1, speed * 3);
let cr, cg, cb;
if (t < 0.5) {
const s = t * 2;
cr = s; cg = s; cb = 1 - s * 0.5;
} else {
const s = (t - 0.5) * 2;
cr = 1; cg = 1 - s; cb = 0.5 - s * 0.5;
}
// ── Globe arrows: 3D with arrowhead ──
{
// Tangent frame (Y-up)
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; }
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;
// Wind direction in 3D = we * east + wn * north
const dirX = we * ex + wn * nx;
const dirY = we * ey + wn * ny;
const dirZ = we * ez + wn * nz;
const dirLen = Math.sqrt(dirX * dirX + dirY * dirY + dirZ * dirZ) || 1;
const dxn = dirX / dirLen, dyn = dirY / dirLen, dzn = dirZ / dirLen;
// Perpendicular in tangent plane: position × dir
let px = y * dzn - z * dyn;
let py = z * dxn - x * dzn;
let pz = x * dyn - y * dxn;
const plen = Math.sqrt(px * px + py * py + pz * pz) || 1;
px /= plen; py /= plen; pz /= plen;
const arrowLen = 0.008 + Math.min(0.012, speed * 0.025);
const R = 1.007;
const ox = x * R, oy = y * R, oz = z * R;
const tx = ox + dxn * arrowLen;
const ty = oy + dyn * arrowLen;
const tz = oz + dzn * arrowLen;
// Shaft
globePositions.push(ox, oy, oz, tx, ty, tz);
globeColors.push(cr, cg, cb, cr, cg, cb);
// Arrowhead wings
const hLen = arrowLen * HEAD_FRAC;
const lwx = tx + (-dxn * cosA + px * sinA) * hLen;
const lwy = ty + (-dyn * cosA + py * sinA) * hLen;
const lwz = tz + (-dzn * cosA + pz * sinA) * hLen;
const rwx = tx + (-dxn * cosA - px * sinA) * hLen;
const rwy = ty + (-dyn * cosA - py * sinA) * hLen;
const rwz = tz + (-dzn * cosA - pz * sinA) * hLen;
globePositions.push(tx, ty, tz, lwx, lwy, lwz);
globeColors.push(cr, cg, cb, cr, cg, cb);
globePositions.push(tx, ty, tz, rwx, rwy, rwz);
globeColors.push(cr, cg, cb, cr, cg, cb);
}
// ── Map arrows: 2D with arrowhead ──
{
let lon = Math.atan2(x, z) - (state.mapCenterLon || 0);
if (lon > PI) lon -= 2 * PI; else if (lon < -PI) lon += 2 * PI;
const lat = Math.asin(Math.max(-1, Math.min(1, y)));
const mx = lon * sx;
const my = lat * sx;
const norm = speed || 1;
const arrowLen = 0.006 + Math.min(0.012, speed * 0.025);
const dx = (we / norm) * arrowLen;
const dy = (wn / norm) * arrowLen;
const tipX = mx + dx, tipY = my + dy;
// Shaft
mapPositions.push(mx, my, 0.002, tipX, tipY, 0.002);
mapColors.push(cr, cg, cb, cr, cg, cb);
// Arrowhead wings (2D rotation of -dir)
const hLen = arrowLen * HEAD_FRAC;
const dLen = Math.sqrt(dx * dx + dy * dy) || 1;
const ndx = -dx / dLen, ndy = -dy / dLen;
const lx = tipX + (ndx * cosA - ndy * sinA) * hLen;
const ly = tipY + (ndx * sinA + ndy * cosA) * hLen;
const rx = tipX + (ndx * cosA + ndy * sinA) * hLen;
const ry = tipY + (-ndx * sinA + ndy * cosA) * hLen;
mapPositions.push(tipX, tipY, 0.002, lx, ly, 0.002);
mapColors.push(cr, cg, cb, cr, cg, cb);
mapPositions.push(tipX, tipY, 0.002, rx, ry, 0.002);
mapColors.push(cr, cg, cb, cr, cg, cb);
}
}
state.windArrowGroup = new THREE.Group();
// Globe arrows
if (globePositions.length > 0) {
const gGeo = new THREE.BufferGeometry();
gGeo.setAttribute('position', new THREE.Float32BufferAttribute(globePositions, 3));
gGeo.setAttribute('color', new THREE.Float32BufferAttribute(globeColors, 3));
const gMat = new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, opacity: 0.6, depthWrite: false });
const gLines = new THREE.LineSegments(gGeo, gMat);
gLines.name = 'windGlobe';
gLines.visible = !state.mapMode;
state.windArrowGroup.add(gLines);
}
// Map arrows
if (mapPositions.length > 0) {
const mGeo = new THREE.BufferGeometry();
mGeo.setAttribute('position', new THREE.Float32BufferAttribute(mapPositions, 3));
mGeo.setAttribute('color', new THREE.Float32BufferAttribute(mapColors, 3));
const mMat = new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, opacity: 0.6 });
const mLines = new THREE.LineSegments(mGeo, mMat);
mLines.name = 'windMap';
mLines.visible = state.mapMode;
state.windArrowGroup.add(mLines);
}
// ── ITCZ spline line (shown on pressure layers) ──
const isPressureLayer = season && (state.debugLayer === 'pressureSummer' || state.debugLayer === 'pressureWinter');
const itczLons = state.curData.itczLons;
const itczLats = season === 'winter' ? state.curData.itczLatsWinter : state.curData.itczLatsSummer;
if (isPressureLayer && itczLons && itczLats) {
const N = itczLons.length;
const R_ITCZ = 1.01;
// Globe: polyline on sphere surface
const gPos = [];
for (let i = 0; i < N; i++) {
const j = (i + 1) % N;
const lon0 = itczLons[i], lat0 = itczLats[i];
const lon1 = itczLons[j], lat1 = itczLats[j];
const cosLat0 = Math.cos(lat0), cosLat1 = Math.cos(lat1);
gPos.push(
Math.sin(lon0) * cosLat0 * R_ITCZ, Math.sin(lat0) * R_ITCZ, Math.cos(lon0) * cosLat0 * R_ITCZ,
Math.sin(lon1) * cosLat1 * R_ITCZ, Math.sin(lat1) * R_ITCZ, Math.cos(lon1) * cosLat1 * R_ITCZ
);
}
const igGeo = new THREE.BufferGeometry();
igGeo.setAttribute('position', new THREE.Float32BufferAttribute(gPos, 3));
const igMat = new THREE.LineBasicMaterial({ color: 0x00ff88, linewidth: 2, depthWrite: false });
const igLines = new THREE.LineSegments(igGeo, igMat);
igLines.name = 'windGlobe';
igLines.visible = !state.mapMode;
state.windArrowGroup.add(igLines);
// Map: polyline on equirectangular projection
const mPos = [];
for (let i = 0; i < N; i++) {
const j = (i + 1) % N;
const mx0 = itczLons[i] * sx, my0 = itczLats[i] * sx;
const mx1 = itczLons[j] * sx, my1 = itczLats[j] * sx;
// Skip segment that wraps across antimeridian
if (Math.abs(mx1 - mx0) > 1) continue;
mPos.push(mx0, my0, 0.003, mx1, my1, 0.003);
}
const imGeo = new THREE.BufferGeometry();
imGeo.setAttribute('position', new THREE.Float32BufferAttribute(mPos, 3));
const imMat = new THREE.LineBasicMaterial({ color: 0x00ff88, linewidth: 2 });
const imLines = new THREE.LineSegments(imGeo, imMat);
imLines.name = 'windMap';
imLines.visible = state.mapMode;
state.windArrowGroup.add(imLines);
}
scene.add(state.windArrowGroup);
}
// Ocean current arrows — show current direction colored by heat transport.
export function buildOceanCurrentArrows(season) {
// Clean up previous arrows
if (state.oceanCurrentArrowGroup) {
state.oceanCurrentArrowGroup.traverse(child => {
if (child.geometry) child.geometry.dispose();
if (child.material) child.material.dispose();
});
scene.remove(state.oceanCurrentArrowGroup);
state.oceanCurrentArrowGroup = null;
}
if (!season || !state.curData || !state.curData.r_ocean_current_east_summer) return;
const { mesh, r_xyz, r_elevation } = state.curData;
const currentE = season === 'winter'
? state.curData.r_ocean_current_east_winter : state.curData.r_ocean_current_east_summer;
const currentN = season === 'winter'
? state.curData.r_ocean_current_north_winter : state.curData.r_ocean_current_north_summer;
const speedArr = season === 'winter'
? state.curData.r_ocean_speed_winter : state.curData.r_ocean_speed_summer;
const warmthArr = season === 'winter'
? state.curData.r_ocean_warmth_winter : state.curData.r_ocean_warmth_summer;
if (!currentE || !currentN || !speedArr || !warmthArr) return;
const PI = Math.PI;
const DEG = PI / 180;
const sx = 2 / PI;
const numRegions = mesh.numRegions;
// ── Bin regions into a lat/lon grid for even geographic sampling ──
const LAT_STEP = 3;
const LON_STEP = 3;
const latBands = Math.floor(180 / LAT_STEP);
const lonBands = Math.floor(360 / LON_STEP);
const gridRegions = new Int32Array(latBands * lonBands).fill(-1);
const gridDist2 = new Float32Array(latBands * lonBands).fill(1e9);
for (let r = 0; r < numRegions; r++) {
// Skip land
if (r_elevation[r] > 0) continue;
const ry = r_xyz[3 * r + 1];
const lat = Math.asin(Math.max(-1, Math.min(1, ry)));
const lon = Math.atan2(r_xyz[3 * r], r_xyz[3 * r + 2]);
const li = Math.max(0, Math.min(latBands - 1,
Math.floor((lat + PI / 2) / (LAT_STEP * DEG))));
const lo = Math.max(0, Math.min(lonBands - 1,
Math.floor((lon + PI) / (LON_STEP * DEG))));
const cellLat = (-90 + li * LAT_STEP + LAT_STEP * 0.5) * DEG;
const cellLon = (-180 + lo * LON_STEP + LON_STEP * 0.5) * DEG;
const dlat = lat - cellLat, dlon = lon - cellLon;
const d2 = dlat * dlat + dlon * dlon;
const idx = li * lonBands + lo;
if (d2 < gridDist2[idx]) {
gridDist2[idx] = d2;
gridRegions[idx] = r;
}
}
const globePositions = [];
const globeColors = [];
const mapPositions = [];
const mapColors = [];
const HEAD_ANGLE = 25 * DEG;
const HEAD_FRAC = 0.35;
const cosA = Math.cos(HEAD_ANGLE), sinA = Math.sin(HEAD_ANGLE);
for (let i = 0; i < gridRegions.length; i++) {
const r = gridRegions[i];
if (r < 0) continue;
const ce = currentE[r], cn = currentN[r];
const speed = speedArr[r];
const warmth = warmthArr[r];
if (speed < 0.01) continue;
const x = r_xyz[3 * r], y = r_xyz[3 * r + 1], z = r_xyz[3 * r + 2];
// Color by heat transport: red (warm/poleward), blue (cold/equatorward), gray (neutral)
let cr, cg, cb;
if (warmth > 0.1) {
cr = 0.9; cg = 0.15; cb = 0.15;
} else if (warmth < -0.1) {
cr = 0.15; cg = 0.3; cb = 0.9;
} else {
cr = 0.5; cg = 0.5; cb = 0.5;
}
// ── Globe arrows: 3D with arrowhead ──
{
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; }
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;
const dirX = ce * ex + cn * nx;
const dirY = ce * ey + cn * ny;
const dirZ = ce * ez + cn * nz;
const dirLen = Math.sqrt(dirX * dirX + dirY * dirY + dirZ * dirZ) || 1;
const dxn = dirX / dirLen, dyn = dirY / dirLen, dzn = dirZ / dirLen;
let px = y * dzn - z * dyn;
let py = z * dxn - x * dzn;
let pz = x * dyn - y * dxn;
const plen = Math.sqrt(px * px + py * py + pz * pz) || 1;
px /= plen; py /= plen; pz /= plen;
const arrowLen = 0.006 + Math.min(0.014, speed * 0.025);
const R = 1.007;
const ox = x * R, oy = y * R, oz = z * R;
const tx = ox + dxn * arrowLen;
const ty = oy + dyn * arrowLen;
const tz = oz + dzn * arrowLen;
globePositions.push(ox, oy, oz, tx, ty, tz);
globeColors.push(cr, cg, cb, cr, cg, cb);
const hLen = arrowLen * HEAD_FRAC;
const lwx = tx + (-dxn * cosA + px * sinA) * hLen;
const lwy = ty + (-dyn * cosA + py * sinA) * hLen;
const lwz = tz + (-dzn * cosA + pz * sinA) * hLen;
const rwx = tx + (-dxn * cosA - px * sinA) * hLen;
const rwy = ty + (-dyn * cosA - py * sinA) * hLen;
const rwz = tz + (-dzn * cosA - pz * sinA) * hLen;
globePositions.push(tx, ty, tz, lwx, lwy, lwz);
globeColors.push(cr, cg, cb, cr, cg, cb);
globePositions.push(tx, ty, tz, rwx, rwy, rwz);
globeColors.push(cr, cg, cb, cr, cg, cb);
}
// ── Map arrows: 2D with arrowhead ──
{
let lon = Math.atan2(x, z) - (state.mapCenterLon || 0);
if (lon > PI) lon -= 2 * PI; else if (lon < -PI) lon += 2 * PI;
const lat = Math.asin(Math.max(-1, Math.min(1, y)));
const mx = lon * sx;
const my = lat * sx;
const rawSpeed = Math.sqrt(ce * ce + cn * cn) || 1;
const arrowLen = 0.006 + Math.min(0.014, speed * 0.025);
const dx = (ce / rawSpeed) * arrowLen;
const dy = (cn / rawSpeed) * arrowLen;
const tipX = mx + dx, tipY = my + dy;
mapPositions.push(mx, my, 0.002, tipX, tipY, 0.002);
mapColors.push(cr, cg, cb, cr, cg, cb);
const hLen = arrowLen * HEAD_FRAC;
const dLen = Math.sqrt(dx * dx + dy * dy) || 1;
const ndx = -dx / dLen, ndy = -dy / dLen;
const lx = tipX + (ndx * cosA - ndy * sinA) * hLen;
const ly = tipY + (ndx * sinA + ndy * cosA) * hLen;
const rx = tipX + (ndx * cosA + ndy * sinA) * hLen;
const ry = tipY + (-ndx * sinA + ndy * cosA) * hLen;
mapPositions.push(tipX, tipY, 0.002, lx, ly, 0.002);
mapColors.push(cr, cg, cb, cr, cg, cb);
mapPositions.push(tipX, tipY, 0.002, rx, ry, 0.002);
mapColors.push(cr, cg, cb, cr, cg, cb);
}
}
console.log(`[OceanArrows] ${season}: ${globePositions.length / 18} arrows (from ${gridRegions.length} grid cells)`);
state.oceanCurrentArrowGroup = new THREE.Group();
if (globePositions.length > 0) {
const gGeo = new THREE.BufferGeometry();
gGeo.setAttribute('position', new THREE.Float32BufferAttribute(globePositions, 3));
gGeo.setAttribute('color', new THREE.Float32BufferAttribute(globeColors, 3));
const gMat = new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, opacity: 0.6, depthWrite: false });
const gLines = new THREE.LineSegments(gGeo, gMat);
gLines.name = 'oceanGlobe';
gLines.visible = !state.mapMode;
state.oceanCurrentArrowGroup.add(gLines);
}
if (mapPositions.length > 0) {
const mGeo = new THREE.BufferGeometry();
mGeo.setAttribute('position', new THREE.Float32BufferAttribute(mapPositions, 3));
mGeo.setAttribute('color', new THREE.Float32BufferAttribute(mapColors, 3));
const mMat = new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, opacity: 0.6 });
const mLines = new THREE.LineSegments(mGeo, mMat);
mLines.name = 'oceanMap';
mLines.visible = state.mapMode;
state.oceanCurrentArrowGroup.add(mLines);
}
scene.add(state.oceanCurrentArrowGroup);
}
// Export equirectangular map as PNG (async, with tiled rendering for large sizes).
export async function exportMap(type, width, onProgress) {
if (!state.curData) return;
// Yield so the browser paints the loading overlay before heavy work begins
await new Promise(r => setTimeout(r, 50));
const height = width / 2;
const { mesh, r_xyz, t_xyz, r_elevation } = state.curData;
const isBW = type === 'heightmap' || type === 'landheightmap' || type === 'landmask';
const is16Bit = type === 'heightmap' || type === 'landheightmap';
// Climate-dependent export types (Satellite / Köppen)
const debugLayers = state.curData.debugLayers;
const koppenArr = (type === 'biome' || type === 'koppen') ? (debugLayers && debugLayers.koppen) : null;
const biomeSmoothed = (type === 'biome' && koppenArr) ? getCachedBiomeSmoothed(mesh, koppenArr, r_elevation) : null;
// Painted-map layers (class, uplift, erodibility, drainage, slope, basins)
const paintedLayer = PAINTED_EXPORT_TYPES[type] || null;
const paintedCtx = paintedLayer ? preparePaintedLayer(paintedLayer, debugLayers && debugLayers[paintedLayer], state.curData) : null;
// Build map triangles (same projection as buildMapMesh, chosen coloring, no grid)
const { numSides, numTriangles } = mesh;
const PI = Math.PI;
const sx = 2 / PI;
// For heightmap exports, precompute averaged elevation at each triangle center
// (each triangle touches 3 regions). This enables smooth Gouraud interpolation
// instead of flat hex-cell shading.
let t_elev;
if (is16Bit) {
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;
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);
// Per-vertex colors: c0 = inner_t vertex, c1 = outer_t vertex, c2 = region vertex
let c0r, c0g, c0b, c1r, c1g, c1b, c2r, c2g, c2b;
if (is16Bit) {
// Smooth heightmap: triangle-center vertices use averaged elevation
const colorFn = type === 'landheightmap' ? landHeightmapColor : heightmapColor;
const v0 = colorFn(t_elev[it])[0];
const v1 = colorFn(t_elev[ot])[0];
const v2 = colorFn(r_elevation[br])[0];
c0r = c0g = c0b = v0;
c1r = c1g = c1b = v1;
c2r = c2g = c2b = v2;
} else {
let cr, cg, cb;
if (type === 'landmask') {
[cr, cg, cb] = landMaskColor(r_elevation[br]);
} else if (paintedCtx) {
[cr, cg, cb] = paintedCtx.color(br);
} else if (type === 'biome' && biomeSmoothed) {
cr = biomeSmoothed[br * 3]; cg = biomeSmoothed[br * 3 + 1]; cb = biomeSmoothed[br * 3 + 2];
} else if (type === 'koppen' && koppenArr) {
[cr, cg, cb] = koppenColor(koppenArr[br]);
} else {
[cr, cg, cb] = elevationToColor(r_elevation[br]);
}
c0r = c1r = c2r = cr;
c0g = c1g = c2g = cg;
c0b = c1b = c2b = cb;
}
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)));
const clx = (v) => Math.max(-2, Math.min(2, v));
const cly = (v) => Math.max(-1, Math.min(1, v));
const maxLon = Math.max(lon0, lon1, lon2);
const minLon = Math.min(lon0, lon1, lon2);
const wraps = (maxLon - minLon) > PI;
if (wraps) {
if (lon0 < 0) lon0 += 2 * PI;
if (lon1 < 0) lon1 += 2 * PI;
if (lon2 < 0) lon2 += 2 * PI;
let off = triCount * 9;
posArr[off] = clx(lon0*sx); posArr[off+1] = cly(lat0*sx); posArr[off+2] = 0;
posArr[off+3] = clx(lon1*sx); posArr[off+4] = cly(lat1*sx); posArr[off+5] = 0;
posArr[off+6] = clx(lon2*sx); posArr[off+7] = cly(lat2*sx); posArr[off+8] = 0;
colArr[off]=c0r; colArr[off+1]=c0g; colArr[off+2]=c0b;
colArr[off+3]=c1r; colArr[off+4]=c1g; colArr[off+5]=c1b;
colArr[off+6]=c2r; colArr[off+7]=c2g; colArr[off+8]=c2b;
triCount++;
off = triCount * 9;
posArr[off] = clx((lon0-2*PI)*sx); posArr[off+1] = cly(lat0*sx); posArr[off+2] = 0;
posArr[off+3] = clx((lon1-2*PI)*sx); posArr[off+4] = cly(lat1*sx); posArr[off+5] = 0;
posArr[off+6] = clx((lon2-2*PI)*sx); posArr[off+7] = cly(lat2*sx); posArr[off+8] = 0;
colArr[off]=c0r; colArr[off+1]=c0g; colArr[off+2]=c0b;
colArr[off+3]=c1r; colArr[off+4]=c1g; colArr[off+5]=c1b;
colArr[off+6]=c2r; colArr[off+7]=c2g; colArr[off+8]=c2b;
triCount++;
} else {
const off = triCount * 9;
posArr[off] = clx(lon0*sx); posArr[off+1] = cly(lat0*sx); posArr[off+2] = 0;
posArr[off+3] = clx(lon1*sx); posArr[off+4] = cly(lat1*sx); posArr[off+5] = 0;
posArr[off+6] = clx(lon2*sx); posArr[off+7] = cly(lat2*sx); posArr[off+8] = 0;
colArr[off]=c0r; colArr[off+1]=c0g; colArr[off+2]=c0b;
colArr[off+3]=c1r; colArr[off+4]=c1g; colArr[off+5]=c1b;
colArr[off+6]=c2r; colArr[off+7]=c2g; colArr[off+8]=c2b;
triCount++;
}
}
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));
const mapMesh = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.DoubleSide }));
const offScene = new THREE.Scene();
offScene.background = isBW ? new THREE.Color(0x000000) : new THREE.Color(0x1a1a2e);
offScene.add(mapMesh);
// Tiled rendering — split into small tiles to stay within GPU/CPU memory limits.
// Cap at 2048 regardless of GPU maxTextureSize to keep render-target + pixel-
// readback + ImageData under ~48 MB per tile (2048×2048×4 × 3 buffers).
const maxTex = renderer.capabilities.maxTextureSize;
const MAX_TILE = 2048;
const tileW = Math.min(width, maxTex, MAX_TILE);
const tileH = Math.min(height, maxTex, MAX_TILE);
const tilesX = Math.ceil(width / tileW);
const tilesY = Math.ceil(height / tileH);
const totalTiles = tilesX * tilesY;
// 16-bit heightmaps write to a Uint16Array; other types use a canvas
let cvs, ctx, img16;
if (is16Bit) {
img16 = new Uint16Array(width * height);
} else {
cvs = document.createElement('canvas');
cvs.width = width;
cvs.height = height;
ctx = cvs.getContext('2d');
}
let tilesDone = 0;
for (let ty = 0; ty < tilesY; ty++) {
for (let tx = 0; tx < tilesX; tx++) {
const px0 = tx * tileW;
const py0 = ty * tileH;
const pw = Math.min(tileW, width - px0);
const ph = Math.min(tileH, height - py0);
// Orthographic frustum for this tile (map space: x [-2,2], y [-1,1])
const left = -2 + 4 * px0 / width;
const right = -2 + 4 * (px0 + pw) / width;
const top = 1 - 2 * py0 / height;
const bottom = 1 - 2 * (py0 + ph) / height;
const cam = new THREE.OrthographicCamera(left, right, top, bottom, 0.1, 10);
cam.position.set(0, 0, 5);
cam.lookAt(0, 0, 0);
if (is16Bit) {
// Float render target preserves full precision of elevation values
const renderTarget = new THREE.WebGLRenderTarget(pw, ph, { type: THREE.FloatType });
const prevCS = renderer.outputColorSpace;
renderer.outputColorSpace = THREE.LinearSRGBColorSpace;
renderer.setRenderTarget(renderTarget);
renderer.render(offScene, cam);
renderer.outputColorSpace = prevCS;
const floatPixels = new Float32Array(pw * ph * 4);
renderer.readRenderTargetPixels(renderTarget, 0, 0, pw, ph, floatPixels);
renderer.setRenderTarget(null);
renderTarget.dispose();
// Write to img16 (flip rows, extract R channel → 16-bit)
for (let y = 0; y < ph; y++) {
const srcRow = (ph - 1 - y) * pw;
const dstRow = (py0 + y) * width + px0;
for (let x = 0; x < pw; x++) {
const v = floatPixels[(srcRow + x) * 4]; // R channel
img16[dstRow + x] = Math.max(0, Math.min(65535, (v * 65535 + 0.5) | 0));
}
}
} else {
const renderTarget = new THREE.WebGLRenderTarget(pw, ph);
renderer.setRenderTarget(renderTarget);
renderer.render(offScene, cam);
const pixels = new Uint8Array(pw * ph * 4);
renderer.readRenderTargetPixels(renderTarget, 0, 0, pw, ph, pixels);
renderer.setRenderTarget(null);
renderTarget.dispose();
// Write tile to canvas (flip rows + sRGB gamma)
const imageData = ctx.createImageData(pw, ph);
const out = imageData.data;
for (let y = 0; y < ph; y++) {
const src = (ph - 1 - y) * pw * 4;
const dst = y * pw * 4;
for (let x = 0; x < pw; x++) {
const si = src + x * 4, di = dst + x * 4;
for (let c = 0; c < 3; c++) {
const v = pixels[si + c] / 255;
out[di + c] = (v <= 0.0031308
? v * 12.92
: 1.055 * Math.pow(v, 1 / 2.4) - 0.055) * 255 + 0.5 | 0;
}
out[di + 3] = pixels[si + 3];
}
}
ctx.putImageData(imageData, px0, py0);
}
tilesDone++;
if (onProgress) onProgress(tilesDone / totalTiles * 80, 'Rendering...');
await new Promise(r => setTimeout(r, 0));
}
}
// The overlay export is the dimmed class map with the sheet composited over it, at the export's own size.
if (!is16Bit && type === 'overlay') compositeOverlaySheet(ctx, width, height);
// Cleanup mesh
geo.dispose();
mapMesh.material.dispose();
// Encode & download
if (onProgress) onProgress(85, 'Encoding PNG...');
await new Promise(r => setTimeout(r, 0));
const code = location.hash.replace(/^#/, '').trim() || (state.curData ? state.curData.seed : '');
const filename = exportFilename(type, code);
if (is16Bit) {
const blob = await encode16BitGrayscalePNG(width, height, img16);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 5000);
} else {
await new Promise(resolve => {
cvs.toBlob(blob => {
if (blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 5000);
}
// Release canvas bitmap memory so sequential exports don't accumulate
cvs.width = 0;
cvs.height = 0;
resolve();
}, 'image/png');
});
}
}
function exportFilename(type, seed) {
switch (type) {
case 'landmask': return `orogen-landmask-${seed}.png`;
case 'landheightmap': return `orogen-land-heightmap-${seed}.png`;
case 'heightmap': return `orogen-heightmap-${seed}.png`;
case 'biome': return `orogen-satellite-${seed}.png`;
case 'koppen': return `orogen-climate-${seed}.png`;
case 'paintclass': return `orogen-painted-class-${seed}.png`;
case 'uplift': return `orogen-painted-uplift-${seed}.png`;
case 'erodibility': return `orogen-painted-erodibility-${seed}.png`;
case 'flow': return `orogen-painted-drainage-${seed}.png`;
case 'slope': return `orogen-painted-slope-${seed}.png`;
case 'basins': return `orogen-painted-basins-${seed}.png`;
case 'overlay': return `orogen-painted-overlay-${seed}.png`;
default: return `orogen-colormap-${seed}.png`;
}
}
// Batch export — builds geometry once, recolors per type. Avoids GPU memory
// exhaustion that occurs when exportMap is called multiple times in sequence.
export async function exportMapBatch(types, width, onProgress) {
if (!state.curData) return;
await new Promise(r => setTimeout(r, 50));
const height = width / 2;
const { mesh, r_xyz, t_xyz, r_elevation } = state.curData;
const debugLayers = state.curData.debugLayers;
const koppenArr = debugLayers && debugLayers.koppen;
const biomeSmoothed = koppenArr ? getCachedBiomeSmoothed(mesh, koppenArr, r_elevation) : null;
const { numSides, numTriangles } = mesh;
const PI = Math.PI;
const sx = 2 / PI;
// Precompute averaged elevation at each triangle center for smooth heightmap exports
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;
}
// Build positions once and record per-triangle vertex topology.
// Positions are reused across all export types — only colors change.
const posArr = new Float32Array(numSides * 18);
const triRegions = new Uint32Array(numSides * 2); // max 2 tris per side (wrapping)
const triInnerT = new Uint32Array(numSides * 2); // inner triangle index per output tri
const triOuterT = new Uint32Array(numSides * 2); // outer triangle index per output tri
let triCount = 0;
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 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)));
const clx = (v) => Math.max(-2, Math.min(2, v));
const cly = (v) => Math.max(-1, Math.min(1, v));
const maxLon = Math.max(lon0, lon1, lon2);
const minLon = Math.min(lon0, lon1, lon2);
const wraps = (maxLon - minLon) > PI;
if (wraps) {
if (lon0 < 0) lon0 += 2 * PI;
if (lon1 < 0) lon1 += 2 * PI;
if (lon2 < 0) lon2 += 2 * PI;
let off = triCount * 9;
posArr[off] = clx(lon0*sx); posArr[off+1] = cly(lat0*sx); posArr[off+2] = 0;
posArr[off+3] = clx(lon1*sx); posArr[off+4] = cly(lat1*sx); posArr[off+5] = 0;
posArr[off+6] = clx(lon2*sx); posArr[off+7] = cly(lat2*sx); posArr[off+8] = 0;
triRegions[triCount] = br; triInnerT[triCount] = it; triOuterT[triCount] = ot;
triCount++;
off = triCount * 9;
posArr[off] = clx((lon0-2*PI)*sx); posArr[off+1] = cly(lat0*sx); posArr[off+2] = 0;
posArr[off+3] = clx((lon1-2*PI)*sx); posArr[off+4] = cly(lat1*sx); posArr[off+5] = 0;
posArr[off+6] = clx((lon2-2*PI)*sx); posArr[off+7] = cly(lat2*sx); posArr[off+8] = 0;
triRegions[triCount] = br; triInnerT[triCount] = it; triOuterT[triCount] = ot;
triCount++;
} else {
const off = triCount * 9;
posArr[off] = clx(lon0*sx); posArr[off+1] = cly(lat0*sx); posArr[off+2] = 0;
posArr[off+3] = clx(lon1*sx); posArr[off+4] = cly(lat1*sx); posArr[off+5] = 0;
posArr[off+6] = clx(lon2*sx); posArr[off+7] = cly(lat2*sx); posArr[off+8] = 0;
triRegions[triCount] = br; triInnerT[triCount] = it; triOuterT[triCount] = ot;
triCount++;
}
}
// Trim position array to actual triangle count
const posData = new Float32Array(posArr.buffer, 0, triCount * 9);
const offScene = new THREE.Scene();
// Tiled rendering setup (shared across all types).
// Cap at 2048 to keep render-target + readback + ImageData under ~48 MB per tile.
const maxTex = renderer.capabilities.maxTextureSize;
const MAX_TILE = 2048;
const tileW = Math.min(width, maxTex, MAX_TILE);
const tileH = Math.min(height, maxTex, MAX_TILE);
const tilesX = Math.ceil(width / tileW);
const tilesY = Math.ceil(height / tileH);
const totalTiles = tilesX * tilesY;
const code = location.hash.replace(/^#/, '').trim() || (state.curData ? state.curData.seed : '');
const total = types.length;
// Pre-allocate pixel readback buffer (reused across all tiles and 8-bit types)
const pixelBuf = new Uint8Array(tileW * tileH * 4);
const floatBuf = new Float32Array(tileW * tileH * 4);
// Single canvas reused across 8-bit export types (avoids repeated bitmap allocation)
const cvs = document.createElement('canvas');
cvs.width = width;
cvs.height = height;
const ctx = cvs.getContext('2d');
for (let ti = 0; ti < total; ti++) {
const { type, label } = types[ti];
const isBW = type === 'heightmap' || type === 'landheightmap' || type === 'landmask';
const is16Bit = type === 'heightmap' || type === 'landheightmap';
offScene.background = isBW ? new THREE.Color(0x000000) : new THREE.Color(0x1a1a2e);
const paintedLayer = PAINTED_EXPORT_TYPES[type] || null;
const paintedCtx = paintedLayer ? preparePaintedLayer(paintedLayer, debugLayers && debugLayers[paintedLayer], state.curData) : null;
// 16-bit heightmaps write to a Uint16Array instead of the canvas
let img16;
if (is16Bit) img16 = new Uint16Array(width * height);
// Build fresh color array for this type
const colData = new Float32Array(triCount * 9);
for (let i = 0; i < triCount; i++) {
const br = triRegions[i];
const off = i * 9;
if (is16Bit) {
// Smooth heightmap: triangle-center vertices use averaged elevation
const colorFn = type === 'landheightmap' ? landHeightmapColor : heightmapColor;
const v0 = colorFn(t_elev[triInnerT[i]])[0];
const v1 = colorFn(t_elev[triOuterT[i]])[0];
const v2 = colorFn(r_elevation[br])[0];
colData[off] = colData[off+1] = colData[off+2] = v0;
colData[off+3] = colData[off+4] = colData[off+5] = v1;
colData[off+6] = colData[off+7] = colData[off+8] = v2;
} else {
let cr, cg, cb;
if (type === 'landmask') {
[cr, cg, cb] = landMaskColor(r_elevation[br]);
} else if (paintedCtx) {
[cr, cg, cb] = paintedCtx.color(br);
} else if (type === 'biome' && biomeSmoothed) {
cr = biomeSmoothed[br * 3]; cg = biomeSmoothed[br * 3 + 1]; cb = biomeSmoothed[br * 3 + 2];
} else if (type === 'koppen' && koppenArr) {
[cr, cg, cb] = koppenColor(koppenArr[br]);
} else {
[cr, cg, cb] = elevationToColor(r_elevation[br]);
}
colData[off] = colData[off+3] = colData[off+6] = cr;
colData[off+1] = colData[off+4] = colData[off+7] = cg;
colData[off+2] = colData[off+5] = colData[off+8] = cb;
}
}
// Fresh geometry + mesh per type — avoids stale GPU buffer issues
// when the same renderer interleaves with the main animation loop.
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(posData, 3));
geo.setAttribute('color', new THREE.BufferAttribute(colData, 3));
const mat = new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.DoubleSide });
const mapMesh = new THREE.Mesh(geo, mat);
offScene.add(mapMesh);
// Render tiles
let tilesDone = 0;
for (let ty = 0; ty < tilesY; ty++) {
for (let tx = 0; tx < tilesX; tx++) {
const px0 = tx * tileW;
const py0 = ty * tileH;
const pw = Math.min(tileW, width - px0);
const ph = Math.min(tileH, height - py0);
const left = -2 + 4 * px0 / width;
const right = -2 + 4 * (px0 + pw) / width;
const top = 1 - 2 * py0 / height;
const bottom = 1 - 2 * (py0 + ph) / height;
const cam = new THREE.OrthographicCamera(left, right, top, bottom, 0.1, 10);
cam.position.set(0, 0, 5);
cam.lookAt(0, 0, 0);
if (is16Bit) {
const renderTarget = new THREE.WebGLRenderTarget(pw, ph, { type: THREE.FloatType });
const prevCS = renderer.outputColorSpace;
renderer.outputColorSpace = THREE.LinearSRGBColorSpace;
renderer.setRenderTarget(renderTarget);
renderer.render(offScene, cam);
renderer.outputColorSpace = prevCS;
renderer.readRenderTargetPixels(renderTarget, 0, 0, pw, ph, floatBuf);
renderer.setRenderTarget(null);
renderTarget.dispose();
for (let y = 0; y < ph; y++) {
const srcRow = (ph - 1 - y) * pw;
const dstRow = (py0 + y) * width + px0;
for (let x = 0; x < pw; x++) {
const v = floatBuf[(srcRow + x) * 4];
img16[dstRow + x] = Math.max(0, Math.min(65535, (v * 65535 + 0.5) | 0));
}
}
} else {
const renderTarget = new THREE.WebGLRenderTarget(pw, ph);
renderer.setRenderTarget(renderTarget);
renderer.render(offScene, cam);
renderer.readRenderTargetPixels(renderTarget, 0, 0, pw, ph, pixelBuf);
renderer.setRenderTarget(null);
renderTarget.dispose();
const imageData = ctx.createImageData(pw, ph);
const out = imageData.data;
for (let y = 0; y < ph; y++) {
const src = (ph - 1 - y) * pw * 4;
const dst = y * pw * 4;
for (let x = 0; x < pw; x++) {
const si = src + x * 4, di = dst + x * 4;
for (let c = 0; c < 3; c++) {
const v = pixelBuf[si + c] / 255;
out[di + c] = (v <= 0.0031308
? v * 12.92
: 1.055 * Math.pow(v, 1 / 2.4) - 0.055) * 255 + 0.5 | 0;
}
out[di + 3] = pixelBuf[si + 3];
}
}
ctx.putImageData(imageData, px0, py0);
}
tilesDone++;
if (onProgress) onProgress(tilesDone / totalTiles * 80, `Exporting ${label} (${ti+1}/${total}): Rendering...`);
await new Promise(r => setTimeout(r, 0));
}
}
// Free GPU resources before PNG encode
offScene.remove(mapMesh);
geo.dispose();
mat.dispose();
// The overlay export is the dimmed class map with the sheet composited over it, as in exportMap.
if (!is16Bit && type === 'overlay') compositeOverlaySheet(ctx, width, height);
// Encode & download
if (onProgress) onProgress(85, `Exporting ${label} (${ti+1}/${total}): Encoding PNG...`);
await new Promise(r => setTimeout(r, 0));
const filename = exportFilename(type, code);
if (is16Bit) {
const blob = await encode16BitGrayscalePNG(width, height, img16);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 5000);
} else {
await new Promise(resolve => {
cvs.toBlob(blob => {
if (blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 5000);
}
resolve();
}, 'image/png');
});
}
// Pause between exports to let the browser reclaim memory
await new Promise(r => setTimeout(r, 100));
}
// Release canvas bitmap after all exports
cvs.width = 0;
cvs.height = 0;
}