import { BufferGeometry, Float32BufferAttribute, Vector3, type Group, type Mesh } from "three"; import type { DungeonNavigationComponentPolicy, DungeonNavigationLink, DungeonNavigationTopologySummary, DungeonTraversalRamp, } from "./dungeonTypes"; import { bridgeDungeonStairNavigation, dungeonRampNavigationBridges, smoothDungeonStairGeometry } from "./dungeonTraversalRamps"; import { buildPartyNavigationGraph, buildPartyNavigationGraphFromDetourTopology, type PartyNavigationGraph, type PartyNavigationDetourTopology, } from "./partyNavigation"; import { MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES } from "./partyNavigationPolicy"; const MAX_CACHED_NAVIGATION_GRAPHS = 8; const graphCache = new Map(); export function cachedPartyNavigationGraph( url: string, links: readonly DungeonNavigationLink[], ramps: readonly DungeonTraversalRamp[] = [], retainSurfaceTriangles = false, detourTopologySummary?: DungeonNavigationTopologySummary, navigationComponentPolicy?: DungeonNavigationComponentPolicy, navigationAssetChecksum?: string, ): PartyNavigationGraph | null { return graphCache.get(graphCacheKey(url, links, ramps) + (retainSurfaceTriangles ? ":floor-validation" : "") + detourTopologyCacheKey(detourTopologySummary) + navigationIdentityCacheKey(navigationComponentPolicy, navigationAssetChecksum)) ?? null; } function navigationIdentityCacheKey( policy: DungeonNavigationComponentPolicy | undefined, checksum: string | undefined, ): string { if (!policy && !checksum) return ""; return `:navigation-${policy ?? "default"}-${encodeURIComponent(checksum ?? "unversioned")}`; } function detourTopologyCacheKey(summary: DungeonNavigationTopologySummary | undefined): string { return summary ? `:detour-${summary.schemaVersion}-${summary.polygonCount}-${summary.directedLinkCount}-${summary.detailTriangleCount}` + (summary.assetChecksum ? `-${encodeURIComponent(summary.assetChecksum)}` : "") : ""; } function validateDetourTopologyManifestCounts( rawTopology: PartyNavigationDetourTopology | undefined, summary: DungeonNavigationTopologySummary | undefined, ): void { if (!rawTopology && !summary) return; if (!rawTopology || !summary) { throw new Error("Source-pinned Detour navigation requires both GLB topology extras and a manifest summary."); } if ( rawTopology.schemaVersion !== summary.schemaVersion || rawTopology.polygonCount !== summary.polygonCount || rawTopology.directedLinkCount !== summary.directedLinkCount || rawTopology.detailTriangleCount !== summary.detailTriangleCount || rawTopology.geometryTriangleCount !== summary.detailTriangleCount || rawTopology.unrepresentedLinkCount !== 0 || rawTopology.polygons.length !== summary.polygonCount || rawTopology.links.length !== summary.directedLinkCount ) { throw new Error("Source-pinned Detour GLB topology counts do not match its reviewed manifest summary."); } } 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, componentPolicy: DungeonNavigationComponentPolicy | undefined, maxSourceTriangles: number, ): BufferGeometry { scene.updateMatrixWorld(true); if (componentPolicy === "all-components") { const sourceMeshes: Mesh[] = []; let sourceTriangleCount = 0; scene.traverse((node) => { const mesh = node as Mesh; if (!mesh.isMesh) return; const position = mesh.geometry.getAttribute("position"); if (!position || position.count < 3) return; const indexCount = mesh.geometry.getIndex()?.count ?? position.count; if (indexCount < 3 || indexCount % 3 !== 0) { throw new Error("An all-components party navigation mesh must contain complete triangles."); } sourceMeshes.push(mesh); sourceTriangleCount += indexCount / 3; }); if (!sourceMeshes.length) throw new Error("The party navigation GLB has no triangle geometry."); if (sourceTriangleCount > maxSourceTriangles) { throw new Error( `Party navigation geometry has ${sourceTriangleCount} triangles; maximum is ${maxSourceTriangles}.`, ); } const mergedPositions = new Float32Array(sourceTriangleCount * 9); const point = new Vector3(); let outputOffset = 0; for (const mesh of sourceMeshes) { const geometry = mesh.geometry; const position = geometry.getAttribute("position"); const index = geometry.getIndex(); const indexCount = index?.count ?? position.count; for (let sourceIndex = 0; sourceIndex < indexCount; sourceIndex += 1) { const vertexIndex = index ? index.getX(sourceIndex) : sourceIndex; if (!Number.isInteger(vertexIndex) || vertexIndex < 0 || vertexIndex >= position.count) { throw new Error(`Party navigation index ${sourceIndex} references an invalid vertex.`); } point.fromBufferAttribute(position, vertexIndex).applyMatrix4(mesh.matrixWorld); mergedPositions[outputOffset++] = point.x; mergedPositions[outputOffset++] = point.y; mergedPositions[outputOffset++] = point.z; } } const merged = new BufferGeometry(); merged.setAttribute("position", new Float32BufferAttribute(mergedPositions, 3)); return merged; } 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[] = [], maxSourceTriangles = MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES, retainSurfaceTriangles = false, detourTopology?: PartyNavigationDetourTopology, detourTopologySummary?: DungeonNavigationTopologySummary, navigationComponentPolicy?: DungeonNavigationComponentPolicy, navigationAssetChecksum?: string, ): PartyNavigationGraph { validateDetourTopologyManifestCounts(detourTopology, detourTopologySummary); if (detourTopology && navigationComponentPolicy) { throw new Error("Source-pinned Detour topology cannot be combined with inferred all-components navigation."); } const cacheKey = graphCacheKey(url, offMeshLinks, ramps) + (retainSurfaceTriangles ? ":floor-validation" : "") + detourTopologyCacheKey(detourTopologySummary) + navigationIdentityCacheKey(navigationComponentPolicy, navigationAssetChecksum); const cached = graphCache.get(cacheKey); if (cached) { graphCache.delete(cacheKey); graphCache.set(cacheKey, cached); return cached; } const geometry = navigationGeometry(scene, navigationComponentPolicy, maxSourceTriangles); let graph: PartyNavigationGraph; try { if (detourTopology) { if (ramps.length || offMeshLinks.length) { throw new Error("Source-pinned Detour topology cannot be combined with inferred ramp or off-mesh links."); } graph = buildPartyNavigationGraphFromDetourTopology(geometry, detourTopology); } else { 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.flatMap((ramp) => dungeonRampNavigationBridges(ramp).map((bridge) => bridge.linkId))); graph = buildPartyNavigationGraph(geometry, { maxSourceTriangles, retainSurfaceTriangles, retainAllComponents: navigationComponentPolicy === "all-components", 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; }