verify-roof-textures.mjs 5.8 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
  1. // Verify embedded raster identity and real Draco UV reprojection after streaming conversion.
  2. import assert from 'node:assert/strict'
  3. import { readFileSync, writeFileSync } from 'node:fs'
  4. import { resolve, join } from 'node:path'
  5. import { createHash } from 'node:crypto'
  6. import draco3d from 'draco3d'
  7. const dir = resolve(process.argv[2])
  8. const mapping = JSON.parse(readFileSync(process.argv[3], 'utf8'))
  9. const manifest = JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf8'))
  10. const draco = await draco3d.createDecoderModule({})
  11. const hashes = new Map(Object.entries(mapping).map(([id, r]) => [id, createHash('sha256').update(readFileSync(r.path)).digest('hex')]))
  12. const checked = new Set()
  13. let vertices = 0, maxUvError = 0, textureBytes = 0
  14. for (const record of manifest.files.filter((r) => r.level === 'lod0')) {
  15. const bytes = readFileSync(join(dir, record.uri)), size = bytes.readUInt32LE(12)
  16. assert.equal(bytes.length % 4, 0)
  17. const json = JSON.parse(bytes.toString('utf8', 20, 20 + size)), bin = bytes.subarray(size + 28)
  18. for (const view of json.bufferViews) {
  19. assert.equal((view.byteOffset || 0) % 4, 0)
  20. assert.ok((view.byteOffset || 0) + view.byteLength <= bin.length)
  21. }
  22. const usedMaterials = new Set(json.meshes.flatMap((m) => m.primitives.map((p) => p.material)))
  23. assert.equal(usedMaterials.size, json.materials.length, 'unused per-tile materials')
  24. const usedTextures = new Set(), usedImages = new Set()
  25. function collectTextures(value) {
  26. for (const [key, child] of Object.entries(value)) {
  27. if (!child || typeof child !== 'object') continue
  28. if (key.endsWith('Texture') && Number.isInteger(child.index)) usedTextures.add(child.index)
  29. else collectTextures(child)
  30. }
  31. }
  32. json.materials.forEach(collectTextures)
  33. for (const index of usedTextures) usedImages.add(json.textures[index].source)
  34. assert.equal(usedTextures.size, (json.textures || []).length, 'unused per-tile textures')
  35. assert.equal(usedImages.size, (json.images || []).length, 'unused per-tile images')
  36. const rasterBytes = (json.images || []).reduce((sum, image) => sum + json.bufferViews[image.bufferView].byteLength, 0)
  37. assert.equal(rasterBytes, record.rasterBytes)
  38. assert.ok(rasterBytes <= manifest.budget.maxRasterBytes)
  39. const translation = json.nodes.find((n) => n.mesh !== undefined).translation || [0, 0, 0]
  40. for (const p of json.meshes[0].primitives) {
  41. const material = json.materials[p.material]
  42. if (!material.name.startsWith('SatelliteRoof_')) continue
  43. const id = material.name.slice('SatelliteRoof_'.length), r = mapping[id]
  44. assert.ok(r, `unknown sourced roof ${id}`)
  45. const texture = json.textures[material.pbrMetallicRoughness.baseColorTexture.index]
  46. const image = json.images[texture.source], imageView = json.bufferViews[image.bufferView]
  47. const raster = bin.subarray(imageView.byteOffset || 0, (imageView.byteOffset || 0) + imageView.byteLength)
  48. assert.equal(createHash('sha256').update(raster).digest('hex'), hashes.get(id), `${id}: changed raster bytes`)
  49. textureBytes += raster.length
  50. const ext = p.extensions.KHR_draco_mesh_compression, view = json.bufferViews[ext.bufferView]
  51. const payload = bin.subarray(view.byteOffset || 0, (view.byteOffset || 0) + view.byteLength)
  52. const decoder = new draco.Decoder(), buffer = new draco.DecoderBuffer(), mesh = new draco.Mesh()
  53. let status
  54. try {
  55. buffer.Init(new Int8Array(payload.buffer, payload.byteOffset, payload.byteLength), payload.length)
  56. status = decoder.DecodeBufferToMesh(buffer, mesh)
  57. assert.ok(status.ok(), status.error_msg())
  58. function attribute(semantic, components) {
  59. const attr = decoder.GetAttributeByUniqueId(mesh, ext.attributes[semantic])
  60. assert.equal(attr.num_components(), components)
  61. const length = mesh.num_points() * components, ptr = draco._malloc(length * 4)
  62. try {
  63. assert.ok(decoder.GetAttributeDataArrayForAllPoints(mesh, attr, draco.DT_FLOAT32, length * 4, ptr))
  64. return new Float32Array(draco.HEAPF32.buffer, ptr, length).slice()
  65. } finally { draco._free(ptr) }
  66. }
  67. assert.equal(json.accessors[p.attributes.TEXCOORD_0].type, 'VEC2')
  68. const positions = attribute('POSITION', 3), uv = attribute('TEXCOORD_0', 2)
  69. for (let i = 0; i < mesh.num_points(); i++) {
  70. const x = positions[i * 3] + translation[0], y = -(positions[i * 3 + 2] + translation[2])
  71. const wanted = [(x - r.boundsENU[0][0]) / (r.boundsENU[1][0] - r.boundsENU[0][0]),
  72. // glTF UV origin is top-left; Blender converts its bottom-left UV once.
  73. 1 - (y - r.boundsENU[0][1]) / (r.boundsENU[1][1] - r.boundsENU[0][1])]
  74. for (let axis = 0; axis < 2; axis++) {
  75. const error = Math.abs(uv[i * 2 + axis] - wanted[axis])
  76. assert.ok(Number.isFinite(error) && error < 0.012, `${id}: UV registration error ${error}`)
  77. maxUvError = Math.max(maxUvError, error)
  78. }
  79. vertices++
  80. }
  81. checked.add(id)
  82. } finally {
  83. if (status) draco.destroy(status)
  84. for (const object of [mesh, buffer, decoder]) draco.destroy(object)
  85. }
  86. }
  87. }
  88. assert.deepEqual([...checked].sort(), Object.keys(mapping).sort(), 'not all buildings have sourced roofs')
  89. const report = { passed: true, buildings: checked.size, vertices, textureBytes, maxUvError,
  90. embeddedRasterHashesMatch: true, glbAlignment: true, caveat: 'UV/raster geometry verified offline, not real-app visual acceptance' }
  91. if (process.argv[4]) writeFileSync(process.argv[4], JSON.stringify(report, null, 2))
  92. console.log(JSON.stringify(report))