Files
healer-man/scripts/dungeon-pipeline/audit-coordinate-alignment.mjs
T
2026-08-27 13:29:58 -04:00

123 lines
4.9 KiB
JavaScript

#!/usr/bin/env node
import path from "node:path";
import { fileURLToPath } from "node:url";
import { 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 { MeshoptDecoder } from "meshoptimizer";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const campaign = await import("../../src/game/generated/dungeonCampaignCatalog.json", {
with: { type: "json" },
}).then((module) => module.default);
const useCollision = process.argv.includes("--collision");
const requestedSlugs = new Set(process.argv.slice(2).filter((argument) => argument !== "--collision"));
const definitions = campaign.definitions.filter((definition) => (
!requestedSlugs.size || requestedSlugs.has(definition.id)
));
if (!definitions.length) {
throw new Error("No matching dungeon definitions. Pass one or more dungeon slugs.");
}
await MeshoptDecoder.ready;
const io = new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
const point = new Vector3();
function normalizerFor(accessor, array) {
if (!accessor.getNormalized()) return (value) => value;
if (array instanceof Int16Array) return (value) => Math.max(value / 32_767, -1);
if (array instanceof Uint16Array) return (value) => value / 65_535;
if (array instanceof Int8Array) return (value) => Math.max(value / 127, -1);
if (array instanceof Uint8Array) return (value) => value / 255;
return (value) => value;
}
async function geometryTrees(file) {
const document = await io.read(file);
const trees = [];
for (const node of document.getRoot().listNodes()) {
const mesh = node.getMesh();
if (!mesh) continue;
const matrix = node.getWorldMatrix();
for (const primitive of mesh.listPrimitives()) {
const accessor = primitive.getAttribute("POSITION");
const source = accessor?.getArray();
if (!accessor || !source) continue;
const normalize = normalizerFor(accessor, source);
const positions = new Float32Array(source.length);
for (let offset = 0; offset < source.length; offset += 3) {
const x = normalize(source[offset]);
const y = normalize(source[offset + 1]);
const z = normalize(source[offset + 2]);
positions[offset] = matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12];
positions[offset + 1] = matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13];
positions[offset + 2] = matrix[2] * x + matrix[6] * y + matrix[10] * z + matrix[14];
}
const geometry = new BufferGeometry();
geometry.setAttribute("position", new BufferAttribute(positions, 3));
const indices = primitive.getIndices()?.getArray();
if (indices) geometry.setIndex(new BufferAttribute(indices, 1));
trees.push(new MeshBVH(geometry));
}
}
return trees;
}
function nearestDistance(trees, position) {
point.fromArray(position);
let distance = Infinity;
for (const tree of trees) {
const result = tree.closestPointToPoint(point);
distance = Math.min(distance, result?.distance ?? Infinity);
}
return distance;
}
function percentile(sorted, proportion) {
return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * proportion))] ?? 0;
}
const results = [];
for (const definition of definitions) {
const geometryUrls = useCollision
? definition.assets.collision.map((chunk) => chunk.url)
: [definition.assets.navigation.url];
const trees = (await Promise.all(geometryUrls.map((url) => (
geometryTrees(path.join(projectRoot, "public", url.replace(/^\//, "")))
)))).flat();
const samples = [
{ id: "entrance", position: definition.entrance.footPosition },
...definition.staticSpawns.map((spawn) => ({ id: spawn.id, position: spawn.position })),
...definition.roamingPacks.flatMap((pack) => (
pack.waypoints.map((position, index) => ({ id: `${pack.id}:${index}`, position }))
)),
];
const distances = samples
.map((sample) => ({ ...sample, distance: nearestDistance(trees, sample.position) }))
.sort((left, right) => left.distance - right.distance);
const values = distances.map((sample) => sample.distance);
results.push({
dungeonId: definition.id,
geometry: useCollision ? "collision" : "navigation",
coordinateAlignment: definition.provenance.coordinateAlignment,
coordinateTransform: definition.provenance.coordinateTransform,
samples: values.length,
withinOneMeter: values.filter((distance) => distance <= 1).length,
medianDistance: percentile(values, 0.5),
p90Distance: percentile(values, 0.9),
maximumDistance: values.at(-1) ?? 0,
furthest: distances.slice(-5).reverse().map(({ id, position, distance }) => ({
id,
position,
distance,
})),
});
}
console.log(JSON.stringify(results, null, 2));