218 lines
11 KiB
JavaScript
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));
|