// 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`);