import { NodeIO } from "@gltf-transform/core"; import { ALL_EXTENSIONS, KHRMaterialsUnlit } from "@gltf-transform/extensions"; import { writeFile } from "node:fs/promises"; import path from "node:path"; import { pathToFileURL } from "node:url"; const SHA256 = /^[\da-f]{64}$/i; export function hasVerifiedNativeMaterialIdentity(extras) { return typeof extras?.nativeSourceModel === "string" && extras.nativeSourceModel.toLowerCase().endsWith(".ros") && typeof extras.nativeSourceResourceSha256 === "string" && SHA256.test(extras.nativeSourceResourceSha256) && Number.isInteger(extras.nativeMaterialIndex) && extras.nativeMaterialIndex >= 0 && typeof extras.nativeSourceTexture === "string" && extras.nativeSourceTexture.trim().length > 0; } /** Preserve only source-proven lighting modes on native ROS materials. */ export async function applyNativeMaterialSemantics(inputFile, outputFile = inputFile) { const io = new NodeIO().registerExtensions(ALL_EXTENSIONS); const document = await io.read(inputFile); const materials = document.getRoot().listMaterials(); const prelitRows = []; const additiveRows = []; for (const material of materials) { const extras = material.getExtras() ?? {}; if (!hasVerifiedNativeMaterialIdentity(extras)) { if (extras.nativeLightingType === "prelit" || extras.nativeBlendMode === "additive") { throw new Error(`Native lighting/blend metadata lacks verified material provenance: ${material.getName()}`); } continue; } if (extras.nativeLightingType === "prelit") prelitRows.push(material); if (extras.nativeBlendMode === "additive" && extras.nativeAlphaPolicy === "native-srcalpha-one-additive") additiveRows.push(material); } if (prelitRows.length > 0) { const unlit = document.createExtension(KHRMaterialsUnlit).setRequired(false).createUnlit(); for (const material of prelitRows) { // Blender exports specular metadata even for source-prelit surfaces. // glTF forbids its combination with unlit; it has no effect on prelit RGB. material.setExtension("KHR_materials_specular", null); material.setExtension("KHR_materials_unlit", unlit); } } await io.write(outputFile, document); const report = { schemaVersion: 1, input: path.resolve(inputFile), output: path.resolve(outputFile), khrMaterialsUnlit: prelitRows.map(material => ({ material: material.getName(), sourceModel: material.getExtras().nativeSourceModel, sourceResourceSha256: material.getExtras().nativeSourceResourceSha256, nativeMaterialIndex: material.getExtras().nativeMaterialIndex, sourceTexture: material.getExtras().nativeSourceTexture, })), nativeAdditiveMaterials: additiveRows.map(material => ({ material: material.getName(), sourceModel: material.getExtras().nativeSourceModel, sourceResourceSha256: material.getExtras().nativeSourceResourceSha256, nativeMaterialIndex: material.getExtras().nativeMaterialIndex, sourceTexture: material.getExtras().nativeSourceTexture, alphaMode: material.getAlphaMode(), })), }; return report; } if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { const inputFile = process.argv[2]; if (!inputFile) throw new Error("Usage: node apply-native-material-semantics.mjs [report.json]"); const report = await applyNativeMaterialSemantics(inputFile); const reportFile = process.argv[3]; if (reportFile) await writeFile(reportFile, `${JSON.stringify(report, null, 2)}\n`, "utf8"); console.log(JSON.stringify({ output: report.output, unlitMaterialCount: report.khrMaterialsUnlit.length, additiveMaterialCount: report.nativeAdditiveMaterials.length, })); }