Files
healer-man/scripts/dungeon-pipeline/reconcile-conversion-report.mjs
2026-08-14 15:56:39 -04:00

149 lines
5.0 KiB
JavaScript

#!/usr/bin/env node
import { open } from "node:fs/promises";
import path from "node:path";
import {
exists,
projectRoot,
readJson,
sha256,
workRoot,
writeJson,
} from "./lib.mjs";
const slug = process.argv[2];
if (!slug || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) {
throw new Error("Usage: node reconcile-conversion-report.mjs <dungeon-slug>");
}
async function glbTriangleCount(file) {
const handle = await open(file, "r");
try {
const header = Buffer.alloc(20);
await handle.read(header, 0, header.length, 0);
if (header.toString("ascii", 0, 4) !== "glTF" || header.readUInt32LE(4) !== 2) {
throw new Error(`${file}: expected a glTF 2 GLB.`);
}
const jsonLength = header.readUInt32LE(12);
if (header.readUInt32LE(16) !== 0x4E4F534A) {
throw new Error(`${file}: leading GLB chunk is not JSON.`);
}
const json = Buffer.alloc(jsonLength);
await handle.read(json, 0, jsonLength, 20);
const document = JSON.parse(json.toString("utf8").replace(/\0+$/u, "").trimEnd());
const accessors = document.accessors ?? [];
return (document.meshes ?? []).reduce((total, mesh) => (
total + (mesh.primitives ?? []).reduce((meshTotal, primitive) => {
const mode = primitive.mode ?? 4;
if (mode !== 4) throw new Error(`${file}: non-triangle primitive mode ${mode}.`);
const accessorIndex = primitive.indices ?? primitive.attributes?.POSITION;
if (accessorIndex === undefined) {
throw new Error(`${file}: primitive has neither indices nor POSITION.`);
}
return meshTotal + Math.floor(accessors[accessorIndex].count / 3);
}, 0)
), 0);
} finally {
await handle.close();
}
}
const conversionFile = path.join(
workRoot,
slug,
"staging",
"environment",
"conversion-report.json",
);
if (!await exists(conversionFile)) throw new Error(`${slug}: conversion report is missing.`);
const conversion = await readJson(conversionFile);
const collisionEntries = Array.isArray(conversion.collision)
? conversion.collision
: conversion.collision
? [conversion.collision]
: [];
if (!collisionEntries.length) throw new Error(`${slug}: conversion report has no collision entries.`);
const corrections = [];
for (const entry of collisionEntries) {
const file = path.join(path.dirname(conversionFile), entry.file);
const exportedTriangles = await glbTriangleCount(file);
if (entry.triangles !== exportedTriangles) {
corrections.push({
file: entry.file,
reportedTriangles: entry.triangles,
exportedTriangles,
});
entry.triangles = exportedTriangles;
}
}
const method = "sum floor(index-or-position accessor count / 3) for TRIANGLES primitives";
const visualCorrections = [];
if (conversion.visual?.file) {
const file = path.join(path.dirname(conversionFile), conversion.visual.file);
const exportedTriangles = await glbTriangleCount(file);
if (conversion.visual.triangles !== exportedTriangles) {
visualCorrections.push({
file: conversion.visual.file,
reportedTriangles: conversion.visual.triangles,
exportedTriangles,
});
conversion.visual.triangles = exportedTriangles;
}
}
conversion.collisionTriangleReconciliation = {
count: corrections.length,
provenance: "post-export GLB accessor recount",
method,
corrections,
};
conversion.visualTriangleReconciliation = {
count: visualCorrections.length,
provenance: "post-export GLB accessor recount",
method,
corrections: visualCorrections,
};
if (corrections.length) {
const warning = `Reconciled ${corrections.length} collision triangle count(s) to authoritative exported GLB accessors.`;
conversion.warnings = [...new Set([...(conversion.warnings ?? []), warning])];
}
if (visualCorrections.length) {
const warning = "Reconciled the visual triangle count to authoritative exported GLB accessors.";
conversion.warnings = [...new Set([...(conversion.warnings ?? []), warning])];
}
await writeJson(conversionFile, conversion);
const conversionReportChecksum = await sha256(conversionFile);
const recastFile = path.join(workRoot, slug, "recast-report.json");
let recastUpdated = false;
if (await exists(recastFile)) {
const recast = await readJson(recastFile);
const expectedSource = path.relative(projectRoot, conversionFile).replace(/\\/g, "/");
if (recast.source?.path !== expectedSource) {
throw new Error(`${slug}: Recast source is ${recast.source?.path}, expected ${expectedSource}.`);
}
recast.source.checksum = conversionReportChecksum;
recast.source.collisionTriangleReconciliation = {
count: corrections.length,
conversionReportChecksum,
geometryChanged: false,
};
recast.source.visualTriangleReconciliation = {
count: visualCorrections.length,
conversionReportChecksum,
geometryChanged: false,
};
await writeJson(recastFile, recast);
recastUpdated = true;
}
console.log(JSON.stringify({
status: "green",
slug,
conversionReport: path.relative(projectRoot, conversionFile).replace(/\\/g, "/"),
conversionReportChecksum,
corrections,
visualCorrections,
recastUpdated,
}, null, 2));