diff --git a/guide/engine/src/components/climate-sim/HexGLRenderer.tsx b/guide/engine/src/components/climate-sim/HexGLRenderer.tsx index 39383094..5cf701cf 100644 --- a/guide/engine/src/components/climate-sim/HexGLRenderer.tsx +++ b/guide/engine/src/components/climate-sim/HexGLRenderer.tsx @@ -8,140 +8,21 @@ import { GLOW_OFFSETS, MAX_VISIBLE_LEY_EDGES, WIND_PARTICLE_COUNT, } from '@magic-civ/engine-ts' import { FIELD_VERT, FIELD_FRAG, POLAR_VERT, POLAR_FRAG, WONDER_VERT, WONDER_FRAG } from './hexGLShaders' +import { buildEquatorLayout, buildPolarLayout, computeHexColors } from './polarHexGrid' +import type { HexGridLayout } from './polarHexGrid' const MAP_W = GRID_WIDTH const MAP_H = GRID_HEIGHT -// ── terrain color lookup (mirrors GLSL terrainColor for polar vertex colors) ── -const TERRAIN_COLORS: [number, number, number][] = [ - [0.24, 0.47, 0.82], [0.31, 0.71, 0.86], [0.31, 0.63, 0.84], [0.25, 0.51, 0.80], - [0.88, 0.94, 1.00], [0.94, 0.96, 1.00], [0.72, 0.76, 0.65], [0.87, 0.78, 0.50], - [0.73, 0.82, 0.53], [0.38, 0.72, 0.35], [0.20, 0.55, 0.25], [0.22, 0.44, 0.30], - [0.09, 0.45, 0.18], [0.42, 0.85, 0.55], [0.58, 0.52, 0.38], [0.62, 0.60, 0.58], - [0.24, 0.31, 0.14], [0.75, 0.20, 0.08], [0.85, 0.70, 1.00], [0.60, 0.95, 0.70], - [0.70, 0.70, 0.85], [0.75, 0.85, 1.00], [0.55, 0.40, 0.25], [0.40, 0.80, 0.75], - [0.30, 0.50, 0.80], -] -function terrainColorJS(encoded: number): [number, number, number] { - const idx = Math.round(encoded * 24) - return TERRAIN_COLORS[Math.min(idx, TERRAIN_COLORS.length - 1)] ?? TERRAIN_COLORS[0]! -} - -// ── polar hex mesh builder ──────────────────────────────────────────────── -// Builds a geodesic hex grid radiating from a single center tile (the pole). -// Ring 0 = 1 hex, ring N = 6N hexes. Each hex maps to the nearest (col,row) -// on the 40×24 equirectangular grid for data lookup. -interface PolarHexData { - geometry: THREE.BufferGeometry - /** (col, row) for each hex tile, used to sample data textures */ - dataCoords: Array<{ col: number; row: number }> - hexCount: number -} - -function buildPolarHexGrid( - pole: 'north' | 'south', - numRings: number, - canvasW: number, - canvasH: number, -): PolarHexData { - // Hex circumradius in pixel space — matches equator hex size - const hexR = HEX_W / 2 - const hexH = hexR * Math.sqrt(3) / 2 // inradius (center to flat edge) - - // Center of the canvas in pixel space - const cx = canvasW / 2 - const cy = canvasH / 2 - - // Collect hex tile centers and their data coordinates - const tiles: Array<{ x: number; y: number; col: number; row: number }> = [] - - for (let ring = 0; ring <= numRings; ring++) { - if (ring === 0) { - // Single center tile = the pole - const poleRow = pole === 'north' ? 0 : MAP_H - 1 - tiles.push({ x: cx, y: cy, col: Math.floor(MAP_W / 2), row: poleRow }) - continue - } - - const tilesInRing = 6 * ring - for (let i = 0; i < tilesInRing; i++) { - // Position using hex ring enumeration (flat-top hex spacing) - // Angle of this tile in the ring - const angle = (i / tilesInRing) * Math.PI * 2 - Math.PI / 2 - - // Distance from center: ring * hex spacing (flat-top: row spacing = hexH * 2) - const dist = ring * hexH * 2 - - const tx = cx + dist * Math.cos(angle) - const ty = cy + dist * Math.sin(angle) - - // Map to grid (col, row): ring → row offset from pole, angle → column - const gridRow = pole === 'north' ? ring : (MAP_H - 1 - ring) - const clampedRow = Math.max(0, Math.min(MAP_H - 1, gridRow)) - - // Angle → column: distribute evenly across 40 columns - const lonFrac = ((angle + Math.PI / 2) / (Math.PI * 2) + 1) % 1 - const gridCol = Math.floor(lonFrac * MAP_W) % MAP_W - - tiles.push({ x: tx, y: ty, col: gridCol, row: clampedRow }) - } - } - - return buildHexGeometry(tiles, hexR) -} - -/** Update hex mesh vertex colors from simulation data. Shared by equator and polar meshes. */ -function updateHexMeshColors( - colorAttr: THREE.BufferAttribute, - dataCoords: Array<{ col: number; row: number }>, - texB: Float32Array, -): void { +/** Build a Three.js BufferGeometry from a hex grid layout. */ +function layoutToGeometry(layout: HexGridLayout): { geometry: THREE.BufferGeometry; dataCoords: Array<{ col: number; row: number }> } { + const { tiles, hexR } = layout const vertsPerHex = 7 - for (let h = 0; h < dataCoords.length; h++) { - const { col, row } = dataCoords[h]! - const texIdx = (row * MAP_W + col) * 4 - const terrainEnc = texB[texIdx + 2] ?? 0 - const [r, g, b] = terrainColorJS(terrainEnc) - for (let v = 0; v < vertsPerHex; v++) { - const ci = (h * vertsPerHex + v) * 3 - colorAttr.array[ci] = r - colorAttr.array[ci + 1] = g - colorAttr.array[ci + 2] = b - } - } - colorAttr.needsUpdate = true -} - -// ── equator hex mesh builder ────────────────────────────────────────────── -// Standard flat hex grid: 40 cols × 24 rows. Same hexCenter() positioning -// as the GLSL shader, ensuring identical tile mapping. -function buildEquatorHexGrid(): PolarHexData { - const hexR = HEX_W / 2 - const tiles: Array<{ x: number; y: number; col: number; row: number }> = [] - - for (let row = 0; row < MAP_H; row++) { - for (let col = 0; col < MAP_W; col++) { - const cx = col * HEX_W * 0.75 + HEX_W / 2 - const cy = row * HEX_H + (col % 2 === 1 ? HEX_H / 2 : 0) + HEX_H / 2 - tiles.push({ x: cx, y: cy, col, row }) - } - } - - return buildHexGeometry(tiles, hexR) -} - -/** Shared geometry builder: takes positioned tiles and creates a BufferGeometry. */ -function buildHexGeometry( - tiles: Array<{ x: number; y: number; col: number; row: number }>, - hexR: number, -): PolarHexData { - const hexCount = tiles.length - const vertsPerHex = 7 - const positions = new Float32Array(hexCount * vertsPerHex * 3) - const colors = new Float32Array(hexCount * vertsPerHex * 3) + const positions = new Float32Array(tiles.length * vertsPerHex * 3) + const colors = new Float32Array(tiles.length * vertsPerHex * 3) const indices: number[] = [] - for (let h = 0; h < hexCount; h++) { + for (let h = 0; h < tiles.length; h++) { const tile = tiles[h]! const base = h * vertsPerHex positions[base * 3] = tile.x @@ -163,7 +44,18 @@ function buildHexGeometry( geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)) geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)) geometry.setIndex(indices) - return { geometry, dataCoords: tiles.map((t) => ({ col: t.col, row: t.row })), hexCount } + return { geometry, dataCoords: tiles.map((t) => ({ col: t.col, row: t.row })) } +} + +/** Update vertex colors on a hex mesh from simulation data. */ +function updateHexMeshColors( + colorAttr: THREE.BufferAttribute, + dataCoords: Array<{ col: number; row: number }>, + texB: Float32Array, +): void { + const newColors = computeHexColors(dataCoords, texB) + ;(colorAttr.array as Float32Array).set(newColors) + colorAttr.needsUpdate = true } function hexCenter(col: number, row: number): [number, number] { @@ -338,7 +230,7 @@ export function HexGLRenderer({ snapshot, referenceSnapshot, layerMask, width, h }) function makePolarMesh(pole: 'north' | 'south', rings: number): PolarMeshState { - const { geometry, dataCoords } = buildPolarHexGrid(pole, rings, mapPxW, mapPxH) + const { geometry, dataCoords } = layoutToGeometry(buildPolarLayout(pole, rings, mapPxW, mapPxH)) const mesh = new THREE.Mesh(geometry, polarMat) mesh.visible = false polarScene.add(mesh) @@ -352,7 +244,7 @@ export function HexGLRenderer({ snapshot, referenceSnapshot, layerMask, width, h let polarSouth = makePolarMesh('south', DEFAULT_POLAR_RINGS) // ── equator hex mesh (same geometry approach as polar, flat grid layout) ── - const equatorData = buildEquatorHexGrid() + const equatorData = layoutToGeometry(buildEquatorLayout()) const equatorMesh = new THREE.Mesh(equatorData.geometry, polarMat) equatorScene.add(equatorMesh) updateHexMeshColors( @@ -545,7 +437,7 @@ export function HexGLRenderer({ snapshot, referenceSnapshot, layerMask, width, h const old = pole === 'north' ? g.polarNorth : g.polarSouth g.polarScene.remove(old.mesh) old.mesh.geometry.dispose() - const { geometry, dataCoords } = buildPolarHexGrid(pole, rings, mapPxW, mapPxH) + const { geometry, dataCoords } = layoutToGeometry(buildPolarLayout(pole, rings, mapPxW, mapPxH)) const mesh = new THREE.Mesh(geometry, old.mesh.material) mesh.visible = old.mesh.visible g.polarScene.add(mesh) diff --git a/guide/engine/src/components/climate-sim/polarHexGrid.ts b/guide/engine/src/components/climate-sim/polarHexGrid.ts new file mode 100644 index 00000000..c405fb4d --- /dev/null +++ b/guide/engine/src/components/climate-sim/polarHexGrid.ts @@ -0,0 +1,132 @@ +/** + * Pure-logic functions for building hex grid tile layouts. + * Used by both equator (flat grid) and polar (geodesic ring) views. + * Separated from HexGLRenderer.tsx so they can be unit tested without Three.js. + */ +import { HEX_W, HEX_H, GRID_WIDTH, GRID_HEIGHT } from '@magic-civ/engine-ts' + +const MAP_W = GRID_WIDTH +const MAP_H = GRID_HEIGHT + +// ── terrain color lookup ────────────────────────────────────────────────── +// Mirrors the GLSL terrainColor() function for vertex coloring. +const TERRAIN_COLORS: [number, number, number][] = [ + [0.24, 0.47, 0.82], [0.31, 0.71, 0.86], [0.31, 0.63, 0.84], [0.25, 0.51, 0.80], + [0.88, 0.94, 1.00], [0.94, 0.96, 1.00], [0.72, 0.76, 0.65], [0.87, 0.78, 0.50], + [0.73, 0.82, 0.53], [0.38, 0.72, 0.35], [0.20, 0.55, 0.25], [0.22, 0.44, 0.30], + [0.09, 0.45, 0.18], [0.42, 0.85, 0.55], [0.58, 0.52, 0.38], [0.62, 0.60, 0.58], + [0.24, 0.31, 0.14], [0.75, 0.20, 0.08], [0.85, 0.70, 1.00], [0.60, 0.95, 0.70], + [0.70, 0.70, 0.85], [0.75, 0.85, 1.00], [0.55, 0.40, 0.25], [0.40, 0.80, 0.75], + [0.30, 0.50, 0.80], +] + +export function terrainColorJS(encoded: number): [number, number, number] { + const idx = Math.round(encoded * 24) + return TERRAIN_COLORS[Math.min(idx, TERRAIN_COLORS.length - 1)] ?? TERRAIN_COLORS[0]! +} + +// ── tile layout types ───────────────────────────────────────────────────── +export interface HexTile { + x: number // screen position + y: number + col: number // data grid coordinate + row: number +} + +export interface HexGridLayout { + tiles: HexTile[] + hexR: number // circumradius of each hex tile +} + +// ── equator hex grid (flat 40×24 layout) ────────────────────────────────── +export function buildEquatorLayout(): HexGridLayout { + const hexR = HEX_W / 2 + const tiles: HexTile[] = [] + + for (let row = 0; row < MAP_H; row++) { + for (let col = 0; col < MAP_W; col++) { + const x = col * HEX_W * 0.75 + HEX_W / 2 + const y = row * HEX_H + (col % 2 === 1 ? HEX_H / 2 : 0) + HEX_H / 2 + tiles.push({ x, y, col, row }) + } + } + + return { tiles, hexR } +} + +// ── polar hex grid (geodesic ring layout) ───────────────────────────────── +// Ring 0: 1 hex (the pole). Ring N: 6N hexes radiating outward. +// Each tile maps to the nearest (col, row) on the 40×24 equirectangular grid. +export function buildPolarLayout( + pole: 'north' | 'south', + numRings: number, + canvasW: number, + canvasH: number, +): HexGridLayout { + const hexR = HEX_W / 2 + const hexInradius = hexR * Math.sqrt(3) / 2 + const cx = canvasW / 2 + const cy = canvasH / 2 + const tiles: HexTile[] = [] + + for (let ring = 0; ring <= numRings; ring++) { + if (ring === 0) { + const poleRow = pole === 'north' ? 0 : MAP_H - 1 + tiles.push({ x: cx, y: cy, col: Math.floor(MAP_W / 2), row: poleRow }) + continue + } + + const tilesInRing = 6 * ring + const dist = ring * hexInradius * 2 + + for (let i = 0; i < tilesInRing; i++) { + const angle = (i / tilesInRing) * Math.PI * 2 - Math.PI / 2 + const tx = cx + dist * Math.cos(angle) + const ty = cy + dist * Math.sin(angle) + + // ring → row offset from pole + const gridRow = pole === 'north' ? ring : (MAP_H - 1 - ring) + const clampedRow = Math.max(0, Math.min(MAP_H - 1, gridRow)) + + // angle → column, evenly distributed across the 40-column grid + const lonFrac = ((angle + Math.PI / 2) / (Math.PI * 2) + 1) % 1 + const gridCol = Math.floor(lonFrac * MAP_W) % MAP_W + + tiles.push({ x: tx, y: ty, col: gridCol, row: clampedRow }) + } + } + + return { tiles, hexR } +} + +// ── hex tile count for a given ring count ───────────────────────────────── +export function polarTileCount(numRings: number): number { + // Ring 0 = 1, ring N = 6N. Total = 1 + 6*(1+2+...+N) = 1 + 3*N*(N+1) + return 1 + 3 * numRings * (numRings + 1) +} + +// ── vertex color computation ────────────────────────────────────────────── +// Computes flat RGB colors for each hex from the simulation data texture. +// Returns a Float32Array of [r,g,b] × 7 vertices per hex. +export function computeHexColors( + dataCoords: Array<{ col: number; row: number }>, + texB: Float32Array, +): Float32Array { + const vertsPerHex = 7 + const colors = new Float32Array(dataCoords.length * vertsPerHex * 3) + + for (let h = 0; h < dataCoords.length; h++) { + const { col, row } = dataCoords[h]! + const texIdx = (row * MAP_W + col) * 4 + const terrainEnc = texB[texIdx + 2] ?? 0 + const [r, g, b] = terrainColorJS(terrainEnc) + for (let v = 0; v < vertsPerHex; v++) { + const ci = (h * vertsPerHex + v) * 3 + colors[ci] = r + colors[ci + 1] = g + colors[ci + 2] = b + } + } + + return colors +}