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