import { createHash } from "node:crypto"; const NULL_LINK_VALUES = new Set([-1, 0xffffffff]); function canonicalize(value) { if (Array.isArray(value)) return value.map(canonicalize); if (!value || typeof value !== "object") return value; return Object.fromEntries( Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]), ); } export function stableSha256(value) { const bytes = typeof value === "string" ? value : JSON.stringify(canonicalize(value)); return createHash("sha256").update(bytes).digest("hex"); } export function buildDetourGeometryRanges(polygonCount, trianglePolygonIds) { const starts = new Int32Array(polygonCount).fill(-1); const counts = new Uint32Array(polygonCount); let previousPolygonId = -1; for (let triangleIndex = 0; triangleIndex < trianglePolygonIds.length; triangleIndex += 1) { const polygonId = trianglePolygonIds[triangleIndex]; if (!Number.isInteger(polygonId) || polygonId < 0 || polygonId >= polygonCount) { throw new Error(`Exported triangle ${triangleIndex} references invalid Detour polygon ${polygonId}.`); } if (polygonId < previousPolygonId) { throw new Error("Exported Detour triangles are not in stable polygon order."); } previousPolygonId = polygonId; if (counts[polygonId] === 0) starts[polygonId] = triangleIndex; counts[polygonId] += 1; } return Array.from({ length: polygonCount }, (_, polygonId) => [ starts[polygonId], counts[polygonId], ]); } function findComponents(nodeCount, links) { const parents = new Int32Array(nodeCount); const sizes = new Int32Array(nodeCount); for (let id = 0; id < nodeCount; id += 1) { parents[id] = id; sizes[id] = 1; } const rootOf = (node) => { let root = node; while (parents[root] !== root) root = parents[root]; while (parents[node] !== node) { const next = parents[node]; parents[node] = root; node = next; } return root; }; for (const [from, to] of links) { const left = rootOf(from); const right = rootOf(to); if (left === right) continue; if (sizes[left] < sizes[right]) { parents[left] = right; sizes[right] += sizes[left]; } else { parents[right] = left; sizes[left] += sizes[right]; } } const components = new Map(); for (let id = 0; id < nodeCount; id += 1) { const root = rootOf(id); components.set(root, (components.get(root) ?? 0) + 1); } const sizesByDescending = [...components.values()].sort((left, right) => right - left); return { count: sizesByDescending.length, largestPolygonCount: sizesByDescending[0] ?? 0, polygonCounts: sizesByDescending, }; } /** * Copies the source Detour surface-polygon order and directed link chain into * compact, stable-ID records. Triangle ranges follow NavMeshHelper's emitted * tile/polygon/detail-triangle iteration order; links are only actual Detour * links and never inferred from near-coincident geometry. */ export function extractDetourTopology(navMesh, { sourceCollisionSha256, settingsSha256, } = {}) { if (!navMesh || typeof navMesh.getMaxTiles !== "function") { throw new Error("Detour topology requires a populated NavMesh."); } const polygons = []; const polygonIdByReference = new Map(); let triangleCount = 0; let polygonFanTriangleCount = 0; let tileCount = 0; let offMeshPolygonCount = 0; for (let tileIndex = 0; tileIndex < navMesh.getMaxTiles(); tileIndex += 1) { const tile = navMesh.getTile(tileIndex); const header = tile.header(); if (!header || header.polyCount() === 0) continue; tileCount += 1; const polyRefBase = navMesh.getPolyRefBase(tile); for (let polygonIndex = 0; polygonIndex < header.polyCount(); polygonIndex += 1) { const polygon = tile.polys(polygonIndex); if (polygon.getType() !== 0) { offMeshPolygonCount += 1; continue; } const detail = tile.detailMeshes(polygonIndex); const triangleCountForPolygon = detail.triCount(); const polygonVertexCount = polygon.vertCount(); const id = polygons.length; polygons.push([ id, tileIndex, header.x(), header.y(), header.layer(), polygonIndex, triangleCount, triangleCountForPolygon, polygonVertexCount, ]); triangleCount += triangleCountForPolygon; polygonFanTriangleCount += Math.max(0, polygonVertexCount - 2); polygonIdByReference.set(polyRefBase + polygonIndex, id); } } const links = []; let unrepresentedLinkCount = 0; for (const polygonRecord of polygons) { const [fromId, tileIndex, , , , polygonIndex] = polygonRecord; const tile = navMesh.getTile(tileIndex); const polygon = tile.polys(polygonIndex); const header = tile.header(); const maxLinkCount = header.maxLinkCount(); let linkIndex = polygon.firstLink(); let traversed = 0; while (!NULL_LINK_VALUES.has(linkIndex)) { if (!Number.isInteger(linkIndex) || linkIndex < 0 || linkIndex >= maxLinkCount) { throw new Error( `Detour polygon ${tileIndex}:${polygonIndex} has invalid link index ${linkIndex}.`, ); } if (traversed >= maxLinkCount) { throw new Error(`Detour polygon ${tileIndex}:${polygonIndex} contains a link cycle.`); } traversed += 1; const link = tile.links(linkIndex); const toId = polygonIdByReference.get(link.ref()); if (toId === undefined) { unrepresentedLinkCount += 1; } else { const edgeIndex = link.edge(); const polygonVertexCount = polygon.vertCount(); if (edgeIndex < 0 || edgeIndex >= polygonVertexCount) { throw new Error( `Detour polygon ${tileIndex}:${polygonIndex} has invalid linked edge ${edgeIndex}.`, ); } const edgeStartVertex = polygon.verts(edgeIndex); const edgeEndVertex = polygon.verts((edgeIndex + 1) % polygonVertexCount); const edgeStartOffset = edgeStartVertex * 3; const edgeEndOffset = edgeEndVertex * 3; links.push([ fromId, toId, edgeIndex, link.side(), link.bmin(), link.bmax(), edgeStartVertex, edgeEndVertex, tile.verts(edgeStartOffset), tile.verts(edgeStartOffset + 1), tile.verts(edgeStartOffset + 2), tile.verts(edgeEndOffset), tile.verts(edgeEndOffset + 1), tile.verts(edgeEndOffset + 2), ]); } linkIndex = link.next(); } } const weakComponents = findComponents(polygons.length, links.map(([from, to]) => [from, to])); return { schemaVersion: 1, kind: "recast-detour-surface-links", coordinateSpace: "GLB navigation POSITION coordinates (meters, Three.js Y-up)", encoding: { polygons: "[id,tileIndex,tileX,tileY,tileLayer,tilePolygonIndex,firstGeometryTriangle,geometryTriangleCount,vertexCount]", links: "[fromPolygonId,toPolygonId,fromEdge,side,bmin,bmax,fromEdgeStartVertex,fromEdgeEndVertex,startX,startY,startZ,endX,endY,endZ] (directed Detour link records)", }, sourceCollisionSha256: sourceCollisionSha256 ?? null, settingsSha256: settingsSha256 ?? null, tileCount, polygonCount: polygons.length, detailTriangleCount: triangleCount, polygonFanTriangleCount, geometryTriangleCount: null, directedLinkCount: links.length, unrepresentedLinkCount, offMeshPolygonCount, weakComponents, polygons, links, }; }