Files
sunrise/tests/structures.test.ts
T

107 lines
6.0 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { Box3, Mesh, Raycaster, Vector3 } from 'three';
import { createStructures, structurePosition } from '../src/scene/structures.ts';
import { observer } from '../src/config/observer.ts';
import { readFile } from 'node:fs/promises';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { STRUCTURES } from '../src/constants/structures.ts';
import { createTerrainGeometry, terrainGeometryHeightAt } from '../src/scene/terrainGeometry.ts';
async function assetModels() {
const ids = [...STRUCTURES.towers.map(s => s.id), 'weather-radar', ...STRUCTURES.decks.map(s => s.id)];
return new Map(await Promise.all(ids.map(async id => {
const bytes = await readFile(new URL(`../public/models/summit/${id}.glb`, import.meta.url));
const gltf = await new GLTFLoader().parseAsync(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), '');
return [id, gltf.scene] as const;
})));
}
test('structure coordinates use meters, east +X and north -Z around the existing observer', () => {
assert.equal(structurePosition(observer.latitude, observer.longitude, observer).length(), 0);
const north = structurePosition(observer.latitude + 0.001, observer.longitude, observer);
const east = structurePosition(observer.latitude, observer.longitude + 0.001, observer);
assert.ok(north.z < -110 && north.z > -112);
assert.ok(east.x > 88 && east.x < 90);
});
test('structures occlude rays through solid geometry, clear overhead rays, and have finite bounds', async () => {
const assets = await assetModels();
const structures = createStructures(observer, (x, z) => x * 0.02 + z * 0.03, assets);
assert.equal(structures.group.children.length, 6);
assert.equal(assets.has('north-deck'), false);
assert.equal(structures.group.getObjectByName('north-deck'), undefined);
for (const [id, model] of assets) assert.equal(model.parent?.name, id, 'Actual GLB is placed once');
structures.group.updateMatrixWorld(true);
const radar = structures.group.getObjectByName('weather-radar')!;
const bounds = new Box3().setFromObject(structures.group);
assert.ok([...bounds.min.toArray(), ...bounds.max.toArray()].every(Number.isFinite));
const ray = new Raycaster(radar.position.clone().add(new Vector3(-20, 3, 0)), new Vector3(1, 0, 0), 0, 40);
assert.ok(ray.intersectObject(radar, true).length > 0);
ray.ray.origin.y += 100;
assert.equal(ray.intersectObject(radar, true).length, 0);
structures.group.traverse(object => {
if (object instanceof Mesh) {
assert.equal(object.material.depthTest, true);
assert.equal(object.material.depthWrite, true);
}
});
structures.dispose();
});
test('rotated deck supports join the GLB anchors to sloped ground and resources are disposed', async () => {
const assets = await assetModels();
const groundAt = (x: number, z: number) => x * .2 - z * .12;
const structures = createStructures(observer, groundAt, assets);
structures.group.updateMatrixWorld(true);
for (const spec of STRUCTURES.decks) {
const deck = structures.group.getObjectByName(spec.id)!;
assert.ok(Math.abs(deck.rotation.y - spec.heading * Math.PI / 180) < 1e-8);
const modelRoot = assets.get(spec.id)!.getObjectByName(spec.id)!;
const anchors = JSON.parse(modelRoot.userData.terrainSupportAnchorsJson) as { position: [number, number, number] }[];
const supports = deck.getObjectByName('terrain-supports')!;
assert.equal(supports.children.length, anchors.length);
supports.children.forEach((support, index) => {
const bounds = new Box3().setFromObject(support);
const anchor = deck.localToWorld(new Vector3(...anchors[index].position));
assert.ok(bounds.max.y >= anchor.y && bounds.max.y < anchor.y + .05);
assert.ok(bounds.min.y < groundAt(anchor.x, anchor.z));
});
}
let disposed = 0;
const geometry = (assets.get('weather-radar')!.getObjectByProperty('isMesh', true) as Mesh).geometry;
geometry.addEventListener('dispose', () => disposed++);
structures.dispose();
assert.equal(disposed, 1);
});
test('real north deck has a continuous entrance and exit reaching the rendered ground', { skip: !STRUCTURES.decks.some(s => s.id === 'north-deck') }, async () => {
const metadata = JSON.parse(await readFile(new URL('../public/terrain/gwanaksan.json', import.meta.url), 'utf8'));
const bytes = await readFile(new URL('../public/terrain/gwanaksan.f32', import.meta.url));
const heights = new Float32Array(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
const terrain = createTerrainGeometry(metadata, heights, true);
const ground = (x: number, z: number) => terrainGeometryHeightAt(terrain, x, z);
const structures = createStructures(metadata.origin, ground, await assetModels());
structures.group.updateMatrixWorld(true);
for (const spec of STRUCTURES.decks.filter(s => s.id === 'north-deck')) {
const deck = structures.group.getObjectByName(spec.id)!;
for (const name of ['entry-access', 'exit-access']) {
const access = deck.getObjectByName(name);
assert.ok(access, `${spec.id}: ${name} must join the open end to terrain`);
const { startZ, endZ, topY } = access.userData;
let previous = topY + deck.position.y;
const samples = Math.ceil(Math.abs(endZ - startZ) / .05);
for (let i = 1; i <= samples; i++) {
const p = deck.localToWorld(new Vector3(0, 0, startZ + (endZ - startZ) * Math.min(i / samples, 1 - 1e-6)));
const hits = new Raycaster(new Vector3(p.x, 10000, p.z), new Vector3(0, -1, 0)).intersectObject(access, true);
assert.ok(hits.length, `${spec.id} ${name} sample ${i}: no hole in the access surface`);
const surface = Math.max(hits[0].point.y, ground(p.x, p.z));
assert.ok(Math.abs(surface - previous) < .25, `${spec.id} ${name} sample ${i}: step ${surface - previous}`);
previous = surface;
if (i === samples) assert.ok(Math.abs(surface - ground(p.x, p.z)) < .05, 'Landing meets terrain');
}
}
}
structures.dispose();
});