Files
osmWorkflow/scripts/glb-digest.js

189 lines
6.9 KiB
JavaScript

#!/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 <file.glb> [--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 <file.glb> [--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,
};