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(); });