// Verify the actual Draco payload, not just self-reported manifest totals. import assert from 'node:assert/strict' import { readFileSync, writeFileSync } from 'node:fs' import { resolve, join } from 'node:path' import draco3d from 'draco3d' const argv = process.argv.slice(2) const reportIndex = argv.indexOf('--report') const reportPath = reportIndex < 0 ? null : argv.splice(reportIndex, 2)[1] const directories = argv.length ? argv : JSON.parse(readFileSync('public/cockpit/config.json', 'utf8')).map.tilesets .filter((plan) => plan.url.startsWith('/gis/cad-bim/')) .map((plan) => join('tools/city-massing/city/cad-bim', plan.url.split('/')[3])) const draco = await draco3d.createDecoderModule({}) const buildingIds = new Set() const report = { checkedAt: new Date().toISOString(), passed: false, batches: [], totals: {} } function glb(path) { const data = readFileSync(path) assert.equal(data.toString('ascii', 0, 4), 'glTF', path) assert.equal(data.readUInt32LE(4), 2, path) assert.equal(data.readUInt32LE(8), data.length, path) const length = data.readUInt32LE(12) assert.equal(data.toString('ascii', 16, 20), 'JSON', path) const json = JSON.parse(data.toString('utf8', 20, 20 + length)) const offset = 20 + length assert.equal(data.toString('ascii', offset + 4, offset + 8), 'BIN\0', path) return { json, bin: data.subarray(offset + 8, offset + 8 + data.readUInt32LE(offset)) } } function boundsFromBox(box) { assert.equal(box.length, 12) // The exporter uses axis-aligned ENU boxes, followed by a single ECEF root transform. assert.deepEqual([box[4], box[5], box[6], box[8], box[9], box[10]], [0, 0, 0, 0, 0, 0]) return { min: [box[0] - box[3], box[1] - box[7], box[2] - box[11]], max: [box[0] + box[3], box[1] + box[7], box[2] + box[11]] } } function verifyContent(path, bounds, record) { const { json, bin } = glb(path) const meshNodes = json.nodes.filter((node) => node.mesh !== undefined) assert.equal(meshNodes.length, 1, `${path}: expected one merged mesh node`) assert.equal(record.merged, true, `${path}: export is not marked merged`) const node = meshNodes[0] assert.equal(node.matrix, undefined, `${path}: unexpected matrix`) assert.deepEqual(node.translation || [0, 0, 0], [0, 0, 0], `${path}: unexpected translation`) assert.deepEqual(node.scale || [1, 1, 1], [1, 1, 1], `${path}: unexpected scale`) assert.deepEqual(node.rotation || [0, 0, 0, 1], [0, 0, 0, 1], `${path}: unexpected rotation`) const primitives = json.meshes[node.mesh].primitives const materials = new Set() let triangles = 0 let vertices = 0 let degenerateTriangles = 0 let decodeMs = 0 for (const primitive of primitives) { assert.equal(primitive.mode ?? 4, 4, `${path}: non-triangle primitive`) const extension = primitive.extensions?.KHR_draco_mesh_compression assert.ok(extension, `${path}: missing Draco payload`) assert.ok(json.materials[primitive.material], `${path}: invalid material`) assert.ok(!materials.has(primitive.material), `${path}: duplicate material primitive`) materials.add(primitive.material) const view = json.bufferViews[extension.bufferView] assert.ok(view.byteOffset + view.byteLength <= bin.length, `${path}: invalid buffer view`) const encoded = bin.subarray(view.byteOffset || 0, (view.byteOffset || 0) + view.byteLength) const decoder = new draco.Decoder() const buffer = new draco.DecoderBuffer() const mesh = new draco.Mesh() let positionsPtr = 0 let indicesPtr = 0 let status try { buffer.Init(new Int8Array(encoded.buffer, encoded.byteOffset, encoded.byteLength), encoded.byteLength) const start = performance.now() status = decoder.DecodeBufferToMesh(buffer, mesh) decodeMs += performance.now() - start assert.ok(status.ok(), `${path}: Draco decode failed: ${status.error_msg()}`) const pointCount = mesh.num_points() const faceCount = mesh.num_faces() assert.equal(pointCount, json.accessors[primitive.attributes.POSITION].count, `${path}: vertex count`) assert.equal(faceCount * 3, json.accessors[primitive.indices].count, `${path}: triangle count`) const attribute = decoder.GetAttributeByUniqueId(mesh, extension.attributes.POSITION) assert.equal(attribute.num_components(), 3, `${path}: position dimensions`) positionsPtr = draco._malloc(pointCount * 3 * 4) decoder.GetAttributeDataArrayForAllPoints(mesh, attribute, draco.DT_FLOAT32, pointCount * 3 * 4, positionsPtr) indicesPtr = draco._malloc(faceCount * 3 * 4) decoder.GetTrianglesUInt32Array(mesh, faceCount * 3 * 4, indicesPtr) // Recreate views after every allocation: WebAssembly memory can grow. const positions = new Float32Array(draco.HEAPF32.buffer, positionsPtr, pointCount * 3) const indices = new Uint32Array(draco.HEAPU32.buffer, indicesPtr, faceCount * 3) for (let index = 0; index < pointCount; index++) { const enu = [positions[index * 3], -positions[index * 3 + 2], positions[index * 3 + 1]] for (let axis = 0; axis < 3; axis++) { assert.ok(Number.isFinite(enu[axis]), `${path}: non-finite position`) // Draco quantization can move a boundary vertex slightly; do not confuse // its sub-metre error with a metre/kilometre transform regression. assert.ok(enu[axis] >= bounds.min[axis] - 0.5 && enu[axis] <= bounds.max[axis] + 0.5, `${path}: decoded vertex outside tile bounds`) } } for (let index = 0; index < indices.length; index += 3) { const a = indices[index], b = indices[index + 1], c = indices[index + 2] assert.ok(a < pointCount && b < pointCount && c < pointCount, `${path}: out-of-range index`) if (a === b || a === c || b === c) degenerateTriangles++ } vertices += pointCount triangles += faceCount } finally { if (positionsPtr) draco._free(positionsPtr) if (indicesPtr) draco._free(indicesPtr) if (status) draco.destroy(status) draco.destroy(mesh) draco.destroy(buffer) draco.destroy(decoder) } } assert.equal(triangles, record.triangles, `${path}: decoded total differs from manifest`) assert.equal(readFileSync(path).length, record.bytes, `${path}: byte length differs from manifest`) assert.equal(degenerateTriangles, 0, `${path}: repeated-index triangles`) return { nodes: 1, primitives: primitives.length, triangles, vertices, bytes: record.bytes, decodeMs } } for (const directory of directories) { const dir = resolve(directory) const manifest = JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf8')) const tileset = JSON.parse(readFileSync(join(dir, 'tileset.json'), 'utf8')) const records = new Map([...manifest.coarseCells, ...manifest.cells.map((cell) => cell.lod0)] .map((record) => [record.uri, record])) const ids = manifest.cells.flatMap((cell) => cell.buildingIds) assert.equal(ids.length, manifest.count, `${directory}: missing buildings`) assert.equal(new Set(ids).size, ids.length, `${directory}: duplicate buildings`) for (const id of ids) { assert.ok(!buildingIds.has(id), `${directory}: duplicate building across batches ${id}`) buildingIds.add(id) } const batch = { directory, buildings: ids.length, files: 0, nodes: 0, primitives: 0, maxPrimitivesPerFile: 0, bytes: 0, triangles: 0, vertices: 0, decodeMs: 0, maxFileDecodeMs: 0 } for (const coarse of tileset.root.children) { for (const tile of [coarse, ...(coarse.children || [])]) { const uri = tile.content.uri assert.ok(records.has(uri), `${directory}: unrecorded content ${uri}`) const result = verifyContent(join(dir, uri), boundsFromBox(tile.boundingVolume.box), records.get(uri)) batch.files++ for (const key of ['nodes', 'primitives', 'bytes', 'triangles', 'vertices', 'decodeMs']) batch[key] += result[key] batch.maxPrimitivesPerFile = Math.max(batch.maxPrimitivesPerFile, result.primitives) batch.maxFileDecodeMs = Math.max(batch.maxFileDecodeMs, result.decodeMs) records.delete(uri) } } assert.equal(records.size, 0, `${directory}: unreferenced manifest content`) batch.decodeMs = Math.round(batch.decodeMs) batch.maxFileDecodeMs = Math.round(batch.maxFileDecodeMs) report.batches.push(batch) console.log(JSON.stringify(batch)) } report.totals = { buildings: buildingIds.size } for (const key of ['files', 'nodes', 'primitives', 'bytes', 'triangles', 'vertices', 'decodeMs']) { report.totals[key] = report.batches.reduce((sum, batch) => sum + batch[key], 0) } report.passed = true if (reportPath) writeFileSync(reportPath, JSON.stringify(report, null, 2) + '\n') console.log(JSON.stringify({ passed: true, totals: report.totals }))