| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160 |
- // 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 }))
|