verify-merged-cad-bim.mjs 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. // Verify the actual Draco payload, not just self-reported manifest totals.
  2. import assert from 'node:assert/strict'
  3. import { readFileSync, writeFileSync } from 'node:fs'
  4. import { resolve, join } from 'node:path'
  5. import draco3d from 'draco3d'
  6. const argv = process.argv.slice(2)
  7. const reportIndex = argv.indexOf('--report')
  8. const reportPath = reportIndex < 0 ? null : argv.splice(reportIndex, 2)[1]
  9. const directories = argv.length ? argv : JSON.parse(readFileSync('public/cockpit/config.json', 'utf8')).map.tilesets
  10. .filter((plan) => plan.url.startsWith('/gis/cad-bim/'))
  11. .map((plan) => join('tools/city-massing/city/cad-bim', plan.url.split('/')[3]))
  12. const draco = await draco3d.createDecoderModule({})
  13. const buildingIds = new Set()
  14. const report = { checkedAt: new Date().toISOString(), passed: false, batches: [], totals: {} }
  15. function glb(path) {
  16. const data = readFileSync(path)
  17. assert.equal(data.toString('ascii', 0, 4), 'glTF', path)
  18. assert.equal(data.readUInt32LE(4), 2, path)
  19. assert.equal(data.readUInt32LE(8), data.length, path)
  20. const length = data.readUInt32LE(12)
  21. assert.equal(data.toString('ascii', 16, 20), 'JSON', path)
  22. const json = JSON.parse(data.toString('utf8', 20, 20 + length))
  23. const offset = 20 + length
  24. assert.equal(data.toString('ascii', offset + 4, offset + 8), 'BIN\0', path)
  25. return { json, bin: data.subarray(offset + 8, offset + 8 + data.readUInt32LE(offset)) }
  26. }
  27. function boundsFromBox(box) {
  28. assert.equal(box.length, 12)
  29. // The exporter uses axis-aligned ENU boxes, followed by a single ECEF root transform.
  30. assert.deepEqual([box[4], box[5], box[6], box[8], box[9], box[10]], [0, 0, 0, 0, 0, 0])
  31. return { min: [box[0] - box[3], box[1] - box[7], box[2] - box[11]],
  32. max: [box[0] + box[3], box[1] + box[7], box[2] + box[11]] }
  33. }
  34. function verifyContent(path, bounds, record) {
  35. const { json, bin } = glb(path)
  36. const meshNodes = json.nodes.filter((node) => node.mesh !== undefined)
  37. assert.equal(meshNodes.length, 1, `${path}: expected one merged mesh node`)
  38. assert.equal(record.merged, true, `${path}: export is not marked merged`)
  39. const node = meshNodes[0]
  40. assert.equal(node.matrix, undefined, `${path}: unexpected matrix`)
  41. assert.deepEqual(node.translation || [0, 0, 0], [0, 0, 0], `${path}: unexpected translation`)
  42. assert.deepEqual(node.scale || [1, 1, 1], [1, 1, 1], `${path}: unexpected scale`)
  43. assert.deepEqual(node.rotation || [0, 0, 0, 1], [0, 0, 0, 1], `${path}: unexpected rotation`)
  44. const primitives = json.meshes[node.mesh].primitives
  45. const materials = new Set()
  46. let triangles = 0
  47. let vertices = 0
  48. let degenerateTriangles = 0
  49. let decodeMs = 0
  50. for (const primitive of primitives) {
  51. assert.equal(primitive.mode ?? 4, 4, `${path}: non-triangle primitive`)
  52. const extension = primitive.extensions?.KHR_draco_mesh_compression
  53. assert.ok(extension, `${path}: missing Draco payload`)
  54. assert.ok(json.materials[primitive.material], `${path}: invalid material`)
  55. assert.ok(!materials.has(primitive.material), `${path}: duplicate material primitive`)
  56. materials.add(primitive.material)
  57. const view = json.bufferViews[extension.bufferView]
  58. assert.ok(view.byteOffset + view.byteLength <= bin.length, `${path}: invalid buffer view`)
  59. const encoded = bin.subarray(view.byteOffset || 0, (view.byteOffset || 0) + view.byteLength)
  60. const decoder = new draco.Decoder()
  61. const buffer = new draco.DecoderBuffer()
  62. const mesh = new draco.Mesh()
  63. let positionsPtr = 0
  64. let indicesPtr = 0
  65. let status
  66. try {
  67. buffer.Init(new Int8Array(encoded.buffer, encoded.byteOffset, encoded.byteLength), encoded.byteLength)
  68. const start = performance.now()
  69. status = decoder.DecodeBufferToMesh(buffer, mesh)
  70. decodeMs += performance.now() - start
  71. assert.ok(status.ok(), `${path}: Draco decode failed: ${status.error_msg()}`)
  72. const pointCount = mesh.num_points()
  73. const faceCount = mesh.num_faces()
  74. assert.equal(pointCount, json.accessors[primitive.attributes.POSITION].count, `${path}: vertex count`)
  75. assert.equal(faceCount * 3, json.accessors[primitive.indices].count, `${path}: triangle count`)
  76. const attribute = decoder.GetAttributeByUniqueId(mesh, extension.attributes.POSITION)
  77. assert.equal(attribute.num_components(), 3, `${path}: position dimensions`)
  78. positionsPtr = draco._malloc(pointCount * 3 * 4)
  79. decoder.GetAttributeDataArrayForAllPoints(mesh, attribute, draco.DT_FLOAT32, pointCount * 3 * 4, positionsPtr)
  80. indicesPtr = draco._malloc(faceCount * 3 * 4)
  81. decoder.GetTrianglesUInt32Array(mesh, faceCount * 3 * 4, indicesPtr)
  82. // Recreate views after every allocation: WebAssembly memory can grow.
  83. const positions = new Float32Array(draco.HEAPF32.buffer, positionsPtr, pointCount * 3)
  84. const indices = new Uint32Array(draco.HEAPU32.buffer, indicesPtr, faceCount * 3)
  85. for (let index = 0; index < pointCount; index++) {
  86. const enu = [positions[index * 3], -positions[index * 3 + 2], positions[index * 3 + 1]]
  87. for (let axis = 0; axis < 3; axis++) {
  88. assert.ok(Number.isFinite(enu[axis]), `${path}: non-finite position`)
  89. // Draco quantization can move a boundary vertex slightly; do not confuse
  90. // its sub-metre error with a metre/kilometre transform regression.
  91. assert.ok(enu[axis] >= bounds.min[axis] - 0.5 && enu[axis] <= bounds.max[axis] + 0.5,
  92. `${path}: decoded vertex outside tile bounds`)
  93. }
  94. }
  95. for (let index = 0; index < indices.length; index += 3) {
  96. const a = indices[index], b = indices[index + 1], c = indices[index + 2]
  97. assert.ok(a < pointCount && b < pointCount && c < pointCount, `${path}: out-of-range index`)
  98. if (a === b || a === c || b === c) degenerateTriangles++
  99. }
  100. vertices += pointCount
  101. triangles += faceCount
  102. } finally {
  103. if (positionsPtr) draco._free(positionsPtr)
  104. if (indicesPtr) draco._free(indicesPtr)
  105. if (status) draco.destroy(status)
  106. draco.destroy(mesh)
  107. draco.destroy(buffer)
  108. draco.destroy(decoder)
  109. }
  110. }
  111. assert.equal(triangles, record.triangles, `${path}: decoded total differs from manifest`)
  112. assert.equal(readFileSync(path).length, record.bytes, `${path}: byte length differs from manifest`)
  113. assert.equal(degenerateTriangles, 0, `${path}: repeated-index triangles`)
  114. return { nodes: 1, primitives: primitives.length, triangles, vertices, bytes: record.bytes, decodeMs }
  115. }
  116. for (const directory of directories) {
  117. const dir = resolve(directory)
  118. const manifest = JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf8'))
  119. const tileset = JSON.parse(readFileSync(join(dir, 'tileset.json'), 'utf8'))
  120. const records = new Map([...manifest.coarseCells, ...manifest.cells.map((cell) => cell.lod0)]
  121. .map((record) => [record.uri, record]))
  122. const ids = manifest.cells.flatMap((cell) => cell.buildingIds)
  123. assert.equal(ids.length, manifest.count, `${directory}: missing buildings`)
  124. assert.equal(new Set(ids).size, ids.length, `${directory}: duplicate buildings`)
  125. for (const id of ids) {
  126. assert.ok(!buildingIds.has(id), `${directory}: duplicate building across batches ${id}`)
  127. buildingIds.add(id)
  128. }
  129. const batch = { directory, buildings: ids.length, files: 0, nodes: 0, primitives: 0, maxPrimitivesPerFile: 0,
  130. bytes: 0, triangles: 0, vertices: 0, decodeMs: 0, maxFileDecodeMs: 0 }
  131. for (const coarse of tileset.root.children) {
  132. for (const tile of [coarse, ...(coarse.children || [])]) {
  133. const uri = tile.content.uri
  134. assert.ok(records.has(uri), `${directory}: unrecorded content ${uri}`)
  135. const result = verifyContent(join(dir, uri), boundsFromBox(tile.boundingVolume.box), records.get(uri))
  136. batch.files++
  137. for (const key of ['nodes', 'primitives', 'bytes', 'triangles', 'vertices', 'decodeMs']) batch[key] += result[key]
  138. batch.maxPrimitivesPerFile = Math.max(batch.maxPrimitivesPerFile, result.primitives)
  139. batch.maxFileDecodeMs = Math.max(batch.maxFileDecodeMs, result.decodeMs)
  140. records.delete(uri)
  141. }
  142. }
  143. assert.equal(records.size, 0, `${directory}: unreferenced manifest content`)
  144. batch.decodeMs = Math.round(batch.decodeMs)
  145. batch.maxFileDecodeMs = Math.round(batch.maxFileDecodeMs)
  146. report.batches.push(batch)
  147. console.log(JSON.stringify(batch))
  148. }
  149. report.totals = { buildings: buildingIds.size }
  150. for (const key of ['files', 'nodes', 'primitives', 'bytes', 'triangles', 'vertices', 'decodeMs']) {
  151. report.totals[key] = report.batches.reduce((sum, batch) => sum + batch[key], 0)
  152. }
  153. report.passed = true
  154. if (reportPath) writeFileSync(reportPath, JSON.stringify(report, null, 2) + '\n')
  155. console.log(JSON.stringify({ passed: true, totals: report.totals }))