82 lines
4.9 KiB
JavaScript
82 lines
4.9 KiB
JavaScript
// Verify the exported artifacts using the same Three.js loader as the application.
|
||
import assert from 'node:assert/strict';
|
||
import { readFileSync, writeFileSync } from 'node:fs';
|
||
import { resolve } from 'node:path';
|
||
import { Box3, Raycaster, Vector3 } from 'three';
|
||
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
||
import { STRUCTURES } from '../src/constants/structures.ts';
|
||
|
||
const directory = resolve('public/models/summit');
|
||
const manifest = JSON.parse(readFileSync(resolve(directory, 'manifest.json'), 'utf8'));
|
||
const specs = [...STRUCTURES.towers, { id: 'weather-radar', ...STRUCTURES.radar }, ...STRUCTURES.decks];
|
||
assert.deepEqual(manifest.assets.map(a => a.id).sort(), specs.map(s => s.id).sort());
|
||
const report = { loader: 'Three.js GLTFLoader', checks: 'passed', assets: [] };
|
||
for (const asset of manifest.assets) {
|
||
const spec = specs.find(s => s.id === asset.id);
|
||
const bytes = readFileSync(resolve(directory, asset.file));
|
||
assert.equal(bytes.toString('ascii', 0, 4), 'glTF');
|
||
assert.equal(bytes.readUInt32LE(4), 2);
|
||
assert.equal(bytes.readUInt32LE(8), bytes.length);
|
||
const jsonLength = bytes.readUInt32LE(12);
|
||
const json = JSON.parse(bytes.toString('utf8', 20, 20 + jsonLength));
|
||
assert.equal(json.scenes.length, 1, `${asset.id}: exactly one scene`);
|
||
assert.equal(json.scenes[0].nodes.length, 1, `${asset.id}: exactly one asset root`);
|
||
assert.ok(!(json.images?.length), 'No reference images or external textures');
|
||
assert.ok(!json.buffers.some(b => b.uri), 'Embedded geometry only');
|
||
assert.ok(!(json.cameras?.length), 'No studio camera');
|
||
assert.ok(!json.nodes.some(n => /Cube|Studio|Camera/.test(n.name ?? '')), 'No unrelated scene objects');
|
||
assert.equal(asset.latitude, spec.latitude);
|
||
assert.equal(asset.longitude, spec.longitude);
|
||
assert.equal(asset.heading, spec.heading ?? 0);
|
||
for (const [key, value] of Object.entries(asset.dimensions)) assert.equal(value, spec[key]);
|
||
const data = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||
const gltf = await new GLTFLoader().parseAsync(data, '');
|
||
const root = gltf.scene.getObjectByName(asset.id);
|
||
assert.ok(root, `${asset.id}: named root survives export`);
|
||
assert.ok(root.position.length() < 1e-6, 'No gallery offset baked into GLB');
|
||
assert.equal(root.userData.dimensionsEstimated, true);
|
||
let triangles = 0, meshes = 0;
|
||
root.traverse(object => {
|
||
if (!object.isMesh) return;
|
||
meshes++;
|
||
const geometry = object.geometry;
|
||
const positions = geometry.getAttribute('position');
|
||
assert.ok(positions.count > 0);
|
||
assert.ok([...positions.array].every(Number.isFinite), 'Finite vertex coordinates');
|
||
const normals = geometry.getAttribute('normal');
|
||
assert.ok(normals && [...normals.array].every(Number.isFinite), 'Finite normals');
|
||
if (geometry.index) {
|
||
assert.ok([...geometry.index.array].every(i => i < positions.count));
|
||
triangles += geometry.index.count / 3;
|
||
} else triangles += positions.count / 3;
|
||
assert.ok(object.material.isMeshStandardMaterial, 'PBR material survives');
|
||
});
|
||
gltf.scene.updateMatrixWorld(true);
|
||
const bounds = new Box3().setFromObject(root);
|
||
const size = bounds.getSize(new Vector3());
|
||
assert.ok([...bounds.min.toArray(), ...bounds.max.toArray()].every(Number.isFinite));
|
||
if (asset.kind === 'tower') {
|
||
assert.ok(Math.abs(bounds.max.y - (spec.height + 4)) < .01, 'Original tower height + 4m antenna');
|
||
assert.ok(Math.abs(bounds.min.y) < .15, 'Base at origin');
|
||
} else if (asset.kind === 'radar') {
|
||
assert.ok(Math.abs(bounds.max.y - (spec.buildingHeight + 1.8 * spec.radius)) < .1);
|
||
const ray = new Raycaster(new Vector3(0, 3, 20), new Vector3(0, 0, -1));
|
||
assert.ok(ray.intersectObject(root, true).length, 'Building is visible to app raycasts');
|
||
} else {
|
||
const ray = new Raycaster(new Vector3(.31, 2, .037), new Vector3(0, -1, 0));
|
||
const hits = ray.intersectObject(root, true);
|
||
assert.ok(hits.length && Math.abs(hits[0].point.y) < .02, 'Upper walking surface at y=0');
|
||
assert.ok(bounds.min.z < -spec.length / 2 - spec.stairLength, 'Stairs extend toward -Z');
|
||
assert.ok(asset.terrainSupportAnchors.length > 0);
|
||
assert.ok(asset.terrainSupportAnchors.every(a => a.position.every(Number.isFinite)));
|
||
}
|
||
report.assets.push({ id: asset.id, bytes: bytes.length, meshes, triangles,
|
||
bounds: { min: bounds.min.toArray(), max: bounds.max.toArray() },
|
||
sizeMetres: size.toArray().map(n => Number(n.toFixed(3))) });
|
||
}
|
||
report.totalBytes = report.assets.reduce((n, a) => n + a.bytes, 0);
|
||
report.totalTriangles = report.assets.reduce((n, a) => n + a.triangles, 0);
|
||
writeFileSync(resolve('assets/summit/verification.json'), JSON.stringify(report, null, 2));
|
||
console.table(report.assets.map(({ id, bytes, meshes, triangles, sizeMetres }) => ({ id, KiB: +(bytes / 1024).toFixed(1), meshes, triangles, sizeMetres: sizeMetres.join(' × ') })));
|
||
console.log(`PASS: ${report.assets.length} assets; ${report.totalTriangles} triangles; ${(report.totalBytes / 1024 / 1024).toFixed(2)} MiB`);
|