204 lines
10 KiB
JavaScript
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] }));
|
|
}
|