Files
healer-man/scripts/runewaker-pipeline/diagnose-native-core-support-overlap.mjs
T
2026-10-06 22:25:34 -04:00

191 lines
9.4 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 supportName = "runewaker_source_anchor_support";
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 include(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 emptyBounds() {
return { minimum: [Infinity, Infinity, Infinity], maximum: [-Infinity, -Infinity, -Infinity] };
}
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 conversion = JSON.parse(await readFile(path.join(workRoot, slug, "converted/conversion-report.json"), "utf8"));
const sourceReportPath = path.join(stageDungeon, "native-primary-collision-report.json");
const sourceReport = JSON.parse(await readFile(sourceReportPath, "utf8"));
const meters = Number(recipe.coordinateSystem.metersPerSourceUnit);
const sourceBounds = sourceReport.nativeCollisionBounds;
const expectedNativeBounds = {
minimum: [
Number(sourceBounds.min[0]) * meters,
Number(sourceBounds.min[1]) * meters,
-Number(sourceBounds.max[2]) * meters,
],
maximum: [
Number(sourceBounds.max[0]) * meters,
Number(sourceBounds.max[1]) * meters,
-Number(sourceBounds.min[2]) * meters,
],
};
const convertedNativeBounds = emptyBounds();
let convertedNativeTriangles = 0;
for (const record of conversion.collision ?? []) {
const stagedRecord = stageReport.staged.collision.find((item) => item.source === record.file);
const file = path.join(stageDungeon, stagedRecord?.staged ?? record.file);
const document = await io.read(file);
for (const node of document.getRoot().listNodes()) {
if (!node.getMesh() || node.getExtras()?.sourceChunk === supportName) continue;
const matrix = new Matrix4().fromArray(Array.from(node.getWorldMatrix()));
for (const primitive of node.getMesh().listPrimitives()) {
const position = primitive.getAttribute("POSITION");
const indices = primitive.getIndices();
convertedNativeTriangles += (indices?.getCount() ?? position?.getCount() ?? 0) / 3;
if (!position) continue;
const values = position.getArray();
for (let offset = 0; offset < values.length; offset += 3) {
include(convertedNativeBounds, new Vector3().fromArray(values, offset).applyMatrix4(matrix).toArray());
}
}
}
}
const placement = stageReport.source.primaryPlacement;
const sourcePoints = recipe.source.sourceAnchorSupport.points.map((point) => point.map(Number));
const omitted = new Set(stageReport.source.omittedNativeGroundedPointIndices);
const nativeXZ = {
minX: convertedNativeBounds.minimum[0], maxX: convertedNativeBounds.maximum[0],
minZ: convertedNativeBounds.minimum[2], maxZ: convertedNativeBounds.maximum[2],
};
const padRows = [];
const supportBounds = emptyBounds();
const coreSupportBounds = emptyBounds();
for (const [sourcePointIndex, sourcePoint] of sourcePoints.entries()) {
if (omitted.has(sourcePointIndex)) continue;
const geometry = sourceAnchorSupportGeometry([sourcePoint], placement, {
halfExtent: Number(stageReport.source.halfExtentSourceUnits),
thickness: Number(stageReport.source.thicknessSourceUnits),
metersPerSourceUnit: meters,
});
const top = [];
for (let vertex = 0; vertex < 4; vertex += 1) {
top.push(geometry.positions.slice(vertex * 3, vertex * 3 + 3));
}
const box = emptyBounds();
for (const point of geometry.positions.reduce((all, value, index) => {
if (index % 3 === 0) all.push(geometry.positions.slice(index, index + 3));
return all;
}, [])) {
include(box, point);
include(supportBounds, point);
}
const overlapsNativeXZ = box.maximum[0] >= nativeXZ.minX && box.minimum[0] <= nativeXZ.maxX
&& box.maximum[2] >= nativeXZ.minZ && box.minimum[2] <= nativeXZ.maxZ;
const yGapM = box.minimum[1] > convertedNativeBounds.maximum[1]
? box.minimum[1] - convertedNativeBounds.maximum[1]
: box.maximum[1] < convertedNativeBounds.minimum[1]
? convertedNativeBounds.minimum[1] - box.maximum[1]
: 0;
if (overlapsNativeXZ) {
for (const point of geometry.positions.reduce((all, value, index) => {
if (index % 3 === 0) all.push(geometry.positions.slice(index, index + 3));
return all;
}, [])) include(coreSupportBounds, point);
}
padRows.push({ sourcePointIndex, sourcePoint, center: top.reduce((center, point) => center.map((value, axis) => value + point[axis] / top.length), [0, 0, 0]),
bounds: box, overlapsNativeXZ, verticalGapToNativeBoundsM: yGapM });
}
const coreBounds = emptyBounds();
for (const point of [
[convertedNativeBounds.minimum[0], convertedNativeBounds.minimum[1], convertedNativeBounds.minimum[2]],
[convertedNativeBounds.maximum[0], convertedNativeBounds.maximum[1], convertedNativeBounds.maximum[2]],
[coreSupportBounds.minimum[0], coreSupportBounds.minimum[1], coreSupportBounds.minimum[2]],
[coreSupportBounds.maximum[0], coreSupportBounds.maximum[1], coreSupportBounds.maximum[2]],
]) {
if (point.every(Number.isFinite)) include(coreBounds, point);
}
const gridWidth = Math.ceil((coreBounds.maximum[0] - coreBounds.minimum[0]) / Number(recipe.navigation.cellSize));
const gridDepth = Math.ceil((coreBounds.maximum[2] - coreBounds.minimum[2]) / Number(recipe.navigation.cellSize));
const overlapRows = padRows.filter((row) => row.overlapsNativeXZ);
const remoteRows = padRows.filter((row) => !row.overlapsNativeXZ);
const report = {
schemaVersion: 1,
slug,
source: {
primaryModel: recipe.source.model,
primaryModelSha256: stageReport.source.sourceModelSha256,
nativeCollisionReport: relative(sourceReportPath),
nativeCollisionReportSha256: sha256(await readFile(sourceReportPath)),
nativeCollisionTriangles: sourceReport.nativeCollisionTriangleCount,
visualFallbackTriangles: sourceReport.renderMeshFallbackTriangleCount,
nativeTriangleFlags: sourceReport.nativeTriangleFlagCounts,
pathCollisionExclusionMask: sourceReport.pathCollisionPolicy.exclusionMask,
convertedCollisionChunks: conversion.collision.map((record) => record.file),
},
boundsProof: {
convertedCollisionExcludingSupport: convertedNativeBounds,
expectedNativeCollisionBoundsAfterUnitsAndZBridge: expectedNativeBounds,
comparisonMaximumErrorM: convertedNativeBounds.minimum.map((value, axis) => Math.max(
Math.abs(value - expectedNativeBounds.minimum[axis]),
Math.abs(convertedNativeBounds.maximum[axis] - expectedNativeBounds.maximum[axis]),
)).reduce((max, value) => Math.max(max, value), 0),
convertedNativeCollisionTriangles: convertedNativeTriangles,
sourceQueryNativeTriangles: sourceReport.nativeCollisionTriangleCount,
exactBoundsMatchToleranceM: 0.01,
nativeCoreBounds: convertedNativeBounds,
supportUnionBounds: supportBounds,
nativeCorePlusXZOverlappingSupportBounds: coreBounds,
},
selection: {
sourceAnchorPointCount: sourcePoints.length,
omittedNativeGroundedPointIndices: [...omitted],
retainedSupportCount: padRows.length,
coreXzOverlapCount: overlapRows.length,
isolatedRemoteCount: remoteRows.length,
coreSupportSourcePointIndices: overlapRows.map((row) => row.sourcePointIndex),
remoteSupportSourcePointIndices: remoteRows.map((row) => row.sourcePointIndex),
remoteSupportClearanceM: Number(recipe.navigation.agentRadius ?? 0.36),
remoteSupportTopInsetHalfExtentM: Number(stageReport.source.halfExtentSourceUnits) * meters - Number(recipe.navigation.agentRadius ?? 0.36),
},
predictedCoreRecastGrid: {
cellSize: Number(recipe.navigation.cellSize),
widthCells: gridWidth,
depthCells: gridDepth,
cellCount: gridWidth * gridDepth,
recipeTileSize: recipe.navigation.tileSize ?? null,
},
padRows,
conclusion: "This report only classifies source-grounded collision and support selection. It does not merge/bake any nav surfaces. Before staging, verify the bounds comparison and keep remote supports as separate explicit components with agent-radius inset; no route connectivity is inferred.",
};
const output = path.join(stageDungeon, "native-core-support-overlap.json");
await writeFile(output, `${JSON.stringify(report, null, 2)}\n`, "utf8");
console.log(JSON.stringify({
report: relative(output),
nativeCollisionTriangles: sourceReport.nativeCollisionTriangleCount,
convertedNativeTriangles,
boundsErrorM: report.boundsProof.comparisonMaximumErrorM,
coreXzOverlapCount: overlapRows.length,
isolatedRemoteCount: remoteRows.length,
coreBounds,
grid: report.predictedCoreRecastGrid,
}, null, 2));