385 lines
18 KiB
JavaScript
385 lines
18 KiB
JavaScript
import { createHash } from "node:crypto";
|
|
import { Accessor } from "@gltf-transform/core";
|
|
|
|
const POLICIES = Object.freeze({
|
|
"rom-demon-stronghold-derived-support-entrance-floor-02": Object.freeze({
|
|
id: "rom-demon-stronghold-derived-support-entrance-floor-02",
|
|
version: 1,
|
|
dungeonId: "demon-stronghold",
|
|
sourceModel: "model/dungeon/devil_city/act_devil_city.ros",
|
|
sourceModelSha256: "44f2f455b1895192689e9bc70fa80a5aef70a32c98799540827b30d6c9e7d47b",
|
|
sourceMaterial: "runewaker_material_32",
|
|
sourceTexture: "texture/dungeon/devil_city/entrance_floor_02",
|
|
stagedTexture: "5825da77_entrance_floor_02.dds",
|
|
sourceDdsSha256: "58911f1b62882c68b7f1f1024f4af0df47f86586ae37def4153e1bc77e2fff6c",
|
|
gltfTexture: "5825da77_entrance_floor_02",
|
|
convertedPngSha256: "99d89ac06969dbc39a9071b68b264b97ea7a91c8139e8983d1e2b8ef5f1d5980",
|
|
imageSize: [512, 512],
|
|
supportNode: "runewaker_source_anchor_support",
|
|
supportMaterial: "runewaker_source_anchor_support",
|
|
expectedPadCount: 186,
|
|
expectedTriangleCount: 2232,
|
|
metersPerUv: 1 / 0.0707851783,
|
|
uvPerMeter: 0.0707851783,
|
|
anchorWorld: [-5.025092, -0.220868, -0.382562],
|
|
anchorUv: [0.5238620214, 4.69202905],
|
|
}),
|
|
"rom-mystic-altar-derived-support-mystic-floor": Object.freeze({
|
|
id: "rom-mystic-altar-derived-support-mystic-floor",
|
|
version: 1,
|
|
dungeonId: "mystic-altar",
|
|
sourceModel: "model/dungeon/mystic_altar/act_mystic_altar_instance.ros",
|
|
sourceModelSha256: "14974678994c48a7315a588c7fbcdd6cdcae9c3313cf1b48a470ed736a14b85e",
|
|
sourceMaterial: "runewaker_material_236",
|
|
sourceTexture: "texture/dungeon/mystic_altar/mystic_altar_floor",
|
|
stagedTexture: "caa8475c_mystic_altar_floor.dds",
|
|
sourceDdsSha256: "58db1826f84f913676e79c89f40126afadb9569daa056c789ad7d73faafc7223",
|
|
gltfTexture: "caa8475c_mystic_altar_floor",
|
|
convertedPngSha256: "ef1d7d6d6876ca650d9318804eaba7e706feeb9a3df66c7099a735d63f8b3dd6",
|
|
imageSize: [512, 512],
|
|
supportNode: "runewaker_source_anchor_support",
|
|
supportMaterial: "runewaker_source_anchor_support",
|
|
expectedPadCount: 250,
|
|
expectedTriangleCount: 3000,
|
|
metersPerUv: 1 / 0.043312560155488414,
|
|
uvPerMeter: 0.043312560155488414,
|
|
anchorWorld: [-16.99230033164831, -0.9904689643118673, -28.474957258355573],
|
|
anchorUv: [1.0586127324699568, 0.5470526715423152],
|
|
nativeWorldUvBasis: Object.freeze({
|
|
dUdx: 0.043312560155488414,
|
|
dUdz: 0,
|
|
dVdx: 0,
|
|
dVdz: 0.04331255484774635,
|
|
sideUvDelta: [0.043312560155488414, 0.04331255484774635],
|
|
sourcePrimitive: "Object03_236_mesh_0",
|
|
sourceTriangle: 13,
|
|
}),
|
|
}),
|
|
});
|
|
|
|
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
const normalizeResource = (value) => String(value ?? "").replaceAll("\\", "/").replace(/^\/+/, "").toLowerCase();
|
|
const accessorHash = (accessor) => {
|
|
const array = accessor.getArray();
|
|
return sha256(Buffer.from(array.buffer, array.byteOffset, array.byteLength));
|
|
};
|
|
|
|
function proveSource(policy, dungeonId, sourceReport, sourceDdsBytes) {
|
|
if (dungeonId !== policy.dungeonId) throw new Error(`Derived-support style ${policy.id} cannot run for ${dungeonId}.`);
|
|
if (normalizeResource(sourceReport?.sourceModel) !== policy.sourceModel.toLowerCase()) {
|
|
throw new Error(`Derived-support style ${policy.id} source ROS path does not match the reviewed model.`);
|
|
}
|
|
if (String(sourceReport?.sourceResourceSha256 ?? "").toLowerCase() !== policy.sourceModelSha256) {
|
|
throw new Error(`Derived-support style ${policy.id} source ROS SHA-256 does not match the reviewed model.`);
|
|
}
|
|
const materials = (sourceReport.materials ?? []).filter((material) => material.name === policy.sourceMaterial);
|
|
if (materials.length !== 1) throw new Error(`Derived-support style ${policy.id} expected one exact native material record; found ${materials.length}.`);
|
|
const material = materials[0];
|
|
if (normalizeResource(material.sourceTexture) !== policy.sourceTexture.toLowerCase()) {
|
|
throw new Error(`Derived-support style ${policy.id} native diffuse texture path does not match the reviewed floor.`);
|
|
}
|
|
if (normalizeResource(material.stagedTexture).split("/").at(-1) !== policy.stagedTexture.toLowerCase()) {
|
|
throw new Error(`Derived-support style ${policy.id} staged DDS basename does not match the reviewed floor.`);
|
|
}
|
|
if (!sourceDdsBytes || sha256(sourceDdsBytes) !== policy.sourceDdsSha256) {
|
|
throw new Error(`Derived-support style ${policy.id} source DDS hash does not match the reviewed floor.`);
|
|
}
|
|
return {
|
|
sourceModel: policy.sourceModel,
|
|
sourceModelSha256: policy.sourceModelSha256,
|
|
sourceNativeMaterial: policy.sourceMaterial,
|
|
sourceTexture: policy.sourceTexture,
|
|
stagedTexture: policy.stagedTexture,
|
|
sourceDdsSha256: policy.sourceDdsSha256,
|
|
convertedPngSha256: policy.convertedPngSha256,
|
|
};
|
|
}
|
|
|
|
function pngDimensions(bytes) {
|
|
if (bytes.length < 24 || bytes.toString("hex", 0, 8) !== "89504e470d0a1a0a") return null;
|
|
return [bytes.readUInt32BE(16), bytes.readUInt32BE(20)];
|
|
}
|
|
|
|
function findTarget(document, policy) {
|
|
const root = document.getRoot();
|
|
const floorMaterials = root.listMaterials().filter((material) => material.getName() === policy.sourceMaterial);
|
|
if (floorMaterials.length !== 1) throw new Error(`Derived-support style ${policy.id} expected one converted native floor material; found ${floorMaterials.length}.`);
|
|
const floorMaterial = floorMaterials[0];
|
|
const floorTexture = floorMaterial.getBaseColorTexture();
|
|
if (floorTexture?.getName() !== policy.gltfTexture || !floorMaterial.getBaseColorTextureInfo()) {
|
|
throw new Error(`Derived-support style ${policy.id} converted floor image does not match the reviewed texture.`);
|
|
}
|
|
const image = Buffer.from(floorTexture.getImage() ?? []);
|
|
if (sha256(image) !== policy.convertedPngSha256) throw new Error(`Derived-support style ${policy.id} converted floor image hash changed.`);
|
|
const dimensions = pngDimensions(image);
|
|
if (!dimensions || dimensions[0] !== policy.imageSize[0] || dimensions[1] !== policy.imageSize[1]) {
|
|
throw new Error(`Derived-support style ${policy.id} converted floor image dimensions changed.`);
|
|
}
|
|
if (floorMaterial.getBaseColorTextureInfo().getTexCoord() !== 0) {
|
|
throw new Error(`Derived-support style ${policy.id} expected the native floor to use TEXCOORD_0.`);
|
|
}
|
|
if (floorMaterial.getBaseColorTextureInfo().getExtension("KHR_texture_transform")
|
|
|| [floorMaterial.getNormalTexture(), floorMaterial.getOcclusionTexture(),
|
|
floorMaterial.getEmissiveTexture(), floorMaterial.getMetallicRoughnessTexture()].some(Boolean)) {
|
|
throw new Error(`Derived-support style ${policy.id} refuses unreviewed texture transforms or secondary maps.`);
|
|
}
|
|
|
|
const targets = [];
|
|
for (const node of root.listNodes()) {
|
|
if (node.getName() !== policy.supportNode || !node.getMesh()) continue;
|
|
for (const [primitiveIndex, primitive] of node.getMesh().listPrimitives().entries()) {
|
|
targets.push({ node, primitive, primitiveIndex });
|
|
}
|
|
}
|
|
if (targets.length !== 1) throw new Error(`Derived-support style ${policy.id} expected one support primitive; found ${targets.length}.`);
|
|
return { floorMaterial, floorTexture, image, ...targets[0] };
|
|
}
|
|
|
|
function transformPoint(matrix, point) {
|
|
return [
|
|
matrix[0] * point[0] + matrix[4] * point[1] + matrix[8] * point[2] + matrix[12],
|
|
matrix[1] * point[0] + matrix[5] * point[1] + matrix[9] * point[2] + matrix[13],
|
|
matrix[2] * point[0] + matrix[6] * point[1] + matrix[10] * point[2] + matrix[14],
|
|
];
|
|
}
|
|
|
|
function supportComponents(position, indices, matrix) {
|
|
const count = position.getCount();
|
|
const adjacency = Array.from({ length: count }, () => new Set());
|
|
for (let i = 0; i < indices.length; i += 3) {
|
|
const a = indices[i];
|
|
const b = indices[i + 1];
|
|
const c = indices[i + 2];
|
|
adjacency[a].add(b); adjacency[a].add(c);
|
|
adjacency[b].add(a); adjacency[b].add(c);
|
|
adjacency[c].add(a); adjacency[c].add(b);
|
|
}
|
|
const worldPositions = Array.from({ length: count }, (_, i) => transformPoint(matrix, position.getElement(i, [0, 0, 0])));
|
|
const componentByVertex = new Int32Array(count).fill(-1);
|
|
const components = [];
|
|
for (let start = 0; start < count; start += 1) {
|
|
if (componentByVertex[start] !== -1) continue;
|
|
const componentIndex = components.length;
|
|
const queue = [start];
|
|
componentByVertex[start] = componentIndex;
|
|
for (let cursor = 0; cursor < queue.length; cursor += 1) {
|
|
for (const neighbor of adjacency[queue[cursor]]) {
|
|
if (componentByVertex[neighbor] !== -1) continue;
|
|
componentByVertex[neighbor] = componentIndex;
|
|
queue.push(neighbor);
|
|
}
|
|
}
|
|
components.push(queue);
|
|
}
|
|
const topByVertex = new Float64Array(count);
|
|
for (const component of components) {
|
|
if (component.length !== 8) throw new Error(`Derived-support style expected eight box vertices per support; found ${component.length}.`);
|
|
const topY = Math.max(...component.map((index) => worldPositions[index][1]));
|
|
if (component.filter((index) => Math.abs(worldPositions[index][1] - topY) <= 1e-4).length !== 4) {
|
|
throw new Error("Derived-support style could not identify one four-vertex top face.");
|
|
}
|
|
for (const index of component) topByVertex[index] = topY;
|
|
}
|
|
return { worldPositions, components, topByVertex };
|
|
}
|
|
|
|
function styleMarker(policy, proof) {
|
|
return {
|
|
id: policy.id,
|
|
version: policy.version,
|
|
kind: "derived-support-texture-fallback",
|
|
nativeReconstruction: false,
|
|
adaptedAttribute: "TEXCOORD_1",
|
|
uvPerMeter: policy.uvPerMeter,
|
|
...(policy.nativeWorldUvBasis ? { nativeWorldUvBasis: policy.nativeWorldUvBasis } : {}),
|
|
sourceNativeMaterial: policy.sourceMaterial,
|
|
sourceTexture: policy.gltfTexture,
|
|
...proof,
|
|
};
|
|
}
|
|
|
|
function validateApplied(policy, primitive, marker) {
|
|
const uv1 = primitive.getAttribute("TEXCOORD_1");
|
|
const position = primitive.getAttribute("POSITION");
|
|
const normal = primitive.getAttribute("NORMAL");
|
|
const indices = primitive.getIndices();
|
|
const material = primitive.getMaterial();
|
|
if (!uv1 || uv1.getCount() !== position?.getCount() || material?.getBaseColorTexture()?.getName() !== policy.gltfTexture
|
|
|| material.getBaseColorTextureInfo()?.getTexCoord() !== 1) {
|
|
throw new Error(`Previously applied derived-support style ${policy.id} is incomplete.`);
|
|
}
|
|
if (primitive.getExtras()?.romDerivedSupportStyling?.id !== policy.id) {
|
|
throw new Error(`Previously applied derived-support style ${policy.id} has a mismatched marker.`);
|
|
}
|
|
const prior = primitive.getExtras().romDerivedSupportStyling;
|
|
if (prior.sourcePositionHash !== accessorHash(position) || prior.sourceNormalHash !== accessorHash(normal)
|
|
|| prior.sourceIndexHash !== accessorHash(indices) || prior.adaptedUvHash !== accessorHash(uv1)) {
|
|
throw new Error(`Previously applied derived-support style ${policy.id} accessors no longer match the policy report.`);
|
|
}
|
|
return marker;
|
|
}
|
|
|
|
function validateFullRankUvBasis(policy) {
|
|
if (!policy.nativeWorldUvBasis) return null;
|
|
const { dUdx, dUdz, dVdx, dVdz, sideUvDelta } = policy.nativeWorldUvBasis;
|
|
const determinants = {
|
|
topXZ: dUdx * dVdz - dUdz * dVdx,
|
|
sideXY: dUdx * sideUvDelta[1] - sideUvDelta[0] * dVdx,
|
|
sideZY: dUdz * sideUvDelta[1] - sideUvDelta[0] * dVdz,
|
|
};
|
|
if (Object.values(determinants).some((determinant) => Math.abs(determinant) < 1e-8)) {
|
|
throw new Error(`Derived-support style ${policy.id} native floor projection is not full-rank on every box face.`);
|
|
}
|
|
return determinants;
|
|
}
|
|
|
|
/**
|
|
* Applies the reviewed Demon Stronghold floor texture to its generated source-anchor
|
|
* support boxes. This is an explicit derived-geometry fallback, not native floor recovery.
|
|
* Existing geometry and attributes remain untouched; only a TEXCOORD_1 accessor and a
|
|
* cloned base-color material are added.
|
|
*/
|
|
export function applyRunewakerDerivedSupportStyling(document, {
|
|
dungeonId,
|
|
policyId,
|
|
sourceReport,
|
|
sourceDdsBytes,
|
|
} = {}) {
|
|
const policy = POLICIES[policyId];
|
|
if (!policy) throw new Error(`Unknown RoM derived-support style policy: ${policyId}.`);
|
|
const uvFaceDeterminants = validateFullRankUvBasis(policy);
|
|
const proof = proveSource(policy, dungeonId, sourceReport, sourceDdsBytes);
|
|
const { floorMaterial, floorTexture, image, node, primitive, primitiveIndex } = findTarget(document, policy);
|
|
if (primitive.getExtras()?.romDerivedSupportStyling) {
|
|
const marker = primitive.getExtras().romDerivedSupportStyling;
|
|
validateApplied(policy, primitive, marker);
|
|
return {
|
|
schemaVersion: 1,
|
|
dungeonId,
|
|
status: "already-applied",
|
|
policy: marker,
|
|
primitiveCount: 1,
|
|
triangleCount: primitive.getIndices()?.getCount() / 3,
|
|
padCount: policy.expectedPadCount,
|
|
geometryChanged: false,
|
|
sourceGeometryPreserved: true,
|
|
nativeLightingMetadataChanged: false,
|
|
};
|
|
}
|
|
if (primitive.getMaterial()?.getName() !== policy.supportMaterial) {
|
|
throw new Error(`Derived-support style ${policy.id} target material changed.`);
|
|
}
|
|
if (primitive.getAttribute("TEXCOORD_0") || primitive.getAttribute("TEXCOORD_1")) {
|
|
throw new Error(`Derived-support style ${policy.id} refuses to overwrite existing UV attributes.`);
|
|
}
|
|
const position = primitive.getAttribute("POSITION");
|
|
const normal = primitive.getAttribute("NORMAL");
|
|
const indices = primitive.getIndices();
|
|
if (!position || !normal || !indices || position.getType() !== Accessor.Type.VEC3 || normal.getCount() !== position.getCount()) {
|
|
throw new Error(`Derived-support style ${policy.id} target lacks matching POSITION, NORMAL, or indices.`);
|
|
}
|
|
if (indices.getCount() / 3 !== policy.expectedTriangleCount) {
|
|
throw new Error(`Derived-support style ${policy.id} support topology changed: expected ${policy.expectedTriangleCount} triangles.`);
|
|
}
|
|
const buffer = document.getRoot().listBuffers()[0];
|
|
if (!buffer) throw new Error(`Derived-support style ${policy.id} has no GLB buffer for TEXCOORD_1.`);
|
|
const indexValues = indices.getArray();
|
|
const matrix = Array.from(node.getWorldMatrix());
|
|
const { worldPositions, components, topByVertex } = supportComponents(position, indexValues, matrix);
|
|
if (components.length !== policy.expectedPadCount) {
|
|
throw new Error(`Derived-support style ${policy.id} expected ${policy.expectedPadCount} disconnected support boxes; found ${components.length}.`);
|
|
}
|
|
const [anchorX, anchorY, anchorZ] = policy.anchorWorld;
|
|
const [anchorU, anchorV] = policy.anchorUv;
|
|
const scale = policy.uvPerMeter;
|
|
const values = new Float32Array(position.getCount() * 2);
|
|
for (let index = 0; index < position.getCount(); index += 1) {
|
|
const [x, y, z] = worldPositions[index];
|
|
const vertical = y - topByVertex[index];
|
|
if (policy.nativeWorldUvBasis) {
|
|
const basis = policy.nativeWorldUvBasis;
|
|
values[index * 2] = anchorU + basis.dUdx * (x - anchorX) + basis.dUdz * (z - anchorZ)
|
|
+ basis.sideUvDelta[0] * vertical;
|
|
values[index * 2 + 1] = anchorV + basis.dVdx * (x - anchorX) + basis.dVdz * (z - anchorZ)
|
|
+ basis.sideUvDelta[1] * vertical;
|
|
} else {
|
|
values[index * 2] = anchorU - scale * (z - anchorZ) + scale * vertical;
|
|
values[index * 2 + 1] = anchorV + scale * (x - anchorX) + scale * vertical;
|
|
}
|
|
}
|
|
|
|
const sourceHashes = {
|
|
sourcePositionHash: accessorHash(position),
|
|
sourceNormalHash: accessorHash(normal),
|
|
sourceIndexHash: accessorHash(indices),
|
|
};
|
|
const uv1 = document.createAccessor(`${node.getName()}:${policy.id}:TEXCOORD_1`)
|
|
.setType(Accessor.Type.VEC2)
|
|
.setArray(values)
|
|
.setBuffer(buffer);
|
|
const adaptedMaterial = floorMaterial.clone()
|
|
.setName("runewaker_source_anchor_support_entrance_floor_02_adapted")
|
|
.setExtras({
|
|
...(floorMaterial.getExtras() ?? {}),
|
|
romDerivedSupportStyling: {
|
|
id: policy.id,
|
|
kind: "derived-support-texture-fallback",
|
|
nativeReconstruction: false,
|
|
sourceTexture: policy.gltfTexture,
|
|
},
|
|
});
|
|
adaptedMaterial.getBaseColorTextureInfo().setTexCoord(1);
|
|
const marker = {
|
|
...styleMarker(policy, proof),
|
|
padCount: components.length,
|
|
triangleCount: indices.getCount() / 3,
|
|
sourcePositionHash: sourceHashes.sourcePositionHash,
|
|
sourceNormalHash: sourceHashes.sourceNormalHash,
|
|
sourceIndexHash: sourceHashes.sourceIndexHash,
|
|
adaptedUvHash: sha256(Buffer.from(values.buffer, values.byteOffset, values.byteLength)),
|
|
geometryChanged: false,
|
|
sourceGeometryPreserved: true,
|
|
sourceNativeMaterialPreserved: true,
|
|
nativeLightingMetadataChanged: false,
|
|
};
|
|
primitive.setAttribute("TEXCOORD_1", uv1);
|
|
primitive.setMaterial(adaptedMaterial);
|
|
primitive.setExtras({ ...(primitive.getExtras() ?? {}), romDerivedSupportStyling: marker });
|
|
|
|
return {
|
|
schemaVersion: 1,
|
|
dungeonId,
|
|
status: "applied",
|
|
policy: marker,
|
|
primitiveCount: 1,
|
|
primitiveIndex,
|
|
triangleCount: indices.getCount() / 3,
|
|
padCount: components.length,
|
|
uvChannel: "TEXCOORD_1",
|
|
imageSize: policy.imageSize,
|
|
geometryChanged: false,
|
|
sourceGeometryPreserved: true,
|
|
nativeLightingMetadataChanged: false,
|
|
sourceTextureImageSha256: sha256(image),
|
|
metersPerUv: policy.metersPerUv,
|
|
sourceFloorReference: {
|
|
material: policy.sourceMaterial,
|
|
texture: policy.gltfTexture,
|
|
anchorWorld: policy.anchorWorld,
|
|
anchorUv: policy.anchorUv,
|
|
uvPerMeter: policy.uvPerMeter,
|
|
densityPixelsPerMeter: policy.uvPerMeter * policy.imageSize[0],
|
|
topFaceMatchesReferencePhase: true,
|
|
sideFacesUseVerticalWorldProjection: true,
|
|
...(policy.nativeWorldUvBasis ? {
|
|
nativeWorldUvBasis: policy.nativeWorldUvBasis,
|
|
fullRankFaceDeterminants: uvFaceDeterminants,
|
|
} : {}),
|
|
},
|
|
};
|
|
}
|
|
|
|
export function listRunewakerDerivedSupportStylingPolicies() {
|
|
return Object.values(POLICIES).map((policy) => ({ ...policy, imageSize: [...policy.imageSize], anchorWorld: [...policy.anchorWorld], anchorUv: [...policy.anchorUv] }));
|
|
}
|