| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192 |
- // Verify embedded raster identity and real Draco UV reprojection after streaming conversion.
- import assert from 'node:assert/strict'
- import { readFileSync, writeFileSync } from 'node:fs'
- import { resolve, join } from 'node:path'
- import { createHash } from 'node:crypto'
- import draco3d from 'draco3d'
- const dir = resolve(process.argv[2])
- const mapping = JSON.parse(readFileSync(process.argv[3], 'utf8'))
- const manifest = JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf8'))
- const draco = await draco3d.createDecoderModule({})
- const hashes = new Map(Object.entries(mapping).map(([id, r]) => [id, createHash('sha256').update(readFileSync(r.path)).digest('hex')]))
- const checked = new Set()
- let vertices = 0, maxUvError = 0, textureBytes = 0
- for (const record of manifest.files.filter((r) => r.level === 'lod0')) {
- const bytes = readFileSync(join(dir, record.uri)), size = bytes.readUInt32LE(12)
- assert.equal(bytes.length % 4, 0)
- const json = JSON.parse(bytes.toString('utf8', 20, 20 + size)), bin = bytes.subarray(size + 28)
- for (const view of json.bufferViews) {
- assert.equal((view.byteOffset || 0) % 4, 0)
- assert.ok((view.byteOffset || 0) + view.byteLength <= bin.length)
- }
- const usedMaterials = new Set(json.meshes.flatMap((m) => m.primitives.map((p) => p.material)))
- assert.equal(usedMaterials.size, json.materials.length, 'unused per-tile materials')
- const usedTextures = new Set(), usedImages = new Set()
- function collectTextures(value) {
- for (const [key, child] of Object.entries(value)) {
- if (!child || typeof child !== 'object') continue
- if (key.endsWith('Texture') && Number.isInteger(child.index)) usedTextures.add(child.index)
- else collectTextures(child)
- }
- }
- json.materials.forEach(collectTextures)
- for (const index of usedTextures) usedImages.add(json.textures[index].source)
- assert.equal(usedTextures.size, (json.textures || []).length, 'unused per-tile textures')
- assert.equal(usedImages.size, (json.images || []).length, 'unused per-tile images')
- const rasterBytes = (json.images || []).reduce((sum, image) => sum + json.bufferViews[image.bufferView].byteLength, 0)
- assert.equal(rasterBytes, record.rasterBytes)
- assert.ok(rasterBytes <= manifest.budget.maxRasterBytes)
- const translation = json.nodes.find((n) => n.mesh !== undefined).translation || [0, 0, 0]
- for (const p of json.meshes[0].primitives) {
- const material = json.materials[p.material]
- if (!material.name.startsWith('SatelliteRoof_')) continue
- const id = material.name.slice('SatelliteRoof_'.length), r = mapping[id]
- assert.ok(r, `unknown sourced roof ${id}`)
- const texture = json.textures[material.pbrMetallicRoughness.baseColorTexture.index]
- const image = json.images[texture.source], imageView = json.bufferViews[image.bufferView]
- const raster = bin.subarray(imageView.byteOffset || 0, (imageView.byteOffset || 0) + imageView.byteLength)
- assert.equal(createHash('sha256').update(raster).digest('hex'), hashes.get(id), `${id}: changed raster bytes`)
- textureBytes += raster.length
- const ext = p.extensions.KHR_draco_mesh_compression, view = json.bufferViews[ext.bufferView]
- const payload = bin.subarray(view.byteOffset || 0, (view.byteOffset || 0) + view.byteLength)
- const decoder = new draco.Decoder(), buffer = new draco.DecoderBuffer(), mesh = new draco.Mesh()
- let status
- try {
- buffer.Init(new Int8Array(payload.buffer, payload.byteOffset, payload.byteLength), payload.length)
- status = decoder.DecodeBufferToMesh(buffer, mesh)
- assert.ok(status.ok(), status.error_msg())
- function attribute(semantic, components) {
- const attr = decoder.GetAttributeByUniqueId(mesh, ext.attributes[semantic])
- assert.equal(attr.num_components(), components)
- const length = mesh.num_points() * components, ptr = draco._malloc(length * 4)
- try {
- assert.ok(decoder.GetAttributeDataArrayForAllPoints(mesh, attr, draco.DT_FLOAT32, length * 4, ptr))
- return new Float32Array(draco.HEAPF32.buffer, ptr, length).slice()
- } finally { draco._free(ptr) }
- }
- assert.equal(json.accessors[p.attributes.TEXCOORD_0].type, 'VEC2')
- const positions = attribute('POSITION', 3), uv = attribute('TEXCOORD_0', 2)
- for (let i = 0; i < mesh.num_points(); i++) {
- const x = positions[i * 3] + translation[0], y = -(positions[i * 3 + 2] + translation[2])
- const wanted = [(x - r.boundsENU[0][0]) / (r.boundsENU[1][0] - r.boundsENU[0][0]),
- // glTF UV origin is top-left; Blender converts its bottom-left UV once.
- 1 - (y - r.boundsENU[0][1]) / (r.boundsENU[1][1] - r.boundsENU[0][1])]
- for (let axis = 0; axis < 2; axis++) {
- const error = Math.abs(uv[i * 2 + axis] - wanted[axis])
- assert.ok(Number.isFinite(error) && error < 0.012, `${id}: UV registration error ${error}`)
- maxUvError = Math.max(maxUvError, error)
- }
- vertices++
- }
- checked.add(id)
- } finally {
- if (status) draco.destroy(status)
- for (const object of [mesh, buffer, decoder]) draco.destroy(object)
- }
- }
- }
- assert.deepEqual([...checked].sort(), Object.keys(mapping).sort(), 'not all buildings have sourced roofs')
- const report = { passed: true, buildings: checked.size, vertices, textureBytes, maxUvError,
- embeddedRasterHashesMatch: true, glbAlignment: true, caveat: 'UV/raster geometry verified offline, not real-app visual acceptance' }
- if (process.argv[4]) writeFileSync(process.argv[4], JSON.stringify(report, null, 2))
- console.log(JSON.stringify(report))
|