#!/usr/bin/env node "use strict"; // Structural digest of a GLB, for the osmassets refactor parity check. // // Byte-comparing the GLB is too strict: Blender packs images in hash-map order // and the buffer padding shifts with it, so two runs of identical code can // differ. This reads the glTF JSON chunk instead and reports the parts that // carry meaning downstream in Cesium — node/mesh/material identity and PBR // values — plus buffer lengths as a coarse size check. // // node scripts/glb-digest.js [--out digest.json] const fs = require("fs"); const path = require("path"); function readGlbJson(file) { const buffer = fs.readFileSync(file); if (buffer.length < 12 || buffer.readUInt32LE(0) !== 0x46546c67) { throw new Error(`Not a GLB (bad magic): ${file}`); } const total = buffer.readUInt32LE(8); let offset = 12; while (offset + 8 <= Math.min(total, buffer.length)) { const chunkLength = buffer.readUInt32LE(offset); const chunkType = buffer.readUInt32LE(offset + 4); const start = offset + 8; if (chunkType === 0x4e4f534a) { return JSON.parse(buffer.slice(start, start + chunkLength).toString("utf8")); } offset = start + chunkLength; } throw new Error(`No JSON chunk found in ${file}`); } function round(value) { if (typeof value === "number") return Number(value.toFixed(6)); if (Array.isArray(value)) return value.map(round); return value; } function materialDigest(material) { const pbr = material.pbrMetallicRoughness || {}; return { name: material.name || null, baseColorFactor: round(pbr.baseColorFactor || null), metallicFactor: round(pbr.metallicFactor ?? null), roughnessFactor: round(pbr.roughnessFactor ?? null), hasBaseColorTexture: Boolean(pbr.baseColorTexture), hasNormalTexture: Boolean(material.normalTexture), emissiveFactor: round(material.emissiveFactor || null), emissiveStrength: round( material.extensions?.KHR_materials_emissive_strength?.emissiveStrength ?? null, ), alphaMode: material.alphaMode || null, doubleSided: material.doubleSided ?? null, }; } function digest(file) { const gltf = readGlbJson(file); return digestGltf(gltf, { file: path.basename(file), fileBytes: fs.statSync(file).size }); } function digestGltf(gltf, fileInfo = {}) { const meshes = (gltf.meshes || []).map((mesh) => ({ name: mesh.name || null, primitives: (mesh.primitives || []).map((primitive) => ({ material: primitive.material ?? null, attributes: Object.keys(primitive.attributes || {}).sort(), // Vertex/index counts live on the accessors; they are the real geometry // fingerprint and stay stable regardless of buffer layout. count: gltf.accessors?.[primitive.attributes?.POSITION]?.count ?? null, indices: gltf.accessors?.[primitive.indices]?.count ?? null, triangles: primitiveTriangleCount(primitive, gltf.accessors || []), })), })); const sourceSummary = summarizeNodeSources(gltf.nodes || [], meshes); const images = (gltf.images || []) .map((image) => ({ name: image.name || null, mimeType: image.mimeType || null, bytes: gltf.bufferViews?.[image.bufferView]?.byteLength ?? 0, })) .sort((a, b) => b.bytes - a.bytes || String(a.name).localeCompare(String(b.name))); const triangles = meshes.reduce( (total, mesh) => total + mesh.primitives.reduce((sum, primitive) => sum + primitive.triangles, 0), 0, ); const renderTriangles = sourceSummary.reduce((total, source) => total + source.triangles, 0); const embeddedImageBytes = images.reduce((total, image) => total + image.bytes, 0); return { file: fileInfo.file || null, fileBytes: fileInfo.fileBytes ?? 0, counts: { nodes: (gltf.nodes || []).length, meshes: meshes.length, materials: (gltf.materials || []).length, images: (gltf.images || []).length, accessors: (gltf.accessors || []).length, triangles, renderTriangles, }, embeddedImageBytes, sourceSummary, extensionsUsed: (gltf.extensionsUsed || []).slice().sort(), buffers: (gltf.buffers || []).map((buffer) => buffer.byteLength), nodes: (gltf.nodes || []) .map((node) => ({ name: node.name || null, mesh: node.mesh ?? null, translation: round(node.translation || null), rotation: round(node.rotation || null), scale: round(node.scale || null), extras: node.extras ?? null, })) .sort((a, b) => String(a.name).localeCompare(String(b.name))), meshes: meshes.sort((a, b) => String(a.name).localeCompare(String(b.name))), materials: (gltf.materials || []) .map(materialDigest) .sort((a, b) => String(a.name).localeCompare(String(b.name))), images, }; } function primitiveTriangleCount(primitive, accessors) { if (primitive.mode !== undefined && primitive.mode !== 4) return 0; const count = primitive.indices === undefined ? accessors[primitive.attributes?.POSITION]?.count : accessors[primitive.indices]?.count; return Number.isFinite(count) ? Math.floor(count / 3) : 0; } function summarizeNodeSources(nodes, meshes) { const summaries = new Map(); for (const node of nodes) { if (node.mesh === undefined || !meshes[node.mesh]) continue; const source = nodeSource(node.name || meshes[node.mesh].name || ""); const summary = summaries.get(source) || { source, nodes: 0, meshInstances: 0, triangles: 0 }; const triangles = meshes[node.mesh].primitives.reduce((sum, primitive) => sum + primitive.triangles, 0); summary.nodes += 1; summary.meshInstances += 1; summary.triangles += triangles; summaries.set(source, summary); } return [...summaries.values()].sort((a, b) => b.triangles - a.triangles || a.source.localeCompare(b.source)); } function nodeSource(name) { const value = String(name).toLowerCase(); if (value.includes("building")) return "buildings"; if (/(shapespark|tree|bush|shrub|grass|branch|trunk|leaf)/.test(value)) return "vegetation"; if (/(road|sidewalk|lane|crosswalk|stop|intersection)/.test(value)) return "roads"; if (/(lake|water)/.test(value)) return "water"; if (value.includes("fountain")) return "fountain"; if (/(vehicle|car)/.test(value)) return "vehicles"; return "other"; } function main() { const argv = process.argv.slice(2); const file = argv.find((arg) => !arg.startsWith("--")); if (!file) { console.error("usage: node scripts/glb-digest.js [--out digest.json]"); process.exit(1); } const outIndex = argv.indexOf("--out"); const result = digest(path.resolve(file)); const text = `${JSON.stringify(result, null, 2)}\n`; if (outIndex >= 0 && argv[outIndex + 1]) { const out = path.resolve(argv[outIndex + 1]); fs.mkdirSync(path.dirname(out), { recursive: true }); fs.writeFileSync(out, text); console.log(`GLB digest: ${out}`); } else { process.stdout.write(text); } } if (require.main === module) { main(); } module.exports = { digest, digestGltf, readGlbJson, };