#!/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));