2410 lines
105 KiB
JavaScript
2410 lines
105 KiB
JavaScript
// 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;
|
||
}
|