#!/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 "); } 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));