98 lines
5.6 KiB
TypeScript
98 lines
5.6 KiB
TypeScript
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();
|
|
});
|