import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three'; import { observer } from '../src/config/observer.ts'; import { createTerrainGeometry, terrainGeometryHeightAt, terrainHeightAt, validateTerrain } from '../src/scene/terrainGeometry.ts'; import type { TerrainMetadata } from '../src/scene/terrainGeometry.ts'; import { calculateSun } from '../src/astronomy/sunCalculator.ts'; import { directionFromAngles } from '../src/astronomy/direction.ts'; import { SCENE } from '../src/constants/scene.ts'; const metadata: TerrainMetadata = JSON.parse(readFileSync(new URL('../public/terrain/gwanaksan.json', import.meta.url), 'utf8')); const bytes = readFileSync(new URL('../public/terrain/gwanaksan.f32', import.meta.url)); const heights = new Float32Array(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)); test('near terrain refinement preserves samples and bounds interpolated elevations', () => { const g = createTerrainGeometry(metadata, heights, true); const p = g.getAttribute('position'); assert.ok(p.count > heights.length, 'nearby terrain needs intermediate vertices'); let foundOrigin = false; for (let i = 0; i < p.count; i++) { const x = p.getX(i), z = p.getZ(i); if (x === 0 && z === 0) { foundOrigin = true; assert.ok(Math.abs(p.getY(i)) < 1e-5); } if (Math.abs(x) > 100 || Math.abs(z) > 100) continue; const col = Math.min(metadata.width - 2, Math.floor((x + metadata.radiusMeters) / metadata.spacingMeters)); const row = Math.min(metadata.height - 2, Math.floor((z + metadata.radiusMeters) / metadata.spacingMeters)); const k = row * metadata.width + col; const corners = [heights[k], heights[k+1], heights[k+metadata.width], heights[k+metadata.width+1]]; const h = p.getY(i) + metadata.observerSurfaceElevation + (x*x+z*z)/(2*6371008.8); assert.ok(h >= Math.min(...corners)-0.001 && h <= Math.max(...corners)+0.001, 'no invented peaks or pits'); } assert.ok(foundOrigin); const mesh = new Mesh(g, new MeshBasicMaterial()); for (const [x,z] of [[2,3],[-17.5,28.2],[179.5,180.5],[210.4,-302.8]]) { const ray = new Raycaster(new Vector3(x,1000,z),new Vector3(0,-1,0)); const hit = ray.intersectObject(mesh)[0]; assert.ok(hit); assert.ok(Math.abs(terrainGeometryHeightAt(g,x,z)-hit.point.y)<1e-4, 'walking follows the actual refined triangle'); } mesh.material.dispose(); g.dispose(); }); test('downloaded grid covers both longitude tiles, no holes, and observer surface is consistent', () => { validateTerrain(metadata, heights, observer); assert.equal(metadata.width, 801); assert.equal(heights.length, 801 * 801); assert.equal(metadata.sources.length, 2); assert.ok(metadata.geographicCorners.some(([lon]) => lon < 127)); assert.ok(metadata.geographicCorners.some(([lon]) => lon > 127)); assert.equal(terrainHeightAt(metadata, heights, 0, 0), metadata.observerSurfaceElevation); assert.ok(metadata.observerSurfaceElevation > 500 && metadata.observerSurfaceElevation < 650); assert.throws(() => validateTerrain(metadata, heights.subarray(1), observer)); assert.throws(() => validateTerrain(metadata, heights, { ...observer, longitude: 127 })); }); test('mesh keeps east +X, north -Z, true scale, and the camera above its center', () => { const geometry = createTerrainGeometry(metadata, heights); const positions = geometry.getAttribute('position'); assert.equal(positions.getX(0), -12000); assert.equal(positions.getZ(0), -12000); assert.equal(positions.getX(800), 12000); assert.equal(positions.getZ(801 * 800), 12000); assert.equal(positions.getY(400 * 801 + 400), 0); const expectedCorner = heights[0] - metadata.observerSurfaceElevation - (12000 ** 2 * 2) / (2 * 6371008.8); assert.ok(Math.abs(positions.getY(0) - expectedCorner) < 0.001); const mesh = new Mesh(geometry, new MeshBasicMaterial()); const hits = new Raycaster(new Vector3(0, observer.eyeHeight, 0), new Vector3(0, -1, 0)).intersectObject(mesh); assert.ok(hits.length > 0, 'triangles must face upward and be visible to a downward ray'); assert.ok(Math.abs(hits[0].distance - observer.eyeHeight) < 0.001); geometry.dispose(); }); test('a synthetic ridge blocks a low sun direction and allows a higher one', () => { const grid = { ...metadata, width: 3, height: 3, spacingMeters: 30, radiusMeters: 30, observerSurfaceElevation: 0 }; const data = new Float32Array([0, 0, 12, 0, 0, 12, 0, 0, 12]); assert.equal(terrainHeightAt(grid, data, 15, 0), 6); const geometry = createTerrainGeometry(grid, data); const mesh = new Mesh(geometry, new MeshBasicMaterial()); const origin = new Vector3(0, 1.7, 0); assert.ok(new Raycaster(origin, new Vector3(1, 0.1, 0).normalize()).intersectObject(mesh).length > 0); assert.equal(new Raycaster(origin, new Vector3(1, 1, 0).normalize()).intersectObject(mesh).length, 0); geometry.dispose(); }); test('real Gwanaksan ridge blocks the March 06:40 sun center but clears it by 06:50', () => { const geometry = createTerrainGeometry(metadata, heights, true); const mesh = new Mesh(geometry, new MeshBasicMaterial()); for (const [time, blocked] of [['06:40', true], ['06:50', false]] as const) { const sun = calculateSun(new Date(`2026-03-20T${time}:00+09:00`), observer); const direction = new Vector3(...directionFromAngles(sun.azimuth, sun.altitude)); const hits = new Raycaster(new Vector3(0, observer.eyeHeight, 0), direction, 0, SCENE.sunDistance).intersectObject(mesh); assert.equal(hits.length > 0, blocked, time); } assert.ok(SCENE.sunDistance > Math.SQRT2 * metadata.radiusMeters); geometry.dispose(); });