forked from phenom/healer-man
747 lines
41 KiB
JavaScript
747 lines
41 KiB
JavaScript
import { createHash } from "node:crypto";
|
||
import { Accessor } from "@gltf-transform/core";
|
||
import { KHRMaterialsVariants } from "@gltf-transform/extensions";
|
||
import sharp from "sharp";
|
||
|
||
const BARREN_POLICY_ID = "rom-barren-caves-wretched-tunnel-surface-quality-v2";
|
||
const HOTO_POLICY_ID = "rom-hoto-source-rock-detail-v2";
|
||
const HOTO_LEGACY_POLICY_ID = "rom-hoto-source-rock-detail-v1";
|
||
const BARREN_SOURCE = Object.freeze({
|
||
model: "model/dungeon/wretched_grotto/act_world_wretched_grotto.ros",
|
||
sha256: "d2de5b83f63248b9b6f6d17e13104ee3cddbc4e77903d4eb561ca5731556bab7",
|
||
material: "runewaker_material_12",
|
||
texture: "texture/dungeon/wretched_grotto/wretched_tunnel",
|
||
image: "a6d9c718_wretched_tunnel",
|
||
imageSha256: "0f05ae62b2486ae90a9836873fac287fd89169abcef962ac7dfacd81fb469a7c",
|
||
});
|
||
const HOTO_SOURCE = Object.freeze({
|
||
model: "model/dungeon/echoes_of_the_sea/act_echoes_of_the_sea.ros",
|
||
sha256: "a0bd77c09045f8bfa1507484bbb2c2ff0cc72d397df4f769fc4e6d5aab14f8a2",
|
||
});
|
||
const HOTO_TARGETS = Object.freeze({
|
||
runewaker_material_96: Object.freeze({
|
||
nativeMaterial: "runewaker_material_96", texture: "texture/dungeon/echoes_of_the_sea/echoes_of_the_sea_01",
|
||
image: "122cc01b_echoes_of_the_sea_01", imageSha256: "ea395afa1d0c65aa57ed7e675a352ca47aab0b1f7a629a3bebdf36e03621a3b1",
|
||
floorStrength: 0.85, floorBaseStrength: 0, wallStrength: 0.85, legacyWallStrength: 0.45,
|
||
wallBaseContrast: 0.6, wallDetailVariation: 1, useOwnBaseMean: true,
|
||
}),
|
||
runewaker_material_53: Object.freeze({
|
||
nativeMaterial: "runewaker_material_53", texture: "texture/dungeon/echoes_of_the_sea/echoes_of_the_sea_04",
|
||
image: "5c863840_echoes_of_the_sea_04", imageSha256: "fbb0eb3ac74689cf2e7b6bc043bc15f1c3cbc4638fc15675ff0710037e9666f9",
|
||
floorStrength: 0.85, floorBaseStrength: 0, wallStrength: 0.85, legacyWallStrength: 0.45,
|
||
wallBaseContrast: 0.6, wallDetailVariation: 1, useOwnBaseMean: true,
|
||
}),
|
||
runewaker_material_26: Object.freeze({
|
||
nativeMaterial: "runewaker_material_94", texture: "texture/dungeon/echoes_of_the_sea/echoes_of_the_sea_02",
|
||
image: "5672f062_echoes_of_the_sea_02", imageSha256: "bc32a6751da4db246d50f66d2c9fb625f640be652956d7022a4feb649d62a1d2",
|
||
floorVariantRole: "floor", floorStrength: 0, floorBaseStrength: 1, wallStrength: 0, legacyWallStrength: 0,
|
||
wallBaseContrast: 1, wallDetailVariation: 0,
|
||
}),
|
||
runewaker_material_18: Object.freeze({
|
||
nativeMaterial: "runewaker_material_95", texture: "texture/dungeon/echoes_of_the_sea/echoes_of_the_sea_03",
|
||
image: "b2ababb9_echoes_of_the_sea_03", imageSha256: "7e20e5f22744d638fa9bd983c13844800bc316e3502ab092bf6d8b6f68a1d018",
|
||
floorVariantRole: "wall", floorStrength: 0, floorBaseStrength: 1, wallStrength: 0.45, legacyWallStrength: 0.45,
|
||
wallBaseContrast: 1, wallDetailVariation: 0,
|
||
}),
|
||
});
|
||
const HOTO_DETAIL = Object.freeze({
|
||
floor: Object.freeze({
|
||
nativeMaterial: "runewaker_material_94", canonicalMaterial: "runewaker_material_26",
|
||
texture: "texture/dungeon/echoes_of_the_sea/echoes_of_the_sea_02",
|
||
image: "5672f062_echoes_of_the_sea_02", imageSha256: "bc32a6751da4db246d50f66d2c9fb625f640be652956d7022a4feb649d62a1d2",
|
||
}),
|
||
wall: Object.freeze({
|
||
nativeMaterial: "runewaker_material_95", canonicalMaterial: "runewaker_material_18",
|
||
texture: "texture/dungeon/echoes_of_the_sea/echoes_of_the_sea_03",
|
||
image: "b2ababb9_echoes_of_the_sea_03", imageSha256: "7e20e5f22744d638fa9bd983c13844800bc316e3502ab092bf6d8b6f68a1d018",
|
||
}),
|
||
});
|
||
const HOTO_VARIANTS = Object.freeze({ floor: "rom-detail-hoto-floor", wall: "rom-detail-hoto-wall" });
|
||
|
||
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 array ? sha256(Buffer.from(array.buffer, array.byteOffset, array.byteLength)) : null;
|
||
};
|
||
|
||
function verifySourceReport(sourceReport, source) {
|
||
if (normalizeResource(sourceReport?.sourceModel) !== source.model.toLowerCase()) {
|
||
throw new Error(`Surface policy source ROS path does not match ${source.model}.`);
|
||
}
|
||
if (String(sourceReport?.sourceResourceSha256 ?? "").toLowerCase() !== source.sha256) {
|
||
throw new Error(`Surface policy source ROS SHA-256 does not match ${source.model}.`);
|
||
}
|
||
}
|
||
|
||
function verifyNativeMaterial(sourceReport, nativeName, texturePath) {
|
||
const rows = (sourceReport?.materials ?? []).filter((row) => row.name === nativeName);
|
||
if (rows.length !== 1 || normalizeResource(rows[0].sourceTexture) !== texturePath.toLowerCase()) {
|
||
throw new Error(`Expected exact native material/texture binding ${nativeName} -> ${texturePath}.`);
|
||
}
|
||
return rows[0];
|
||
}
|
||
|
||
function listVariants(document) {
|
||
const root = document.getRoot();
|
||
const extension = root.listExtensionsUsed().find((item) => item.extensionName === KHRMaterialsVariants.EXTENSION_NAME)
|
||
?? document.createExtension(KHRMaterialsVariants).setRequired(false);
|
||
const byName = new Map(extension.listVariants().map((variant) => [variant.getName(), variant]));
|
||
const getOrCreate = (name) => byName.get(name) ?? (byName.set(name, extension.createVariant(name)), byName.get(name));
|
||
return { extension, getOrCreate };
|
||
}
|
||
|
||
function addSurfaceMappings(document, primitive, carrierByName, variantByName, names = [HOTO_VARIANTS.floor, HOTO_VARIANTS.wall]) {
|
||
if (primitive.getExtension(KHRMaterialsVariants.EXTENSION_NAME)) {
|
||
throw new Error("Surface-quality policy will not replace an existing KHR_materials_variants mapping.");
|
||
}
|
||
const variants = document.getRoot().listExtensionsUsed().find((item) => item.extensionName === KHRMaterialsVariants.EXTENSION_NAME);
|
||
const mappingList = variants.createMappingList();
|
||
for (const name of names) {
|
||
const carrier = carrierByName.get(name);
|
||
const variant = variantByName.get(name);
|
||
if (!carrier || !variant) throw new Error(`Missing surface-detail carrier or variant ${name}.`);
|
||
mappingList.addMapping(variants.createMapping().setMaterial(carrier).addVariant(variant));
|
||
}
|
||
primitive.setExtension(KHRMaterialsVariants.EXTENSION_NAME, mappingList);
|
||
}
|
||
|
||
function getOrCreateCarrier(document, sourceTexture, name, extras) {
|
||
const root = document.getRoot();
|
||
const found = root.listMaterials().find((material) => material.getName() === `${name}:source-tile-carrier`);
|
||
if (found) {
|
||
if (found.getBaseColorTexture() !== sourceTexture || found.getExtras()?.runewakerSurfaceDetailCarrier?.policyId !== extras.policyId) {
|
||
throw new Error(`Existing surface-detail carrier ${name} does not match its pinned source.`);
|
||
}
|
||
return found;
|
||
}
|
||
return document.createMaterial(`${name}:source-tile-carrier`)
|
||
.setBaseColorTexture(sourceTexture)
|
||
.setMetallicFactor(0)
|
||
.setRoughnessFactor(1)
|
||
.setExtras({ runewakerSurfaceDetailCarrier: extras });
|
||
}
|
||
|
||
async function meanLinearFromPng(bytes) {
|
||
const { data, info } = await sharp(bytes).removeAlpha().raw().toBuffer({ resolveWithObject: true });
|
||
if (info.channels < 3 || info.width < 1 || info.height < 1) throw new Error("Surface-detail source image must contain RGB pixels.");
|
||
const sums = [0, 0, 0];
|
||
for (let offset = 0; offset < data.length; offset += info.channels) {
|
||
for (let channel = 0; channel < 3; channel += 1) {
|
||
const srgb = data[offset + channel] / 255;
|
||
sums[channel] += srgb <= 0.04045 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4;
|
||
}
|
||
}
|
||
const count = info.width * info.height;
|
||
return sums.map((value) => value / count);
|
||
}
|
||
|
||
function trianglePositions(matrix, position, indices) {
|
||
return indices.map((index) => {
|
||
const value = position.getElement(index, [0, 0, 0]);
|
||
return [
|
||
matrix[0] * value[0] + matrix[4] * value[1] + matrix[8] * value[2] + matrix[12],
|
||
matrix[1] * value[0] + matrix[5] * value[1] + matrix[9] * value[2] + matrix[13],
|
||
matrix[2] * value[0] + matrix[6] * value[1] + matrix[10] * value[2] + matrix[14],
|
||
];
|
||
});
|
||
}
|
||
|
||
function sub3(left, right) { return [left[0] - right[0], left[1] - right[1], left[2] - right[2]]; }
|
||
function cross3(left, right) {
|
||
return [left[1] * right[2] - left[2] * right[1], left[2] * right[0] - left[0] * right[2], left[0] * right[1] - left[1] * right[0]];
|
||
}
|
||
function length3(value) { return Math.hypot(value[0], value[1], value[2]); }
|
||
|
||
const BARREN_PATCHES = Object.freeze([
|
||
{ key: "u0-v0", col: 0, row: 0, rect: { left: 0, top: 0, width: 256, height: 256 } },
|
||
{ key: "u3-v0", col: 3, row: 0, rect: { left: 768, top: 0, width: 256, height: 256 } },
|
||
{ key: "u0-v1", col: 0, row: 1, rect: { left: 0, top: 256, width: 256, height: 256 } },
|
||
{ key: "u3-v1", col: 3, row: 1, rect: { left: 768, top: 256, width: 256, height: 256 } },
|
||
].map((patch) => Object.freeze({
|
||
...patch,
|
||
uvRect: { u0: patch.col / 4, u1: (patch.col + 1) / 4, v0: patch.row / 2, v1: (patch.row + 1) / 2 },
|
||
})));
|
||
|
||
const BARREN_WALL_DETAIL_PATCH = Object.freeze({
|
||
key: "wall-u1-v0",
|
||
rect: Object.freeze({ left: 256, top: 0, width: 256, height: 256 }),
|
||
textureName: "rom-barren-wretched-tunnel-wall-detail-u1-v0",
|
||
});
|
||
|
||
function barrenPatchForTriangle(uvs, worldNormalY) {
|
||
if (worldNormalY < 0.7) return null;
|
||
const tolerance = 0.0025;
|
||
const minU = Math.min(...uvs.map((uv) => uv[0]));
|
||
const maxU = Math.max(...uvs.map((uv) => uv[0]));
|
||
const minV = Math.min(...uvs.map((uv) => uv[1]));
|
||
const maxV = Math.max(...uvs.map((uv) => uv[1]));
|
||
return BARREN_PATCHES.find((patch) => minU >= patch.uvRect.u0 - tolerance && maxU <= patch.uvRect.u1 + tolerance
|
||
&& minV >= patch.uvRect.v0 - tolerance && maxV <= patch.uvRect.v1 + tolerance)?.key ?? null;
|
||
}
|
||
|
||
async function applyBarrenFloorPolicy(document, sourceReport) {
|
||
verifySourceReport(sourceReport, BARREN_SOURCE);
|
||
verifyNativeMaterial(sourceReport, BARREN_SOURCE.material, BARREN_SOURCE.texture);
|
||
const root = document.getRoot();
|
||
const sourceMaterial = root.listMaterials().find((material) => material.getName() === BARREN_SOURCE.material);
|
||
if (!sourceMaterial) throw new Error("Barren atlas-safe policy cannot find native material 12.");
|
||
const sourceTexture = sourceMaterial.getBaseColorTexture();
|
||
const sourcePng = Buffer.from(sourceTexture?.getImage() ?? []);
|
||
if (sourceTexture?.getName() !== BARREN_SOURCE.image || sha256(sourcePng) !== BARREN_SOURCE.imageSha256) {
|
||
throw new Error("Barren material 12 embedded atlas differs from the pinned native prepared PNG.");
|
||
}
|
||
const imageMeta = await sharp(sourcePng).metadata();
|
||
if (imageMeta.width !== 1024 || imageMeta.height !== 512) throw new Error("Barren atlas dimensions differ from the reviewed 1024×512 source.");
|
||
const baseMeanLinear = await meanLinearFromPng(sourcePng);
|
||
|
||
// Migrate the previous whole-atlas x4 experiment back to authentic UV0
|
||
// before splitting only faces proven to stay inside a native floor island.
|
||
let legacyRepeatPrimitivesRestored = 0;
|
||
for (const node of root.listNodes()) {
|
||
const mesh = node.getMesh();
|
||
if (!mesh) continue;
|
||
for (const primitive of mesh.listPrimitives()) {
|
||
if (primitive.getExtras()?.romUvAdaptation?.id !== "rom-barren-caves-wretched-tunnel-repeat-x4") continue;
|
||
const material = primitive.getMaterial();
|
||
const uv0 = primitive.getAttribute("TEXCOORD_0");
|
||
const uv1 = primitive.getAttribute("TEXCOORD_1");
|
||
if (material?.getBaseColorTexture()?.getName() !== BARREN_SOURCE.image || !uv0 || !uv1
|
||
|| uv0.getCount() !== uv1.getCount()) {
|
||
throw new Error("Cannot safely restore an earlier Barren whole-atlas x4 primitive.");
|
||
}
|
||
for (let index = 0; index < uv0.getCount(); index += 1) {
|
||
const original = uv0.getElement(index, [0, 0]);
|
||
const adapted = uv1.getElement(index, [0, 0]);
|
||
if (Math.abs(adapted[0] - original[0] * 4) > 1e-6 || Math.abs(adapted[1] - original[1] * 4) > 1e-6) {
|
||
throw new Error("Earlier Barren repeat marker does not contain the expected x4 UV1 values.");
|
||
}
|
||
}
|
||
const extras = { ...(primitive.getExtras() ?? {}) };
|
||
delete extras.romUvAdaptation;
|
||
primitive.setExtras(extras);
|
||
primitive.setAttribute("TEXCOORD_1", null);
|
||
primitive.setMaterial(sourceMaterial);
|
||
legacyRepeatPrimitivesRestored += 1;
|
||
}
|
||
}
|
||
|
||
const alreadyApplied = sourceMaterial.getExtras()?.runewakerSurfaceDetail?.policyId === BARREN_POLICY_ID;
|
||
if (alreadyApplied) {
|
||
const surfaceDetail = sourceMaterial.getExtras()?.runewakerSurfaceDetail;
|
||
const cropTextures = root.listTextures().filter((texture) => texture.getExtras()?.romSourcePixelCrop?.policyId === BARREN_POLICY_ID);
|
||
const cropMaterials = root.listMaterials().filter((material) => material.getExtras()?.romUvAdaptation?.id === BARREN_POLICY_ID);
|
||
const expectedCropMaterialNames = new Set(BARREN_PATCHES.map((patch) => `${BARREN_SOURCE.material}_${patch.key}_crop_repeat4`));
|
||
const actualCropMaterialNames = new Set(cropMaterials.map((material) => material.getName()));
|
||
const wallTexture = root.listTextures().find((texture) => texture.getName() === BARREN_WALL_DETAIL_PATCH.textureName);
|
||
const wallCarrier = root.listMaterials().find((material) => material.getName() === "rom-detail-barren-caves-wall:source-tile-carrier");
|
||
const wallInfo = wallCarrier?.getBaseColorTextureInfo();
|
||
if (surfaceDetail.wallBaseContrast !== 0.25 || surfaceDetail.wallStrength !== 0.85 || surfaceDetail.wallDetailVariation !== 1
|
||
|| JSON.stringify(surfaceDetail.baseMeanLinear) !== JSON.stringify(baseMeanLinear)
|
||
|| cropTextures.length !== BARREN_PATCHES.length + 1 || !wallTexture || wallCarrier?.getBaseColorTexture() !== wallTexture
|
||
|| wallInfo?.getWrapS() !== 33648 || wallInfo?.getWrapT() !== 33648
|
||
|| actualCropMaterialNames.size !== BARREN_PATCHES.length
|
||
|| [...expectedCropMaterialNames].some((name) => !actualCropMaterialNames.has(name))) {
|
||
throw new Error("Barren atlas-safe policy marker is present but its exact floor/wall crop carriers are incomplete.");
|
||
}
|
||
return {
|
||
id: BARREN_POLICY_ID, status: "already-applied", cropTextureCount: cropTextures.length,
|
||
cropMaterialCount: actualCropMaterialNames.size, cropMaterialInstanceCount: cropMaterials.length,
|
||
wallDetailTextureName: wallTexture.getName(), wallDetailWrap: "mirrored-repeat",
|
||
repeatFactor: 4, trianglePartitionChanged: true,
|
||
sourceModel: BARREN_SOURCE.model, sourceModelSha256: BARREN_SOURCE.sha256,
|
||
};
|
||
}
|
||
if (root.listTextures().some((texture) => texture.getExtras()?.romSourcePixelCrop?.policyId === BARREN_POLICY_ID)) {
|
||
throw new Error("Barren source GLB already contains partial atlas-safe floor crops.");
|
||
}
|
||
const buffer = root.listBuffers()[0];
|
||
if (!buffer) throw new Error("Barren canonical GLB has no binary buffer for UV1.");
|
||
const cropped = new Map();
|
||
for (const patch of BARREN_PATCHES) {
|
||
const png = await sharp(sourcePng).extract(patch.rect).png().toBuffer();
|
||
const rgba = await sharp(png).ensureAlpha().raw().toBuffer();
|
||
const meanLinear = await meanLinearFromPng(png);
|
||
const sourcePixelCrop = {
|
||
version: 1,
|
||
sourceImageName: BARREN_SOURCE.image,
|
||
sourcePreparedPngSha256: BARREN_SOURCE.imageSha256,
|
||
sourceWidth: 1024,
|
||
sourceHeight: 512,
|
||
rect: patch.rect,
|
||
croppedPngSha256: sha256(png),
|
||
croppedRgbaSha256: sha256(rgba),
|
||
policyId: BARREN_POLICY_ID,
|
||
};
|
||
const texture = document.createTexture(`rom-barren-wretched-tunnel-${patch.key}`)
|
||
.setMimeType("image/png")
|
||
.setImage(png)
|
||
.setExtras({ romSourcePixelCrop: sourcePixelCrop });
|
||
cropped.set(patch.key, { patch, png, meanLinear, sourcePixelCrop, texture });
|
||
}
|
||
const wallDetailPng = await sharp(sourcePng).extract(BARREN_WALL_DETAIL_PATCH.rect).png().toBuffer();
|
||
const wallDetailRgba = await sharp(wallDetailPng).ensureAlpha().raw().toBuffer();
|
||
const wallDetailCrop = {
|
||
version: 1,
|
||
sourceImageName: BARREN_SOURCE.image,
|
||
sourcePreparedPngSha256: BARREN_SOURCE.imageSha256,
|
||
sourceWidth: 1024,
|
||
sourceHeight: 512,
|
||
rect: BARREN_WALL_DETAIL_PATCH.rect,
|
||
croppedPngSha256: sha256(wallDetailPng),
|
||
croppedRgbaSha256: sha256(wallDetailRgba),
|
||
policyId: BARREN_POLICY_ID,
|
||
};
|
||
const wallDetailTexture = document.createTexture(BARREN_WALL_DETAIL_PATCH.textureName)
|
||
.setMimeType("image/png")
|
||
.setImage(wallDetailPng)
|
||
.setExtras({ romSourcePixelCrop: wallDetailCrop });
|
||
const wallDetailMeanLinear = await meanLinearFromPng(wallDetailPng);
|
||
|
||
const variantInfo = listVariants(document);
|
||
const variantNames = ["rom-detail-barren-caves-floor", "rom-detail-barren-caves-wall"];
|
||
const variantByName = new Map(variantNames.map((name) => [name, variantInfo.getOrCreate(name)]));
|
||
const baseExtras = sourceMaterial.getExtras() ?? {};
|
||
const cropForDetail = cropped.get("u0-v0");
|
||
const sourceProof = {
|
||
sourceModel: BARREN_SOURCE.model,
|
||
sourceModelSha256: BARREN_SOURCE.sha256,
|
||
sourceMaterial: BARREN_SOURCE.material,
|
||
sourceTexture: BARREN_SOURCE.texture,
|
||
sourceImageName: BARREN_SOURCE.image,
|
||
sourcePreparedPngSha256: BARREN_SOURCE.imageSha256,
|
||
sourcePixelCrop: cropForDetail.sourcePixelCrop,
|
||
baseMeanLinear,
|
||
wallBaseContrast: 0.25,
|
||
wallDetailVariation: 1,
|
||
wallDetailImageName: BARREN_WALL_DETAIL_PATCH.textureName,
|
||
wallDetailSourcePixelCrop: wallDetailCrop,
|
||
wallDetailMeanLinear,
|
||
wallDetailWrap: "mirrored-repeat",
|
||
};
|
||
sourceMaterial.setExtras({
|
||
...baseExtras,
|
||
runewakerSurfaceDetail: {
|
||
version: 1,
|
||
floorVariant: variantNames[0],
|
||
wallVariant: variantNames[1],
|
||
periodMeters: 2,
|
||
floorStrength: 0,
|
||
wallStrength: 0.85,
|
||
baseMeanLinear,
|
||
wallBaseContrast: 0.25,
|
||
wallDetailVariation: 1,
|
||
floorMeanLinear: cropForDetail.meanLinear,
|
||
wallMeanLinear: wallDetailMeanLinear,
|
||
policyId: BARREN_POLICY_ID,
|
||
sourceProof,
|
||
},
|
||
});
|
||
const carrierExtras = { version: 1, policyId: BARREN_POLICY_ID, sourceImageName: BARREN_SOURCE.image, sourcePixelCrop: cropForDetail.sourcePixelCrop };
|
||
const carriers = new Map([
|
||
[variantNames[0], getOrCreateCarrier(document, cropForDetail.texture, variantNames[0], { ...carrierExtras, variantName: variantNames[0] })],
|
||
[variantNames[1], getOrCreateCarrier(document, wallDetailTexture, variantNames[1], {
|
||
...carrierExtras,
|
||
variantName: variantNames[1],
|
||
sourcePixelCrop: wallDetailCrop,
|
||
sourceImageName: BARREN_WALL_DETAIL_PATCH.textureName,
|
||
wrap: "mirrored-repeat",
|
||
})],
|
||
]);
|
||
carriers.get(variantNames[1]).getBaseColorTextureInfo().setWrapS(33648).setWrapT(33648);
|
||
|
||
const reports = [];
|
||
let adaptedTriangles = 0;
|
||
let untouchedTriangles = 0;
|
||
for (const node of root.listNodes()) {
|
||
const mesh = node.getMesh();
|
||
if (!mesh) continue;
|
||
const matrix = node.getWorldMatrix();
|
||
const originalPrimitives = [...mesh.listPrimitives()];
|
||
for (const [primitiveIndex, primitive] of originalPrimitives.entries()) {
|
||
if (primitive.getMaterial() !== sourceMaterial) continue;
|
||
if (primitive.getExtension(KHRMaterialsVariants.EXTENSION_NAME)) throw new Error("Barren material 12 already has variant mappings.");
|
||
const position = primitive.getAttribute("POSITION");
|
||
const normal = primitive.getAttribute("NORMAL");
|
||
const uv0 = primitive.getAttribute("TEXCOORD_0");
|
||
const indices = primitive.getIndices();
|
||
if (!position || !normal || !uv0 || !indices || uv0.getType() !== Accessor.Type.VEC2) {
|
||
throw new Error(`${node.getName()} material 12 primitive ${primitiveIndex} lacks position/normal/index/UV0 data.`);
|
||
}
|
||
const indexArray = indices.getArray();
|
||
const indicesByCrop = new Map(BARREN_PATCHES.map((patch) => [patch.key, []]));
|
||
const untouched = [];
|
||
for (let offset = 0; offset < indexArray.length; offset += 3) {
|
||
const triangle = [indexArray[offset], indexArray[offset + 1], indexArray[offset + 2]];
|
||
const points = trianglePositions(matrix, position, triangle);
|
||
const cross = cross3(sub3(points[1], points[0]), sub3(points[2], points[0]));
|
||
const magnitude = length3(cross);
|
||
const normalY = magnitude > 1e-12 ? Math.abs(cross[1]) / magnitude : 0;
|
||
const triangleUvs = triangle.map((index) => uv0.getElement(index, [0, 0]));
|
||
const key = barrenPatchForTriangle(triangleUvs, normalY);
|
||
(key ? indicesByCrop.get(key) : untouched).push(...triangle);
|
||
}
|
||
const activeCrops = [...indicesByCrop].filter(([, group]) => group.length > 0);
|
||
if (!activeCrops.length) continue;
|
||
const originalTriangleCount = indexArray.length / 3;
|
||
const before = accessorHash(indices);
|
||
const primitiveOutputs = [];
|
||
if (untouched.length) {
|
||
primitive.setIndices(document.createAccessor(`${node.getName()}:${BARREN_POLICY_ID}:untouched-indices`)
|
||
.setType(Accessor.Type.SCALAR).setArray(new indexArray.constructor(untouched)).setBuffer(buffer));
|
||
primitive.setExtension(KHRMaterialsVariants.EXTENSION_NAME, (() => {
|
||
const current = document.getRoot().listExtensionsUsed().find((item) => item.extensionName === KHRMaterialsVariants.EXTENSION_NAME);
|
||
const mappingList = current.createMappingList();
|
||
for (const name of variantNames) mappingList.addMapping(current.createMapping().setMaterial(carriers.get(name)).addVariant(variantByName.get(name)));
|
||
return mappingList;
|
||
})());
|
||
primitiveOutputs.push({ primitive, triangles: untouched.length / 3, category: "untouched-atlas" });
|
||
} else mesh.removePrimitive(primitive);
|
||
for (const [key, group] of activeCrops) {
|
||
const { patch, texture, sourcePixelCrop } = cropped.get(key);
|
||
const adaptedMaterial = sourceMaterial.clone()
|
||
.setName(`${BARREN_SOURCE.material}_${key}_crop_repeat4`)
|
||
.setBaseColorTexture(texture);
|
||
adaptedMaterial.getBaseColorTextureInfo().setTexCoord(1).setWrapS(10497).setWrapT(10497);
|
||
adaptedMaterial.setExtras({
|
||
...baseExtras,
|
||
romUvAdaptation: {
|
||
version: 1, id: BARREN_POLICY_ID, atlasRect: patch.rect, uvRect: patch.uvRect,
|
||
repeatFactor: 4, nativeBaseTexture: BARREN_SOURCE.image, nativeBaseTexturePngSha256: BARREN_SOURCE.imageSha256,
|
||
sourcePixelCrop,
|
||
},
|
||
});
|
||
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] - patch.uvRect.u0) / (patch.uvRect.u1 - patch.uvRect.u0)) * 4;
|
||
values[index * 2 + 1] = ((uv[1] - patch.uvRect.v0) / (patch.uvRect.v1 - patch.uvRect.v0)) * 4;
|
||
}
|
||
const uv1 = document.createAccessor(`${node.getName()}:${BARREN_POLICY_ID}:${key}:TEXCOORD_1`)
|
||
.setType(Accessor.Type.VEC2).setArray(values).setBuffer(buffer);
|
||
const adapted = primitive.clone()
|
||
.setIndices(document.createAccessor(`${node.getName()}:${BARREN_POLICY_ID}:${key}:indices`)
|
||
.setType(Accessor.Type.SCALAR).setArray(new indexArray.constructor(group)).setBuffer(buffer))
|
||
.setAttribute("TEXCOORD_1", uv1)
|
||
.setMaterial(adaptedMaterial)
|
||
.setExtras({
|
||
...(primitive.getExtras() ?? {}),
|
||
romUvAdaptation: {
|
||
version: 1, id: BARREN_POLICY_ID, atlasRect: patch.rect, uvRect: patch.uvRect, repeatFactor: 4,
|
||
sourceMaterial: BARREN_SOURCE.material, sourceTexture: BARREN_SOURCE.image,
|
||
sourcePngSha256: BARREN_SOURCE.imageSha256, sourcePixelCrop,
|
||
},
|
||
});
|
||
const variants = document.getRoot().listExtensionsUsed().find((item) => item.extensionName === KHRMaterialsVariants.EXTENSION_NAME);
|
||
const mappings = variants.createMappingList();
|
||
for (const name of variantNames) mappings.addMapping(variants.createMapping().setMaterial(carriers.get(name)).addVariant(variantByName.get(name)));
|
||
adapted.setExtension(KHRMaterialsVariants.EXTENSION_NAME, mappings);
|
||
mesh.addPrimitive(adapted);
|
||
primitiveOutputs.push({ primitive: adapted, triangles: group.length / 3, category: key });
|
||
adaptedTriangles += group.length / 3;
|
||
}
|
||
untouchedTriangles += untouched.length / 3;
|
||
const outputTriangles = primitiveOutputs.reduce((sum, item) => sum + item.triangles, 0);
|
||
if (outputTriangles !== originalTriangleCount) throw new Error(`Barren triangle partition failed on ${node.getName()} primitive ${primitiveIndex}.`);
|
||
for (const { primitive: outputPrimitive } of primitiveOutputs) {
|
||
if (outputPrimitive.getAttribute("POSITION") !== position || outputPrimitive.getAttribute("NORMAL") !== normal
|
||
|| outputPrimitive.getAttribute("TEXCOORD_0") !== uv0) {
|
||
throw new Error(`Barren UV policy changed source position/normal/UV0 accessors on ${node.getName()}.`);
|
||
}
|
||
}
|
||
reports.push({
|
||
node: node.getName(), sourcePrimitiveIndex: primitiveIndex, sourceTriangles: originalTriangleCount,
|
||
sourceIndexHash: before, sourcePositionHash: accessorHash(position), sourceNormalHash: accessorHash(normal),
|
||
sourceUv0Hash: accessorHash(uv0),
|
||
outputGroups: primitiveOutputs.map(({ category, triangles }) => ({ category, triangles })),
|
||
triangleCoverageExact: true, positionNormalUv0AccessorsShared: true, indexStreamLayoutChanged: true,
|
||
});
|
||
}
|
||
}
|
||
if (!reports.length) throw new Error("Barren atlas-safe policy found no floor triangles in the four reviewed source-image quadrants.");
|
||
const patchReports = [...cropped.values()].map(({ patch, png, sourcePixelCrop, meanLinear }) => ({
|
||
key: patch.key, sourcePixelRect: patch.rect, sourceUvRect: patch.uvRect, repeatFactor: 4,
|
||
croppedPngSha256: sourcePixelCrop.croppedPngSha256, croppedRgbaSha256: sourcePixelCrop.croppedRgbaSha256,
|
||
bytes: png.length, meanLinear,
|
||
}));
|
||
patchReports.push({
|
||
key: BARREN_WALL_DETAIL_PATCH.key,
|
||
role: "wall-detail-carrier",
|
||
sourcePixelRect: BARREN_WALL_DETAIL_PATCH.rect,
|
||
repeatMode: "mirrored-repeat",
|
||
croppedPngSha256: wallDetailCrop.croppedPngSha256,
|
||
croppedRgbaSha256: wallDetailCrop.croppedRgbaSha256,
|
||
bytes: wallDetailPng.length,
|
||
meanLinear: wallDetailMeanLinear,
|
||
});
|
||
return {
|
||
id: BARREN_POLICY_ID,
|
||
status: "applied",
|
||
sourceModel: BARREN_SOURCE.model,
|
||
sourceModelSha256: BARREN_SOURCE.sha256,
|
||
sourceMaterial: BARREN_SOURCE.material,
|
||
sourceTexture: BARREN_SOURCE.texture,
|
||
sourceImage: BARREN_SOURCE.image,
|
||
sourceImageSha256: BARREN_SOURCE.imageSha256,
|
||
sourceImageDimensions: [imageMeta.width, imageMeta.height],
|
||
repeatFactor: 4,
|
||
worldNormalYThreshold: 0.7,
|
||
atlasSeamToleranceUv: 0.0025,
|
||
trianglePolicy: "floor triangles whose UV0 corners remain inside a reviewed 256x256 floor island receive crop-localized repeat UV1; other faces keep the full native atlas",
|
||
trianglePartitionChanged: true,
|
||
legacyRepeatPrimitivesRestored,
|
||
sourcePositionNormalUv0Preserved: true,
|
||
untouchedTriangles,
|
||
adaptedTriangles,
|
||
primitiveReports: reports,
|
||
crops: patchReports,
|
||
wallDetail: {
|
||
textureName: BARREN_WALL_DETAIL_PATCH.textureName,
|
||
sourcePixelCrop: wallDetailCrop,
|
||
meanLinear: wallDetailMeanLinear,
|
||
samplerWrap: "mirrored-repeat",
|
||
},
|
||
wallBaseContrast: 0.25,
|
||
wallDetailVariation: 1,
|
||
baseMeanLinear,
|
||
variants: variantNames,
|
||
};
|
||
}
|
||
|
||
async function applyHotoDetailPolicy(document, sourceReport) {
|
||
verifySourceReport(sourceReport, HOTO_SOURCE);
|
||
for (const target of Object.values(HOTO_TARGETS)) verifyNativeMaterial(sourceReport, target.nativeMaterial, target.texture);
|
||
const root = document.getRoot();
|
||
const sourceTextures = new Map();
|
||
const targetMaterials = new Map();
|
||
for (const [materialName, target] of Object.entries(HOTO_TARGETS)) {
|
||
const material = root.listMaterials().find((item) => item.getName() === materialName);
|
||
if (!material) throw new Error(`HOTO surface policy cannot find canonical material ${materialName}.`);
|
||
const texture = material.getBaseColorTexture();
|
||
const image = Buffer.from(texture?.getImage() ?? []);
|
||
if (texture?.getName() !== target.image || sha256(image) !== target.imageSha256) {
|
||
throw new Error(`HOTO ${materialName} base atlas/tile is not the pinned source PNG.`);
|
||
}
|
||
if (material.getNormalTexture() || material.getMetallicRoughnessTexture() || material.getEmissiveTexture()) {
|
||
throw new Error(`HOTO surface policy refuses unexpected auxiliary native texture slots on ${materialName}.`);
|
||
}
|
||
targetMaterials.set(materialName, material);
|
||
sourceTextures.set(materialName, texture);
|
||
}
|
||
const detailSources = new Map();
|
||
for (const [role, detail] of Object.entries(HOTO_DETAIL)) {
|
||
const material = targetMaterials.get(detail.canonicalMaterial);
|
||
const texture = sourceTextures.get(detail.canonicalMaterial);
|
||
if (texture?.getName() !== detail.image || sha256(texture.getImage() ?? Buffer.alloc(0)) !== detail.imageSha256) {
|
||
throw new Error(`HOTO ${role} detail tile is not the native, byte-identical image.`);
|
||
}
|
||
detailSources.set(role, { ...detail, sourceTexturePath: detail.texture, textureObject: texture, meanLinear: await meanLinearFromPng(Buffer.from(texture.getImage())) });
|
||
}
|
||
const expectedByMaterial = new Map();
|
||
const detailProofs = {};
|
||
for (const [role, detail] of detailSources) {
|
||
detailProofs[role] = {
|
||
nativeMaterial: detail.nativeMaterial,
|
||
canonicalMaterialName: detail.canonicalMaterial,
|
||
sourceTexture: detail.sourceTexturePath,
|
||
sourceImage: detail.image,
|
||
sourcePreparedPngSha256: detail.imageSha256,
|
||
meanLinear: detail.meanLinear,
|
||
};
|
||
}
|
||
for (const [materialName, target] of Object.entries(HOTO_TARGETS)) {
|
||
const material = targetMaterials.get(materialName);
|
||
const baseTexture = sourceTextures.get(materialName);
|
||
const baseMeanLinear = target.useOwnBaseMean ? await meanLinearFromPng(Buffer.from(baseTexture.getImage())) : undefined;
|
||
const floorRole = target.floorVariantRole ?? "floor";
|
||
const sourceProof = {
|
||
sourceModel: HOTO_SOURCE.model,
|
||
sourceModelSha256: HOTO_SOURCE.sha256,
|
||
sourceMaterial: target.nativeMaterial,
|
||
canonicalMaterialName: materialName,
|
||
sourceTexture: target.texture,
|
||
sourceTextureName: target.image,
|
||
sourcePreparedPngSha256: target.imageSha256,
|
||
floorDetailSource: detailProofs[floorRole],
|
||
wallDetailSource: detailProofs.wall,
|
||
floorVariantRole: floorRole,
|
||
floorBaseStrength: target.floorBaseStrength,
|
||
floorStrength: target.floorStrength,
|
||
wallStrength: target.wallStrength,
|
||
wallBaseContrast: target.wallBaseContrast,
|
||
wallDetailVariation: target.wallDetailVariation,
|
||
};
|
||
const metadata = {
|
||
version: 1,
|
||
floorVariant: HOTO_VARIANTS[floorRole],
|
||
wallVariant: HOTO_VARIANTS.wall,
|
||
periodMeters: 2,
|
||
floorBaseStrength: target.floorBaseStrength,
|
||
floorStrength: target.floorStrength,
|
||
wallStrength: target.wallStrength,
|
||
floorMeanLinear: detailSources.get(floorRole).meanLinear,
|
||
wallMeanLinear: detailSources.get("wall").meanLinear,
|
||
wallBaseContrast: target.wallBaseContrast,
|
||
wallDetailVariation: target.wallDetailVariation,
|
||
policyId: HOTO_POLICY_ID,
|
||
sourceProof,
|
||
};
|
||
if (baseMeanLinear) metadata.baseMeanLinear = baseMeanLinear;
|
||
expectedByMaterial.set(materialName, metadata);
|
||
}
|
||
|
||
const carrierNames = new Map(Object.entries(HOTO_VARIANTS).map(([role, variantName]) => [variantName, role]));
|
||
const existingMaterialPolicies = [...targetMaterials.values()].map((material) => material.getExtras()?.runewakerSurfaceDetail?.policyId ?? null);
|
||
const hasExistingPolicy = existingMaterialPolicies.some(Boolean);
|
||
if (hasExistingPolicy) {
|
||
if (existingMaterialPolicies.some((id) => id !== HOTO_POLICY_ID && id !== HOTO_LEGACY_POLICY_ID)) {
|
||
throw new Error("HOTO surface metadata contains an unknown or partial policy marker.");
|
||
}
|
||
const existingPolicy = existingMaterialPolicies[0];
|
||
if (!existingPolicy || existingMaterialPolicies.some((id) => id !== existingPolicy)) {
|
||
throw new Error("HOTO source-rock detail policy is only partially applied.");
|
||
}
|
||
const extension = root.listExtensionsUsed().find((item) => item.extensionName === KHRMaterialsVariants.EXTENSION_NAME);
|
||
const variantByName = new Map((extension?.listVariants() ?? []).map((variant) => [variant.getName(), variant]));
|
||
if (!extension || [...carrierNames.keys()].some((name) => !variantByName.has(name))) {
|
||
throw new Error("HOTO source-rock detail policy marker exists but its material variants are absent.");
|
||
}
|
||
const carrierByName = new Map();
|
||
for (const [variantName, role] of carrierNames) {
|
||
const detail = detailSources.get(role);
|
||
const carrier = root.listMaterials().find((material) => material.getName() === `${variantName}:source-tile-carrier`);
|
||
const carrierProof = carrier?.getExtras()?.runewakerSurfaceDetailCarrier;
|
||
if (!carrier || carrier.getBaseColorTexture() !== detail.textureObject
|
||
|| carrierProof?.policyId !== existingPolicy
|
||
|| carrierProof?.sourceResourceSha256 !== HOTO_SOURCE.sha256
|
||
|| carrierProof?.sourceTextureName !== detail.image
|
||
|| carrierProof?.sourcePreparedPngSha256 !== detail.imageSha256) {
|
||
throw new Error(`HOTO ${variantName} carrier does not match the pinned source tile/policy.`);
|
||
}
|
||
carrierByName.set(variantName, carrier);
|
||
}
|
||
let mappedPrimitiveCount = 0;
|
||
for (const node of root.listNodes()) for (const primitive of node.getMesh()?.listPrimitives() ?? []) {
|
||
const materialName = primitive.getMaterial()?.getName();
|
||
if (!targetMaterials.has(materialName)) continue;
|
||
const mappingList = primitive.getExtension(KHRMaterialsVariants.EXTENSION_NAME)?.listMappings() ?? [];
|
||
const mappedNames = new Set();
|
||
for (const mapping of mappingList) {
|
||
if (mapping.listVariants().length !== 1) throw new Error(`HOTO ${materialName} has a bundled or ambiguous surface-detail mapping.`);
|
||
const variantName = mapping.listVariants()[0].getName();
|
||
if (!carrierByName.has(variantName) || mapping.getMaterial() !== carrierByName.get(variantName) || mappedNames.has(variantName)) {
|
||
throw new Error(`HOTO ${materialName} surface-detail mapping does not point to its verified source carrier.`);
|
||
}
|
||
mappedNames.add(variantName);
|
||
}
|
||
if (mappedNames.size !== 2 || [...carrierNames.keys()].some((name) => !mappedNames.has(name))) {
|
||
throw new Error(`HOTO ${materialName} is missing one of the pinned source-detail mappings.`);
|
||
}
|
||
mappedPrimitiveCount += 1;
|
||
}
|
||
if (mappedPrimitiveCount !== 36) throw new Error(`HOTO source-rock detail mappings are incomplete: expected 36, found ${mappedPrimitiveCount}.`);
|
||
|
||
if (existingPolicy === HOTO_LEGACY_POLICY_ID) {
|
||
for (const [materialName, target] of Object.entries(HOTO_TARGETS)) {
|
||
const old = targetMaterials.get(materialName).getExtras()?.runewakerSurfaceDetail;
|
||
const proof = old?.sourceProof;
|
||
if (old?.version !== 1 || old.floorVariant !== HOTO_VARIANTS.floor || old.wallVariant !== HOTO_VARIANTS.wall
|
||
|| old.periodMeters !== 2 || old.floorStrength !== 0.65 || old.floorBaseStrength !== undefined && old.floorBaseStrength !== 0
|
||
|| old.wallStrength !== (target.legacyWallStrength ?? target.wallStrength)
|
||
|| old.wallBaseContrast !== undefined && old.wallBaseContrast !== 1
|
||
|| old.wallDetailVariation !== undefined && old.wallDetailVariation !== 0
|
||
|| JSON.stringify(old.floorMeanLinear) !== JSON.stringify(detailSources.get("floor").meanLinear)
|
||
|| JSON.stringify(old.wallMeanLinear) !== JSON.stringify(detailSources.get("wall").meanLinear)
|
||
|| proof?.sourceModelSha256 !== HOTO_SOURCE.sha256 || proof?.canonicalMaterialName !== materialName
|
||
|| proof?.sourceMaterial !== target.nativeMaterial || proof?.sourceTexture !== target.texture
|
||
|| proof?.sourceTextureName !== target.image || proof?.sourcePreparedPngSha256 !== target.imageSha256) {
|
||
throw new Error(`HOTO legacy v1 metadata on ${materialName} is not the reviewed migration source.`);
|
||
}
|
||
}
|
||
for (const carrier of carrierByName.values()) {
|
||
const extras = carrier.getExtras() ?? {};
|
||
carrier.setExtras({ ...extras, runewakerSurfaceDetailCarrier: { ...extras.runewakerSurfaceDetailCarrier, policyId: HOTO_POLICY_ID } });
|
||
}
|
||
for (const [materialName, metadata] of expectedByMaterial) {
|
||
const material = targetMaterials.get(materialName);
|
||
material.setExtras({ ...(material.getExtras() ?? {}), runewakerSurfaceDetail: metadata });
|
||
}
|
||
return { id: HOTO_POLICY_ID, status: "migrated-v1", migratedFrom: HOTO_LEGACY_POLICY_ID, mappedPrimitiveCount, materials: Object.keys(HOTO_TARGETS) };
|
||
}
|
||
|
||
for (const [variantName, role] of carrierNames) {
|
||
const detail = detailSources.get(role);
|
||
const proof = carrierByName.get(variantName).getExtras()?.runewakerSurfaceDetailCarrier;
|
||
if (proof?.variantName !== variantName || proof?.sourceMaterial !== detail.nativeMaterial
|
||
|| proof?.canonicalMaterialName !== detail.canonicalMaterial || proof?.sourceTexturePath !== detail.texture) {
|
||
throw new Error(`HOTO ${variantName} carrier provenance does not match the pinned source.`);
|
||
}
|
||
}
|
||
for (const [materialName, metadata] of expectedByMaterial) {
|
||
const existing = targetMaterials.get(materialName).getExtras()?.runewakerSurfaceDetail;
|
||
if (JSON.stringify(existing) !== JSON.stringify(metadata)) throw new Error(`HOTO v2 surface metadata on ${materialName} differs from the reviewed policy.`);
|
||
}
|
||
return { id: HOTO_POLICY_ID, status: "already-applied", mappedPrimitiveCount, materials: Object.keys(HOTO_TARGETS) };
|
||
}
|
||
|
||
if (root.listMaterials().some((material) => material.getExtras()?.runewakerSurfaceDetailCarrier?.policyId === HOTO_LEGACY_POLICY_ID
|
||
|| material.getExtras()?.runewakerSurfaceDetailCarrier?.policyId === HOTO_POLICY_ID)) {
|
||
throw new Error("HOTO source-tile carrier markers exist without the complete source-detail policy.");
|
||
}
|
||
const { getOrCreate } = listVariants(document);
|
||
const variantByName = new Map(Object.values(HOTO_VARIANTS).map((name) => [name, getOrCreate(name)]));
|
||
const carrierByName = new Map();
|
||
for (const [role, detail] of detailSources) {
|
||
const variantName = HOTO_VARIANTS[role];
|
||
const extras = {
|
||
version: 1, variantName, policyId: HOTO_POLICY_ID, sourceResourceSha256: HOTO_SOURCE.sha256,
|
||
sourceMaterial: detail.nativeMaterial, canonicalMaterialName: detail.canonicalMaterial,
|
||
sourceTexturePath: detail.sourceTexturePath, sourceTextureName: detail.image, sourcePreparedPngSha256: detail.imageSha256,
|
||
};
|
||
carrierByName.set(variantName, getOrCreateCarrier(document, detail.textureObject, variantName, extras));
|
||
}
|
||
let mappedPrimitiveCount = 0;
|
||
for (const node of root.listNodes()) {
|
||
const mesh = node.getMesh();
|
||
if (!mesh) continue;
|
||
for (const primitive of mesh.listPrimitives()) {
|
||
const materialName = primitive.getMaterial()?.getName();
|
||
if (targetMaterials.get(materialName) !== primitive.getMaterial()) continue;
|
||
addSurfaceMappings(document, primitive, carrierByName, variantByName);
|
||
mappedPrimitiveCount += 1;
|
||
}
|
||
}
|
||
if (mappedPrimitiveCount !== 36) throw new Error(`HOTO source-rock detail target count changed: expected 36, found ${mappedPrimitiveCount}.`);
|
||
for (const [materialName, metadata] of expectedByMaterial) {
|
||
const material = targetMaterials.get(materialName);
|
||
material.setExtras({ ...(material.getExtras() ?? {}), runewakerSurfaceDetail: metadata });
|
||
}
|
||
return {
|
||
id: HOTO_POLICY_ID,
|
||
status: "applied",
|
||
sourceModel: HOTO_SOURCE.model,
|
||
sourceModelSha256: HOTO_SOURCE.sha256,
|
||
detail: { periodMeters: 2, sourceSpace: "browser-adapted world-space detail; native base UV0 retained" },
|
||
detailSources: detailProofs,
|
||
materials: Object.keys(HOTO_TARGETS).map((name) => ({ name, metadata: expectedByMaterial.get(name) })),
|
||
mappedPrimitiveCount,
|
||
sourceGeometryChanged: false,
|
||
nativeBaseTexturesAndMaterialSemanticsPreserved: true,
|
||
migration: "v1 metadata migrates only when every target material, tile carrier and variant mapping matches the reviewed legacy configuration",
|
||
};
|
||
}
|
||
|
||
/** Source-pinned atlas-safe crops and native source-rock detail carriers. */
|
||
export async function applyRunewakerSurfaceQualityPolicies(document, { dungeonId, policyIds = [], sourceReport } = {}) {
|
||
if (new Set(policyIds).size !== policyIds.length) throw new Error("RoM surface-quality policy IDs must be unique.");
|
||
const results = [];
|
||
for (const policyId of policyIds) {
|
||
if (dungeonId === "barren-caves" && policyId === BARREN_POLICY_ID) results.push(await applyBarrenFloorPolicy(document, sourceReport));
|
||
else if (dungeonId === "hoto" && policyId === HOTO_POLICY_ID) results.push(await applyHotoDetailPolicy(document, sourceReport));
|
||
else throw new Error(`Unknown or mismatched RoM surface-quality policy ${policyId} for ${dungeonId}.`);
|
||
}
|
||
return { schemaVersion: 1, dungeonId, status: policyIds.length ? "applied" : "no-policy", policies: results };
|
||
}
|
||
|
||
export const runewakerSurfaceQualityPolicyIds = Object.freeze({ barrenCaves: BARREN_POLICY_ID, hoto: HOTO_POLICY_ID });
|