Files
healer-man/scripts/runewaker-pipeline/diagnose-source-anchor-nav-rebake.mjs
2026-10-06 22:25:34 -04:00

199 lines
9.7 KiB
JavaScript

import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { Matrix4, Vector3 } from "three";
import { sourceAnchorSupportGeometry } from "./lib/source-anchor-support.mjs";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const workRoot = path.join(projectRoot, "runewaker-export-work");
const slug = process.argv[2] ?? "aeternal-circle";
const stageDungeon = path.join(workRoot, "source-anchor-support-correction-staged-v2", slug);
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
const relative = (file) => path.relative(projectRoot, file).replaceAll("\\", "/");
function includePoint(bounds, point) {
for (let axis = 0; axis < 3; axis += 1) {
bounds.minimum[axis] = Math.min(bounds.minimum[axis], point[axis]);
bounds.maximum[axis] = Math.max(bounds.maximum[axis], point[axis]);
}
}
function newBounds() {
return { minimum: [Infinity, Infinity, Infinity], maximum: [-Infinity, -Infinity, -Infinity] };
}
function summarizeCollision(document) {
const global = newBounds();
const nativeBounds = newBounds();
let totalTriangles = 0;
let totalVertices = 0;
let degenerateTriangles = 0;
let invalidIndices = 0;
let nonFiniteCoordinates = 0;
const meshes = [];
const padCenters = [];
const edgeAb = new Vector3();
const edgeAc = new Vector3();
const normal = new Vector3();
for (const node of document.getRoot().listNodes()) {
if (!node.getMesh()) continue;
const support = node.getExtras()?.sourceChunk === "runewaker_source_anchor_support";
const nodeMatrix = new Matrix4().fromArray(Array.from(node.getWorldMatrix()));
let nodeTriangles = 0;
let nodeVertices = 0;
let nodeDegenerates = 0;
const nodeBounds = newBounds();
for (const primitive of node.getMesh().listPrimitives()) {
const position = primitive.getAttribute("POSITION");
const indices = primitive.getIndices();
if (!position) continue;
const positions = position.getArray();
const elements = indices?.getArray() ?? Array.from({ length: position.getCount() }, (_, index) => index);
nodeVertices += position.getCount();
totalVertices += position.getCount();
nodeTriangles += elements.length / 3;
totalTriangles += elements.length / 3;
for (let vertex = 0; vertex < position.getCount(); vertex += 1) {
const point = new Vector3().fromArray(positions, vertex * 3).applyMatrix4(nodeMatrix).toArray();
if (!point.every(Number.isFinite)) {
nonFiniteCoordinates += 1;
continue;
}
includePoint(global, point);
includePoint(nodeBounds, point);
if (!support) includePoint(nativeBounds, point);
}
for (let element = 0; element < elements.length; element += 3) {
const triangle = [elements[element], elements[element + 1], elements[element + 2]];
if (triangle.some((index) => !Number.isInteger(index) || index < 0 || index >= position.getCount())) {
invalidIndices += 1;
continue;
}
const a = new Vector3().fromArray(positions, triangle[0] * 3).applyMatrix4(nodeMatrix);
const b = new Vector3().fromArray(positions, triangle[1] * 3).applyMatrix4(nodeMatrix);
const c = new Vector3().fromArray(positions, triangle[2] * 3).applyMatrix4(nodeMatrix);
if (normal.crossVectors(edgeAb.subVectors(b, a), edgeAc.subVectors(c, a)).lengthSq() <= 1e-16) {
degenerateTriangles += 1;
nodeDegenerates += 1;
}
}
}
if (support) {
const primitive = node.getMesh().listPrimitives()[0];
const position = primitive.getAttribute("POSITION");
const values = position.getArray();
for (let offset = 0, pointIndex = 0; offset < position.getCount(); offset += 8, pointIndex += 1) {
const top = [];
for (let vertex = 0; vertex < 4; vertex += 1) {
top.push(new Vector3().fromArray(values, (offset + vertex) * 3).applyMatrix4(nodeMatrix).toArray());
}
const center = [0, 1, 2].map((axis) => top.reduce((sum, point) => sum + point[axis], 0) / top.length);
padCenters.push({ pointIndex, center });
}
}
meshes.push({
node: node.getName(),
sourceChunk: node.getExtras()?.sourceChunk ?? null,
generatedSupport: support,
vertices: nodeVertices,
triangles: nodeTriangles,
degenerateTriangles: nodeDegenerates,
bounds: nodeBounds,
spans: nodeBounds.minimum.map((value, axis) => nodeBounds.maximum[axis] - value),
});
}
return {
totals: { vertices: totalVertices, triangles: totalTriangles, degenerateTriangles, invalidIndices, nonFiniteCoordinates },
bounds: global,
nativeOnlyBounds: nativeBounds,
supportPadCount: padCenters.length,
supportCenters: padCenters,
largestMeshes: meshes.slice().sort((left, right) => right.triangles - left.triangles).slice(0, 12),
widestMeshes: meshes.slice().sort((left, right) => Math.max(...right.spans) - Math.max(...left.spans)).slice(0, 12),
meshes,
};
}
const recipe = JSON.parse(await readFile(path.join(projectRoot, "scripts/runewaker-pipeline/recipes", `${slug}.json`), "utf8"));
const stageReport = JSON.parse(await readFile(path.join(stageDungeon, "staging-report.json"), "utf8"));
const originalFile = path.join(workRoot, slug, "converted", stageReport.staged.collision[0].source);
const correctedFile = path.join(stageDungeon, stageReport.staged.collision[0].staged);
const [original, corrected] = await Promise.all([io.read(originalFile), io.read(correctedFile)]);
const originalSummary = summarizeCollision(original);
const correctedSummary = summarizeCollision(corrected);
const oldReport = JSON.parse(await readFile(path.join(workRoot, slug, "converted/recast-report.json"), "utf8"));
const sourceBounds = oldReport.input.bounds;
const sourceGrid = oldReport.build;
const cellSize = Number(recipe.navigation.cellSize);
const correctedWidth = Math.ceil((correctedSummary.bounds.maximum[0] - correctedSummary.bounds.minimum[0]) / cellSize);
const correctedHeight = Math.ceil((correctedSummary.bounds.maximum[2] - correctedSummary.bounds.minimum[2]) / cellSize);
const pointGeometry = sourceAnchorSupportGeometry(recipe.source.sourceAnchorSupport.points, stageReport.source.primaryPlacement, {
halfExtent: Number(stageReport.source.halfExtentSourceUnits),
thickness: Number(stageReport.source.thicknessSourceUnits),
metersPerSourceUnit: Number(recipe.coordinateSystem.metersPerSourceUnit),
omitPointIndices: stageReport.source.omittedNativeGroundedPointIndices,
});
const expectedPads = newBounds();
for (let offset = 0; offset < pointGeometry.positions.length; offset += 3) {
includePoint(expectedPads, pointGeometry.positions.slice(offset, offset + 3));
}
const report = {
schemaVersion: 1,
slug,
source: {
collisionBeforeCorrection: relative(originalFile),
collisionBeforeCorrectionSha256: stageReport.source.originalCollisionFiles[0].sha256,
correctedCollision: relative(correctedFile),
correctedCollisionSha256: sha256(await readFile(correctedFile)),
oldRecastInputBounds: sourceBounds,
oldRecastGrid: sourceGrid,
collisionTrianglesInOldRecastReport: oldReport.input.triangleCount,
recipeCellSize: cellSize,
expectedRetainedSupportBounds: expectedPads,
expectedRetainedSupportTriangles: pointGeometry.indices.length / 3,
expectedSupportNavigationTriangles: pointGeometry.navigationPositions.length / 9,
},
original: originalSummary,
corrected: correctedSummary,
diff: {
triangleDelta: correctedSummary.totals.triangles - originalSummary.totals.triangles,
nativeBoundsEqual: JSON.stringify(originalSummary.nativeOnlyBounds) === JSON.stringify(correctedSummary.nativeOnlyBounds),
correctedBounds: correctedSummary.bounds,
nativeOnlyBounds: correctedSummary.nativeOnlyBounds,
correctedGridWidthCells: correctedWidth,
correctedGridDepthCells: correctedHeight,
correctedGridCellCount: correctedWidth * correctedHeight,
tilesAtRecipe64: Math.ceil(correctedWidth / 64) * Math.ceil(correctedHeight / 64),
tilesAtDerived512: Math.ceil(correctedWidth / 512) * Math.ceil(correctedHeight / 512),
tilesAtDerived1024: Math.ceil(correctedWidth / 1024) * Math.ceil(correctedHeight / 1024),
allCollisionCoordinatesFinite: correctedSummary.totals.nonFiniteCoordinates === 0,
allIndicesValid: correctedSummary.totals.invalidIndices === 0,
},
conclusion: {
explanation: "Compare global/native-only bounds and mesh scales with old Recast report; unchanged native bounds mean support correction did not create a new world-space envelope. The all-dungeon rebake cost is therefore in rebuilding the broad provisional primary-ROS collision mesh, which copies decorative renderables, rather than the 1,812 support triangles.",
sourceAnchorPoint0Omitted: stageReport.source.omittedNativeGroundedPointIndices.includes(0),
},
};
const output = path.join(stageDungeon, "nav-rebake-diagnostic.json");
await writeFile(output, `${JSON.stringify(report, null, 2)}\n`, "utf8");
console.log(JSON.stringify({
report: relative(output),
original: originalSummary.totals,
corrected: correctedSummary.totals,
nativeBoundsEqual: report.diff.nativeBoundsEqual,
fullBounds: correctedSummary.bounds,
nativeOnlyBounds: correctedSummary.nativeOnlyBounds,
gridCells: report.diff.correctedGridCellCount,
tilesAtRecipe64: report.diff.tilesAtRecipe64,
tilesAtDerived512: report.diff.tilesAtDerived512,
largestMeshes: correctedSummary.largestMeshes,
widestMeshes: correctedSummary.widestMeshes,
}, null, 2));