import { BufferGeometry, Float32BufferAttribute, type Group, type Mesh } from "three"; import type { DungeonNavigationLink, DungeonTraversalRamp } from "./dungeonTypes"; import { bridgeDungeonStairNavigation, smoothDungeonStairGeometry } from "./dungeonTraversalRamps"; import { buildPartyNavigationGraph, type PartyNavigationGraph, } from "./partyNavigation"; import { MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES } from "./partyNavigationPolicy"; const MAX_CACHED_NAVIGATION_GRAPHS = 8; const graphCache = new Map(); function graphCacheKey( url: string, offMeshLinks: readonly DungeonNavigationLink[], ramps: readonly DungeonTraversalRamp[], ): string { const links = offMeshLinks.map((link) => [ link.id, link.start, link.end, link.bidirectional, ]); return `${url}:${JSON.stringify([links, ramps])}`; } function navigationGeometry(scene: Group): BufferGeometry { scene.updateMatrixWorld(true); let sourceMesh: Mesh | null = null; let largestVertexCount = 0; scene.traverse((node) => { const mesh = node as Mesh; const count = mesh.isMesh ? mesh.geometry.getAttribute("position")?.count ?? 0 : 0; if (count > largestVertexCount) { sourceMesh = mesh; largestVertexCount = count; } }); if (!sourceMesh) throw new Error("The party navigation GLB has no triangle geometry."); const geometry = (sourceMesh as Mesh).geometry.clone(); const position = geometry.getAttribute("position"); // BufferAttribute.applyMatrix4 writes back through the source component // type. Quantized normalized integers therefore clamp world coordinates to // [-1, 1] while baking their node transform. Expand them to floats first. if (position.normalized) { const expanded = new Float32Array(position.count * 3); for (let index = 0; index < position.count; index += 1) { expanded[index * 3] = position.getX(index); expanded[index * 3 + 1] = position.getY(index); expanded[index * 3 + 2] = position.getZ(index); } geometry.setAttribute("position", new Float32BufferAttribute(expanded, 3)); } geometry.applyMatrix4((sourceMesh as Mesh).matrixWorld); return geometry; } export function preparePartyNavigationGraph( url: string, scene: Group, offMeshLinks: readonly DungeonNavigationLink[], ramps: readonly DungeonTraversalRamp[] = [], ): PartyNavigationGraph { const cacheKey = graphCacheKey(url, offMeshLinks, ramps); const cached = graphCache.get(cacheKey); if (cached) { graphCache.delete(cacheKey); graphCache.set(cacheKey, cached); return cached; } const geometry = navigationGeometry(scene); let graph: PartyNavigationGraph; try { if (ramps.length) { // Recast normally lifts its walkable surface slightly above collision. smoothDungeonStairGeometry(geometry, ramps, 0.05); bridgeDungeonStairNavigation(geometry, ramps, 0.05); } const bridgedLinks = new Set(ramps.map((ramp) => ramp.navigationBridge.linkId)); graph = buildPartyNavigationGraph(geometry, { maxSourceTriangles: MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES, offMeshLinks: offMeshLinks.filter((link) => !bridgedLinks.has(link.id)), }); } finally { geometry.dispose(); } graphCache.set(cacheKey, graph); if (graphCache.size > MAX_CACHED_NAVIGATION_GRAPHS) { const oldest = graphCache.keys().next().value as string | undefined; if (oldest) graphCache.delete(oldest); } return graph; }