191 lines
9.4 KiB
JavaScript
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));
|