Files
healer-man/scripts/runewaker-pipeline/lib/rom-visual-uv-adaptations.mjs
T
2026-10-06 22:25:34 -04:00

204 lines
10 KiB
JavaScript

import { createHash } from "node:crypto";
import { Accessor } from "@gltf-transform/core";
// Reviewed browser surface fixes live in rom-surface-quality-policies.mjs.
// This registry remains for future UV-only adaptations whose projection is
// safe to express as one source-UV transform.
const POLICIES = Object.freeze({});
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
const accessorHash = (accessor) => {
const array = accessor.getArray();
return sha256(Buffer.from(array.buffer, array.byteOffset, array.byteLength));
};
const normalizeResource = (value) => String(value ?? "").replaceAll("\\", "/").replace(/^\/+/, "").toLowerCase();
function sourceProof(policy, dungeonId, sourceReport) {
if (dungeonId !== policy.dungeonId) throw new Error(`UV policy ${policy.id} cannot run for ${dungeonId}.`);
if (normalizeResource(sourceReport?.sourceModel) !== policy.sourceModel.toLowerCase()) {
throw new Error(`UV policy ${policy.id} source ROS path does not match the pinned model.`);
}
if (String(sourceReport?.sourceResourceSha256 ?? "").toLowerCase() !== policy.sourceModelSha256) {
throw new Error(`UV policy ${policy.id} source ROS SHA-256 does not match the reviewed source.`);
}
const matches = (sourceReport.materials ?? []).filter((material) => material.name === policy.sourceMaterial);
if (matches.length !== 1) throw new Error(`UV policy ${policy.id} expected one exact native material record; found ${matches.length}.`);
const sourceMaterial = matches[0];
if (normalizeResource(sourceMaterial.sourceTexture) !== policy.sourceTexture.toLowerCase()) {
throw new Error(`UV policy ${policy.id} native diffuse texture path does not match the reviewed source.`);
}
if (normalizeResource(sourceMaterial.stagedTexture).split("/").at(-1) !== policy.stagedTexture.toLowerCase()) {
throw new Error(`UV policy ${policy.id} staged texture basename does not match the reviewed source.`);
}
return {
sourceModel: policy.sourceModel,
sourceModelSha256: policy.sourceModelSha256,
sourceMaterial: policy.sourceMaterial,
sourceTexture: policy.sourceTexture,
stagedTexture: policy.stagedTexture,
};
}
function policyMarker(policy) {
return {
id: policy.id,
version: 1,
repeat: [...policy.repeat],
sourceAttribute: "TEXCOORD_0",
adaptedAttribute: "TEXCOORD_1",
sourceMaterial: policy.sourceMaterial,
sourceTexture: policy.gltfTexture,
};
}
function validateAdaptedPrimitive(primitive, policy) {
const info = primitive.getMaterial()?.getBaseColorTextureInfo();
const uv0 = primitive.getAttribute("TEXCOORD_0");
const uv1 = primitive.getAttribute("TEXCOORD_1");
if (!uv0 || !uv1 || info?.getTexCoord() !== 1) throw new Error(`Previously adapted primitive is incomplete for ${policy.id}.`);
if (primitive.getExtras()?.romUvAdaptation?.id !== policy.id) throw new Error(`Previously adapted primitive has a mismatched marker for ${policy.id}.`);
if (uv0.getCount() !== uv1.getCount()) throw new Error(`Previously adapted UV accessor counts differ for ${policy.id}.`);
for (let index = 0; index < uv0.getCount(); index += 1) {
const source = uv0.getElement(index, [0, 0]);
const adapted = uv1.getElement(index, [0, 0]);
if (Math.abs(adapted[0] - source[0] * policy.repeat[0]) > 1e-6
|| Math.abs(adapted[1] - source[1] * policy.repeat[1]) > 1e-6) {
throw new Error(`Previously adapted UV values are invalid for ${policy.id}.`);
}
}
}
/**
* Applies explicitly opted-in, source-pinned RoM UV corrections to a canonical
* unoptimized visual document. The source UV0 and all geometry attributes stay
* intact; the cloned base-color material alone reads repeated UV1.
*/
export function applyRunewakerVisualUvAdaptations(document, { dungeonId, policyIds = [], sourceReport } = {}) {
if (new Set(policyIds).size !== policyIds.length) throw new Error("RoM visual UV adaptation policy IDs must be unique.");
const root = document.getRoot();
const result = {
schemaVersion: 1,
dungeonId,
status: policyIds.length ? "applied" : "no-policy",
policies: [],
materialLightingMetadataChanged: false,
geometryChanged: false,
sourceUv0Changed: false,
};
for (const policyId of policyIds) {
const policy = POLICIES[policyId];
if (!policy) throw new Error(`Unknown RoM visual UV adaptation policy: ${policyId}.`);
const proof = sourceProof(policy, dungeonId, sourceReport);
const marker = policyMarker(policy);
const originalMaterial = root.listMaterials().find((material) => material.getName() === policy.sourceMaterial);
if (!originalMaterial) throw new Error(`UV policy ${policy.id} source material is absent from the canonical GLB.`);
const originalTexture = originalMaterial.getBaseColorTexture();
if (originalTexture?.getName() !== policy.gltfTexture || !originalMaterial.getBaseColorTextureInfo()) {
throw new Error(`UV policy ${policy.id} base-color image does not match the source report.`);
}
if (originalMaterial.getNormalTexture() || originalMaterial.getMetallicRoughnessTexture() || originalMaterial.getEmissiveTexture()) {
throw new Error(`UV policy ${policy.id} has unexpected secondary texture slots.`);
}
const primitives = [];
const alreadyAdapted = [];
for (const node of root.listNodes()) {
if (!node.getMesh()) continue;
for (const [primitiveIndex, primitive] of node.getMesh().listPrimitives().entries()) {
if (primitive.getMaterial()?.getName() === policy.sourceMaterial) {
if (primitive.getMaterial().getBaseColorTexture()?.getName() !== policy.gltfTexture) {
throw new Error(`UV policy ${policy.id} material use maps to an unexpected image.`);
}
primitives.push({ node, primitive, primitiveIndex });
} else if (primitive.getExtras()?.romUvAdaptation?.id === policy.id) {
alreadyAdapted.push({ node, primitive, primitiveIndex });
}
}
}
if (alreadyAdapted.length) {
if (primitives.length) throw new Error(`UV policy ${policy.id} document contains a partial adaptation.`);
for (const { primitive } of alreadyAdapted) validateAdaptedPrimitive(primitive, policy);
result.status = "already-applied";
result.policies.push({
...proof,
...marker,
status: "already-applied",
primitiveCount: alreadyAdapted.length,
triangleCount: alreadyAdapted.reduce((sum, { primitive }) => sum + (primitive.getIndices()?.getCount() ?? primitive.getAttribute("POSITION")?.getCount() ?? 0) / 3, 0),
nodes: alreadyAdapted.map(({ node, primitive }) => ({ node: node.getName(), sourceUvHash: accessorHash(primitive.getAttribute("TEXCOORD_0")), adaptedUvHash: accessorHash(primitive.getAttribute("TEXCOORD_1")) })),
lightingMetadataPreserved: true,
});
continue;
}
if (!primitives.length) throw new Error(`UV policy ${policy.id} found no matching canonical GLB primitives.`);
const buffer = root.listBuffers()[0];
if (!buffer) throw new Error(`UV policy ${policy.id} document has no buffer for new accessors.`);
const preflight = primitives.map(({ node, primitive, primitiveIndex }) => {
if (primitive.getExtras()?.romUvAdaptation) throw new Error(`UV policy ${policy.id} target is marked by another adaptation.`);
if (primitive.getAttribute("TEXCOORD_1")) throw new Error(`UV policy ${policy.id} refuses to overwrite existing TEXCOORD_1.`);
const position = primitive.getAttribute("POSITION");
const normal = primitive.getAttribute("NORMAL");
const indices = primitive.getIndices();
const uv0 = primitive.getAttribute("TEXCOORD_0");
if (!position || !normal || !indices || !uv0 || uv0.getType() !== Accessor.Type.VEC2) {
throw new Error(`UV policy ${policy.id} target is missing a required POSITION, NORMAL, index, or VEC2 TEXCOORD_0 accessor.`);
}
return { node, primitive, primitiveIndex, position, normal, indices, uv0 };
});
const adaptedMaterial = originalMaterial.clone().setName(`${policy.sourceMaterial}_repeat${policy.repeat[0]}x${policy.repeat[1]}_adapted`);
adaptedMaterial.getBaseColorTextureInfo().setTexCoord(1);
const policyReport = {
...proof,
...marker,
status: "applied",
primitiveCount: primitives.length,
triangleCount: 0,
nodes: [],
lightingMetadataPreserved: true,
noGeometryChange: true,
noOtherUvSetChanged: true,
};
for (const { node, primitive, primitiveIndex, position, normal, indices, uv0 } of preflight) {
const sourceHashes = {
position: accessorHash(position),
normal: accessorHash(normal),
indices: accessorHash(indices),
uv0: accessorHash(uv0),
};
const values = new Float32Array(uv0.getCount() * 2);
for (let index = 0; index < uv0.getCount(); index += 1) {
const uv = uv0.getElement(index, [0, 0]);
values[index * 2] = uv[0] * policy.repeat[0];
values[index * 2 + 1] = uv[1] * policy.repeat[1];
}
const uv1 = document.createAccessor(`${node.getName()}:${policy.id}:TEXCOORD_1`)
.setType(Accessor.Type.VEC2)
.setArray(values)
.setBuffer(buffer);
primitive.setAttribute("TEXCOORD_1", uv1);
primitive.setMaterial(adaptedMaterial);
primitive.setExtras({ ...(primitive.getExtras() ?? {}), romUvAdaptation: marker });
policyReport.triangleCount += indices.getCount() / 3;
policyReport.nodes.push({
node: node.getName(),
primitiveIndex,
triangles: indices.getCount() / 3,
vertices: position.getCount(),
sourcePositionHash: sourceHashes.position,
sourceNormalHash: sourceHashes.normal,
sourceIndexHash: sourceHashes.indices,
sourceUvHash: sourceHashes.uv0,
adaptedUvHash: sha256(Buffer.from(values.buffer, values.byteOffset, values.byteLength)),
});
}
result.policies.push(policyReport);
}
return result;
}
export function listRunewakerVisualUvAdaptationPolicies() {
return Object.values(POLICIES).map((policy) => ({ ...policy, repeat: [...policy.repeat] }));
}