Files
healer-man/scripts/runewaker-pipeline/probe-aeternal-entry-local-native-nav.mjs
2026-10-06 22:25:34 -04:00

218 lines
11 KiB
JavaScript

import { mkdir, readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Document, NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { BufferAttribute, BufferGeometry, Vector3 } from "three";
import { MeshBVH } from "three-mesh-bvh";
import { bakeNavigationFromCollisionAssets } from "../dungeon-pipeline/recast-bake.mjs";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const work = path.join(projectRoot, "runewaker-export-work", "aeternal-circle");
const inspection = path.join(work, "inspection");
const recipe = JSON.parse(await readFile(path.join(projectRoot, "scripts/runewaker-pipeline/recipes/aeternal-circle.json"), "utf8"));
const wdbReport = JSON.parse(await readFile(path.join(work, "raw/wdb-report.json"), "utf8"));
const sourceFile = path.join(work, "converted/aeternal-circle-visual.glb");
const cropFile = path.join(inspection, "aeternal-native-entry-roi.glb");
const navFile = path.join(inspection, "aeternal-native-entry-roi-navigation.glb");
const navReportFile = path.join(inspection, "aeternal-native-entry-roi-recast-report.json");
const outputFile = path.join(inspection, "aeternal-native-entry-roi-probe.json");
const primary = wdbReport.descriptors.find((descriptor) => (
descriptor.resource.replaceAll("/", "\\").toLowerCase() === recipe.source.model.replaceAll("/", "\\").toLowerCase()
&& descriptor.translation.every((value, index) => Math.abs(value - recipe.source.primaryPlacementTranslation[index]) <= 0.0001)
));
if (!primary) throw new Error("Cannot resolve the recipe's primary WDB placement.");
const sourceAnchor = recipe.source.sourceAnchorSupport.points[0].map(Number);
const scale = recipe.coordinateSystem.metersPerSourceUnit;
const entry = [
(sourceAnchor[0] - primary.translation[0]) * scale,
(sourceAnchor[1] - primary.translation[1]) * scale,
-(sourceAnchor[2] - primary.translation[2]) * scale,
];
const roi = { halfExtent: 28, minY: entry[1] - 5, maxY: entry[1] + 2 };
const transformPoint = (matrix, p) => [
matrix[0] * p[0] + matrix[4] * p[1] + matrix[8] * p[2] + matrix[12],
matrix[1] * p[0] + matrix[5] * p[1] + matrix[9] * p[2] + matrix[13],
matrix[2] * p[0] + matrix[6] * p[1] + matrix[10] * p[2] + matrix[14],
];
const inputDocument = await new NodeIO().registerExtensions(ALL_EXTENSIONS).read(sourceFile);
const allPoints = [];
let retainedTriangles = 0;
let skippedSupportTriangles = 0;
for (const node of inputDocument.getRoot().listNodes()) {
if (!node.getMesh()) continue;
if (node.getName() === "runewaker_source_anchor_support") {
skippedSupportTriangles += node.getMesh().listPrimitives().reduce((sum, primitive) => (
sum + (primitive.getIndices()?.getCount() ?? 0) / 3
), 0);
continue;
}
const matrix = Array.from(node.getWorldMatrix());
for (const primitive of node.getMesh().listPrimitives()) {
const position = primitive.getAttribute("POSITION");
const array = primitive.getIndices()?.getArray();
if (!position || !array) continue;
for (let offset = 0; offset < array.length; offset += 3) {
const triangle = [array[offset], array[offset + 1], array[offset + 2]]
.map((index) => transformPoint(matrix, position.getElement(index, [0, 0, 0])));
const min = [0, 1, 2].map((axis) => Math.min(...triangle.map((point) => point[axis])));
const max = [0, 1, 2].map((axis) => Math.max(...triangle.map((point) => point[axis])));
if (max[0] < entry[0] - roi.halfExtent || min[0] > entry[0] + roi.halfExtent
|| max[2] < entry[2] - roi.halfExtent || min[2] > entry[2] + roi.halfExtent
|| max[1] < roi.minY || min[1] > roi.maxY) continue;
allPoints.push(...triangle.flat());
retainedTriangles += 1;
}
}
}
if (!retainedTriangles) throw new Error("No source-native triangles intersect the Aeternal entry ROI.");
const cropDocument = new Document();
const buffer = cropDocument.createBuffer("aeternal-native-entry-roi-buffer");
const positions = cropDocument.createAccessor("native-source-positions", buffer)
.setType("VEC3").setArray(new Float32Array(allPoints));
const primitive = cropDocument.createPrimitive().setAttribute("POSITION", positions);
const mesh = cropDocument.createMesh("aeternal-native-entry-roi").addPrimitive(primitive);
cropDocument.createScene("aeternal-native-entry-roi-scene")
.addChild(cropDocument.createNode("native-primary-ros-roi").setMesh(mesh));
await mkdir(inspection, { recursive: true });
await new NodeIO().registerExtensions(ALL_EXTENSIONS).write(cropFile, cropDocument);
const navReport = await bakeNavigationFromCollisionAssets({
slug: "aeternal-native-entry-roi-inspection",
collisionAssets: [{ id: "native-entry-roi", role: "collision", path: path.relative(projectRoot, cropFile).replaceAll("\\", "/") }],
settings: { ...recipe.navigation, partition: "monotone", retainTileIntermediates: false },
output: navFile,
reportFile: navReportFile,
source: {
kind: "inspection-only-native-primary-ROS-entry-ROI",
canonicalVisual: path.relative(projectRoot, sourceFile).replaceAll("\\", "/"),
excludedGeneratedSupportNode: "runewaker_source_anchor_support",
excludedSupportTriangles: skippedSupportTriangles,
bounds: roi,
},
});
const navDocument = await new NodeIO().registerExtensions(ALL_EXTENSIONS).read(navFile);
const navNode = navDocument.getRoot().listNodes().find((node) => node.getName() === "navigation");
if (!navNode?.getMesh()) throw new Error("Local native-only Recast output has no navigation node.");
const navTrianglePositions = [];
for (const navPrimitive of navNode.getMesh().listPrimitives()) {
const position = navPrimitive.getAttribute("POSITION");
const indices = navPrimitive.getIndices()?.getArray();
if (!position || !indices) continue;
const matrix = Array.from(navNode.getWorldMatrix());
for (let offset = 0; offset < indices.length; offset += 3) {
navTrianglePositions.push([indices[offset], indices[offset + 1], indices[offset + 2]].map((index) => {
const p = position.getElement(index, [0, 0, 0]);
return transformPoint(matrix, p);
}));
}
}
const navGeometry = new BufferGeometry();
navGeometry.setAttribute("position", new BufferAttribute(new Float32Array(navTrianglePositions.flat(2)), 3));
const bvh = new MeshBVH(navGeometry);
const pointKey = (point) => point.map((value) => Math.round(value * 10000)).join(",");
const edgeKey = (a, b) => [pointKey(a), pointKey(b)].sort().join("|");
const edgeUsers = new Map();
for (const [triangleIndex, triangle] of navTrianglePositions.entries()) {
for (const [a, b] of [[0, 1], [1, 2], [2, 0]]) {
const key = edgeKey(triangle[a], triangle[b]);
const users = edgeUsers.get(key) ?? [];
users.push(triangleIndex);
edgeUsers.set(key, users);
}
}
const adjacency = Array.from({ length: navTrianglePositions.length }, () => new Set());
for (const users of edgeUsers.values()) {
for (const from of users) for (const to of users) if (from !== to) adjacency[from].add(to);
}
const componentForTriangle = new Int32Array(navTrianglePositions.length).fill(-1);
const components = [];
for (let start = 0; start < navTrianglePositions.length; start += 1) {
if (componentForTriangle[start] !== -1) continue;
const id = components.length;
const queue = [start];
componentForTriangle[start] = id;
for (let cursor = 0; cursor < queue.length; cursor += 1) {
for (const next of adjacency[queue[cursor]]) {
if (componentForTriangle[next] !== -1) continue;
componentForTriangle[next] = id;
queue.push(next);
}
}
const points = queue.flatMap((triangleIndex) => navTrianglePositions[triangleIndex]);
components.push({
id,
triangleCount: queue.length,
bounds: {
min: [0, 1, 2].map((axis) => Math.min(...points.map((point) => point[axis]))),
max: [0, 1, 2].map((axis) => Math.max(...points.map((point) => point[axis]))),
},
});
}
const projections = [];
for (let dx = -24; dx <= 24; dx += 4) for (let dz = -24; dz <= 24; dz += 4) {
const target = new Vector3(entry[0] + dx, entry[1], entry[2] + dz);
const closest = bvh.closestPointToPoint(target);
if (!closest) continue;
const triangle = navTrianglePositions[closest.faceIndex];
const a = triangle[1].map((value, index) => value - triangle[0][index]);
const b = triangle[2].map((value, index) => value - triangle[0][index]);
const normal = [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
const length = Math.hypot(...normal) || 1;
projections.push({
offsetMeters: [dx, dz],
point: closest.point.toArray().map((value) => Number(value.toFixed(5))),
distanceMeters: Number(closest.distance.toFixed(5)),
upwardNormalY: Number((normal[1] / length).toFixed(5)),
triangleIndex: closest.faceIndex,
componentId: componentForTriangle[closest.faceIndex],
});
}
const entryProjection = projections.find((projection) => projection.offsetMeters[0] === 0 && projection.offsetMeters[1] === 0) ?? null;
const report = {
schemaVersion: 1,
kind: "aeternal-circle-native-entry-roi-navigation-probe",
input: {
canonicalVisual: path.relative(projectRoot, sourceFile).replaceAll("\\", "/"),
diagnosticCrop: path.relative(projectRoot, cropFile).replaceAll("\\", "/"),
supportNodeExcluded: "runewaker_source_anchor_support",
supportTrianglesExcluded: skippedSupportTriangles,
entryRuntimeLocal: entry,
roi: { halfExtentMeters: roi.halfExtent, minY: roi.minY, maxY: roi.maxY },
retainedNativeTriangles: retainedTriangles,
},
nativeOnlyRecast: {
geometry: navReport.geometry,
build: navReport.build,
settings: navReport.settings,
triangleCount: navTrianglePositions.length,
componentCount: components.length,
largestComponents: components.slice().sort((a, b) => b.triangleCount - a.triangleCount).slice(0, 10),
entryConnectedComponent: entryProjection ? components[entryProjection.componentId] : null,
projections,
entryProjection,
},
interpretation: {
localNativeWalkSurfaceFound: Boolean(entryProjection && entryProjection.distanceMeters <= 0.5 && entryProjection.upwardNormalY >= 0.75),
entryProjectionBelongsToConnectedComponent: Boolean(entryProjection && components[entryProjection.componentId]?.triangleCount > 1),
entryProjectionMaxLocalDistanceMeters: 0.5,
minimumUpwardNormalY: 0.75,
recommendation: "The source primary ROS supplies a locally connected walk surface at the entry anchor without the generated support mesh. Remove only this entry's generated pad after checking the shipped coordinate correction; keep the remaining pads pending per-anchor native-route evidence.",
limitation: "The diagnostic crops only the primary ROS to a 56m entry ROI. It does not certify complete dungeon traversal or assess secondary WDB placements.",
},
caveat: "This inspection-only 56m ROI tests native primary-ROS Recast support. It excludes all generated source-anchor support and all secondary WDB placements; it cannot establish a full-dungeon route by itself.",
};
await (await import("node:fs/promises")).writeFile(outputFile, `${JSON.stringify(report, null, 2)}\n`, "utf8");
navGeometry.dispose();
console.log(JSON.stringify({
report: path.relative(projectRoot, outputFile).replaceAll("\\", "/"),
entry,
retainedNativeTriangles: retainedTriangles,
navTriangles: navTrianglePositions.length,
gridCells: navReport.build.gridCells,
entryProjection: report.nativeOnlyRecast.entryProjection,
}, null, 2));