Files
sunrise/scripts/verify-summit-assets.mjs
T

82 lines
4.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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`);