verify-streaming-tiles.mjs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. // Verify bounded streaming-v2 tiles: request volumes, Draco payloads, budgets, and coverage.
  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. import { tileRasterBudget } from './raster-budget.mjs'
  7. import { verifyMaterialClosure } from './verify-material-closure.mjs'
  8. const argv = process.argv.slice(2)
  9. // Flat-face agreement is opt-in: curved/smooth library assets need not match each face normal.
  10. const checkFlatNormals = argv.includes('--check-flat-normals')
  11. if (checkFlatNormals) argv.splice(argv.indexOf('--check-flat-normals'), 1)
  12. // Reject collapsed faces independently of the optional flat-shading criterion.
  13. const rejectDegenerate = argv.includes('--reject-degenerate')
  14. if (rejectDegenerate) argv.splice(argv.indexOf('--reject-degenerate'), 1)
  15. const reportIndex = argv.indexOf('--report')
  16. const reportPath = reportIndex < 0 ? null : argv.splice(reportIndex, 2)[1]
  17. const dirs = argv.length ? argv : [process.argv[2]]
  18. assert.ok(dirs[0], 'batch directory required')
  19. const decoderModule = await draco3d.createDecoderModule({})
  20. function parseGlb(path) {
  21. const bytes = readFileSync(path)
  22. assert.equal(bytes.toString('ascii', 0, 4), 'glTF', path)
  23. assert.equal(bytes.readUInt32LE(4), 2, path)
  24. assert.equal(bytes.readUInt32LE(8), bytes.length, path)
  25. const jsonLength = bytes.readUInt32LE(12)
  26. const json = JSON.parse(bytes.toString('utf8', 20, 20 + jsonLength))
  27. const binOffset = 20 + jsonLength
  28. assert.equal(bytes.toString('ascii', binOffset + 4, binOffset + 8), 'BIN\0', path)
  29. const binLength = bytes.readUInt32LE(binOffset)
  30. return { bytes, json, bin: bytes.subarray(binOffset + 8, binOffset + 8 + binLength) }
  31. }
  32. function decodePrimitive(path, json, bin, primitive, translation, bounds) {
  33. const extension = primitive.extensions?.KHR_draco_mesh_compression
  34. assert.ok(extension, `${path}: missing Draco extension`)
  35. const view = json.bufferViews[extension.bufferView]
  36. assert.ok(view, `${path}: missing Draco bufferView`)
  37. const encoded = bin.subarray(view.byteOffset || 0, (view.byteOffset || 0) + view.byteLength)
  38. const decoder = new decoderModule.Decoder()
  39. const buffer = new decoderModule.DecoderBuffer()
  40. const mesh = new decoderModule.Mesh()
  41. let status
  42. try {
  43. buffer.Init(new Int8Array(encoded.buffer, encoded.byteOffset, encoded.byteLength), encoded.byteLength)
  44. status = decoder.DecodeBufferToMesh(buffer, mesh)
  45. assert.ok(status.ok(), `${path}: ${status.error_msg()}`)
  46. const points = mesh.num_points()
  47. const faces = mesh.num_faces()
  48. assert.equal(points, json.accessors[primitive.attributes.POSITION].count, `${path}: point count mismatch`)
  49. assert.equal(faces * 3, json.accessors[primitive.indices].count, `${path}: face count mismatch`)
  50. const attribute = decoder.GetAttributeByUniqueId(mesh, extension.attributes.POSITION)
  51. assert.equal(attribute.num_components(), 3)
  52. const positionBytes = points * 3 * 4
  53. const positionPtr = decoderModule._malloc(positionBytes)
  54. let decodedPositions
  55. try {
  56. decoder.GetAttributeDataArrayForAllPoints(mesh, attribute, decoderModule.DT_FLOAT32, positionBytes, positionPtr)
  57. const positions = new Float32Array(decoderModule.HEAPF32.buffer, positionPtr, points * 3)
  58. for (let index = 0; index < points; index++) {
  59. const enu = [positions[index * 3] + translation[0], -(positions[index * 3 + 2] + translation[2]),
  60. positions[index * 3 + 1] + translation[1]]
  61. for (let axis = 0; axis < 3; axis++) {
  62. assert.ok(Number.isFinite(enu[axis]), `${path}: non-finite position`)
  63. assert.ok(enu[axis] >= bounds.min[axis] - 0.05 && enu[axis] <= bounds.max[axis] + 0.05,
  64. `${path}: decoded world position outside tile bounds`)
  65. }
  66. }
  67. decodedPositions = positions.slice()
  68. } finally { decoderModule._free(positionPtr) }
  69. const normalAttribute = decoder.GetAttributeByUniqueId(mesh, extension.attributes.NORMAL)
  70. assert.equal(normalAttribute.num_components(), 3, `${path}: NORMAL components`)
  71. const normalPtr = decoderModule._malloc(points * 12)
  72. let normals
  73. try {
  74. assert.ok(decoder.GetAttributeDataArrayForAllPoints(mesh, normalAttribute, decoderModule.DT_FLOAT32, points * 12, normalPtr))
  75. normals = new Float32Array(decoderModule.HEAPF32.buffer, normalPtr, points * 3).slice()
  76. for (let i = 0; i < points; i++) {
  77. const length = Math.hypot(...normals.subarray(i * 3, i * 3 + 3))
  78. assert.ok(Number.isFinite(length) && Math.abs(length - 1) < 0.01, `${path}: invalid normal`)
  79. }
  80. } finally { decoderModule._free(normalPtr) }
  81. let checkedNormalTriangles = 0, degenerateTriangles = 0, minNormalDot = 1
  82. const indexBytes = faces * 3 * 4
  83. const indexPtr = decoderModule._malloc(indexBytes)
  84. try {
  85. decoder.GetTrianglesUInt32Array(mesh, indexBytes, indexPtr)
  86. const indices = new Uint32Array(decoderModule.HEAPU32.buffer, indexPtr, faces * 3)
  87. for (const index of indices) assert.ok(index < points, `${path}: out-of-range triangle index`)
  88. for (let face = 0; face < faces; face++) {
  89. const ids = indices.subarray(face * 3, face * 3 + 3)
  90. const [a, b, c] = Array.from(ids, (id) => decodedPositions.subarray(id * 3, id * 3 + 3))
  91. const u = b.map((value, axis) => value - a[axis]), v = c.map((value, axis) => value - a[axis])
  92. const cross = [u[1]*v[2]-u[2]*v[1], u[2]*v[0]-u[0]*v[2], u[0]*v[1]-u[1]*v[0]]
  93. const area2 = Math.hypot(...cross)
  94. if (area2 < 1e-10) {
  95. degenerateTriangles++
  96. assert.ok(!(rejectDegenerate || checkFlatNormals), `${path}: triangle ${face} collapsed after decoding`)
  97. continue
  98. }
  99. for (const id of ids) {
  100. const dot = cross.reduce((sum, value, axis) => sum + value * normals[id * 3 + axis], 0) / area2
  101. if (checkFlatNormals) assert.ok(dot >= 0.9, `${path}: material ${json.materials[primitive.material]?.name}, triangle ${face} normal/winding mismatch ${dot}; positions=${JSON.stringify([Array.from(a), Array.from(b), Array.from(c)])}, normal=${JSON.stringify(Array.from(normals.subarray(id * 3, id * 3 + 3)))}`)
  102. minNormalDot = Math.min(minNormalDot, dot)
  103. }
  104. checkedNormalTriangles++
  105. }
  106. } finally { decoderModule._free(indexPtr) }
  107. return { points, triangles: faces, checkedNormalTriangles, degenerateTriangles, minNormalDot }
  108. } finally {
  109. if (status) decoderModule.destroy(status)
  110. decoderModule.destroy(mesh); decoderModule.destroy(buffer); decoderModule.destroy(decoder)
  111. }
  112. }
  113. function collectTiles(tile, out = []) {
  114. if (tile.content?.uri) out.push(tile)
  115. for (const child of tile.children || []) collectTiles(child, out)
  116. return out
  117. }
  118. function boxVolume(box) {
  119. assert.equal(box.length, 12)
  120. assert.deepEqual([box[4], box[5], box[6], box[8], box[9], box[10]], [0, 0, 0, 0, 0, 0])
  121. return Math.max(0.001, box[3] * 2) * Math.max(0.001, box[7] * 2) * Math.max(0.001, box[11] * 2)
  122. }
  123. const all = []
  124. for (const directory of dirs) {
  125. const dir = resolve(directory)
  126. const tree = JSON.parse(readFileSync(join(dir, 'tileset.json'), 'utf8'))
  127. const manifest = JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf8'))
  128. assert.equal(manifest.provenance?.gisFootprints, true, `${directory}: GIS provenance missing`)
  129. assert.equal(manifest.provenance?.roadsProcedural, false, `${directory}: procedural roads provenance`)
  130. const tiles = collectTiles(tree.root).filter((tile) => tile !== tree.root)
  131. const records = new Map(manifest.files.map((record) => [record.uri, record]))
  132. assert.equal(records.size, manifest.files.length, `${directory}: duplicate file records`)
  133. assert.ok(tiles.length > 0, `${directory}: no content tiles`)
  134. assert.ok(tree.root.content?.uri, `${directory}: no root anchor`)
  135. const root = tree.root
  136. assert.ok(root.children?.length >= 2, `${directory}: too few structural children`)
  137. const ids = manifest.cells.flatMap((cell) => cell.buildingIds)
  138. assert.equal(ids.length, manifest.count, `${directory}: wrong building count`)
  139. assert.equal(new Set(ids).size, manifest.count, `${directory}: duplicate or missing building IDs`)
  140. const source = JSON.parse(readFileSync(join(dir, '..', 'manifest.json'), 'utf8'))
  141. assert.deepEqual([...ids].sort(), source.cells.flatMap((cell) => cell.buildingIds).sort(), `${directory}: source coverage changed`)
  142. const byLevel = { lod1: { triangles: 0, bytes: 0 }, lod0: { triangles: 0, bytes: 0 } }
  143. let files = 0, triangles = 0, vertices = 0, bytes = 0, maxVertices = 0, maxTriangles = 0
  144. let checkedNormalTriangles = 0, degenerateTriangles = 0, minNormalDot = 1, maxDecodedTextureBytes = 0
  145. for (const tile of tiles) {
  146. assert.ok(tile.viewerRequestVolume?.sphere, `${directory}: content missing viewerRequestVolume`)
  147. assert.ok(tile.content?.uri !== 'root-anchor.glb', `${directory}: anchor in a request tile`)
  148. const record = records.get(tile.content.uri)
  149. assert.ok(record, `${directory}: unrecorded ${tile.content.uri}`)
  150. assert.ok(tile.boundingVolume?.box, `${directory}: missing content box`)
  151. boxVolume(tile.boundingVolume.box)
  152. const box = tile.boundingVolume.box
  153. const center = box.slice(0, 3)
  154. const half = [box[3], box[7], box[11]]
  155. const bounds = { min: center.map((value, axis) => value - half[axis]), max: center.map((value, axis) => value + half[axis]) }
  156. const sphere = tile.viewerRequestVolume.sphere
  157. const distance = record.level === 'lod1' ? manifest.budget.coarseDistance : manifest.budget.detailDistance
  158. assert.equal(sphere.length, 4)
  159. sphere.slice(0, 3).forEach((value, axis) => assert.ok(Math.abs(value - center[axis]) < 0.01, `${directory}: request center drift`))
  160. assert.ok(Math.abs(sphere[3] - Math.hypot(...half) - distance) < 0.01, `${directory}: wrong distance request gate`)
  161. const path = join(dir, tile.content.uri)
  162. const { bytes: raw, json, bin } = parseGlb(path)
  163. verifyMaterialClosure(json, path)
  164. const nodes = json.nodes.filter((node) => node.mesh !== undefined)
  165. assert.equal(nodes.length, 1, `${path}: expected one mesh node`)
  166. assert.ok(!nodes[0].matrix && !nodes[0].rotation && !nodes[0].scale, `${path}: unexpected transform`)
  167. const translation = nodes[0].translation || [0, 0, 0]
  168. assert.equal(translation.length, 3)
  169. assert.ok(json.extensionsRequired?.includes('KHR_draco_mesh_compression'), `${path}: Draco is not required`)
  170. let fileTriangles = 0, fileVertices = 0
  171. for (const primitive of json.meshes[nodes[0].mesh].primitives) {
  172. const decoded = decodePrimitive(path, json, bin, primitive, translation, bounds)
  173. fileTriangles += decoded.triangles
  174. fileVertices += decoded.points
  175. checkedNormalTriangles += decoded.checkedNormalTriangles
  176. degenerateTriangles += decoded.degenerateTriangles
  177. minNormalDot = Math.min(minNormalDot, decoded.minNormalDot)
  178. }
  179. assert.equal(fileTriangles, record.triangles, `${path}: triangle total differs from manifest`)
  180. assert.equal(raw.length, record.bytes, `${path}: byte total differs from manifest`)
  181. assert.ok(fileVertices <= manifest.budget.maxVertices, `${path}: vertex budget exceeded`)
  182. assert.ok(fileTriangles <= manifest.budget.maxTriangles, `${path}: triangle budget exceeded`)
  183. if (manifest.budget.maxRasterBytes !== undefined) {
  184. const rasterBytes = (json.images || []).reduce((sum, image) => sum + json.bufferViews[image.bufferView].byteLength, 0)
  185. assert.equal(rasterBytes, record.rasterBytes, `${path}: raster total differs from manifest`)
  186. assert.ok(rasterBytes <= manifest.budget.maxRasterBytes, `${path}: raster budget exceeded`)
  187. }
  188. if (manifest.budget.maxDecodedTextureBytes !== undefined) {
  189. const images = (json.images || []).map((image) => {
  190. const view = json.bufferViews[image.bufferView]
  191. assert.ok(view && (view.byteOffset || 0) + view.byteLength <= bin.length, `${path}: invalid image view`)
  192. return { mimeType: image.mimeType, bytes: bin.subarray(view.byteOffset || 0, (view.byteOffset || 0) + view.byteLength) }
  193. })
  194. const allocation = tileRasterBudget(images, json.textures || [], manifest.budget)
  195. for (const [key, value] of Object.entries(allocation)) assert.equal(value, record[key], `${path}: ${key} differs from manifest`)
  196. maxDecodedTextureBytes = Math.max(maxDecodedTextureBytes, allocation.decodedTextureBytes)
  197. }
  198. byLevel[record.level].triangles += fileTriangles
  199. byLevel[record.level].bytes += raw.length
  200. assert.equal(fileVertices, record.vertices, `${path}: vertex total differs from manifest`)
  201. files++; triangles += fileTriangles; vertices += fileVertices; bytes += raw.length
  202. maxVertices = Math.max(maxVertices, fileVertices); maxTriangles = Math.max(maxTriangles, fileTriangles)
  203. records.delete(tile.content.uri)
  204. }
  205. assert.equal(records.size, 0, `${directory}: unreferenced manifest files`)
  206. for (const level of ['lod1', 'lod0']) {
  207. assert.deepEqual(byLevel[level], manifest.totals[level], `${directory}: ${level} totals differ from files`)
  208. assert.equal(byLevel[level].triangles, source.cells.reduce((sum, cell) => sum + cell[level].triangles, 0),
  209. `${directory}: ${level} source triangle count changed`)
  210. }
  211. const result = { directory, buildings: manifest.count, files, triangles, vertices, bytes, maxVertices, maxTriangles,
  212. budget: manifest.budget, normals: { flatFaceAgreementRequired: checkFlatNormals, degenerateRejectionRequired: rejectDegenerate || checkFlatNormals, checkedNormalTriangles,
  213. degenerateTriangles, minNormalDot }, maxDecodedTextureBytes: manifest.budget.maxDecodedTextureBytes === undefined ? null : maxDecodedTextureBytes,
  214. decodedTextureBudgetValidated: manifest.budget.maxDecodedTextureBytes !== undefined }
  215. all.push(result)
  216. console.log(JSON.stringify(result))
  217. }
  218. const result = { passed: true, batches: all, totals: {
  219. buildings: all.reduce((sum, item) => sum + item.buildings, 0),
  220. files: all.reduce((sum, item) => sum + item.files, 0),
  221. triangles: all.reduce((sum, item) => sum + item.triangles, 0),
  222. vertices: all.reduce((sum, item) => sum + item.vertices, 0),
  223. bytes: all.reduce((sum, item) => sum + item.bytes, 0),
  224. } }
  225. if (reportPath) writeFileSync(reportPath, JSON.stringify(result, null, 2) + '\n')
  226. console.log(JSON.stringify(result))