import type { BufferGeometry } from "three"; export const PARTY_NAVIGATION_WELD_TOLERANCE = 0.001; export const MAX_PARTY_NAVIGATION_DETOUR_POLYGONS = 50_000; export const MAX_PARTY_NAVIGATION_DETOUR_LINKS = 100_000; export const MAX_PARTY_NAVIGATION_DETAIL_TRIANGLES = 200_000; export const PARTY_NAVIGATION_SURFACE_BUCKET_SIZE = 8; const MAX_PARTY_NAVIGATION_SURFACE_BUCKET_REFERENCES = 2_000_000; export type PartyNavigationPosition = readonly [x: number, y: number, z: number]; export type PartyNavigationPoint = PartyNavigationPosition | Readonly<{ x: number; y: number; z: number }>; export interface PartyNavigationEdge { readonly to: number; readonly cost: number; /** Center of an exact Detour portal; present only for a source-pinned graph. */ readonly point?: PartyNavigationPosition; } export interface PartyNavigationNode { readonly id: number; readonly position: PartyNavigationPosition; readonly neighbors: readonly PartyNavigationEdge[]; } export type PartyNavigationTriangle = readonly [a: number, b: number, c: number]; export type PartyNavigationPortal = readonly [a: number, b: number]; export interface PartyNavigationTriangleEdge { readonly to: number; readonly portal: PartyNavigationPortal; readonly cost: number; } export interface PartyNavigationTriangleNode { readonly id: number; readonly centroid: PartyNavigationPosition; readonly neighbors: readonly PartyNavigationTriangleEdge[]; } export interface PartyNavigationGraph { readonly nodes: readonly PartyNavigationNode[]; /** Triangle node indices retained by the builder, in matching node-index order. */ readonly triangles?: readonly PartyNavigationTriangle[]; /** Shared-edge adjacency for the retained triangles, in matching index order. */ readonly triangleNodes?: readonly PartyNavigationTriangleNode[]; readonly weldTolerance: number; readonly sourceVertexCount: number; readonly sourceTriangleCount: number; /** Number of welded nodes before disconnected islands were discarded. */ readonly weldedNodeCount: number; readonly componentCount: number; readonly discardedNodeCount: number; /** Packed original detail triangles and their source Detour polygon IDs. */ readonly detourSurface?: PartyNavigationDetourSurface; } export interface PartyNavigationDetourSurface { /** Three positions per triangle, in the original GLB detail-mesh order. */ readonly positions: Float32Array; readonly polygonByTriangle: Uint32Array; readonly bucketSize: number; readonly buckets: ReadonlyMap; } export interface PartyNavigationDetourTopology { readonly schemaVersion: 1; readonly kind: "recast-detour-surface-links"; readonly polygonCount: number; readonly detailTriangleCount: number; readonly geometryTriangleCount: number; readonly directedLinkCount: number; readonly unrepresentedLinkCount: number; /** Encoded records from the `runewakerNavigationTopology` GLB extra. */ readonly polygons: readonly (readonly number[])[]; readonly links: readonly (readonly number[])[]; } export interface PartyNavigationBuildOptions { /** Validation may retain floor triangles while gameplay routes off-mesh links through nodes. */ readonly retainSurfaceTriangles?: boolean; /** Keep every disconnected source island selectable; routes still cannot cross components without links. */ readonly retainAllComponents?: boolean; readonly weldTolerance?: number; /** Defensive runtime ceiling; checked before graph allocations begin. */ readonly maxSourceTriangles?: number; readonly offMeshLinks?: readonly { readonly start: PartyNavigationPosition; readonly end: PartyNavigationPosition; readonly bidirectional: boolean; }[]; } export interface PartyNavigationNearestNode { readonly node: PartyNavigationNode; readonly distance: number; } export interface PartyNavigationPathOptions { /** Reject endpoints farther than this distance from the retained navigation surface. */ readonly maxSnapDistance?: number; /** Optional start-only override for maxSnapDistance. */ readonly maxStartSnapDistance?: number; /** Optional end-only override for maxSnapDistance. */ readonly maxEndSnapDistance?: number; /** Maximum 3D deviation when removing redundant, collinear waypoints. */ readonly simplifyTolerance?: number; /** Optional collision/clearance validation for each reconstructed corridor segment. */ readonly routeSegmentAllowed?: ( start: PartyNavigationPosition, end: PartyNavigationPosition, ) => boolean; } interface MutableWeldedNode { readonly representative: [number, number, number]; sumX: number; sumY: number; sumZ: number; samples: number; readonly neighbors: Set; } interface OpenEntry { readonly id: number; readonly g: number; readonly h: number; readonly f: number; } export interface PartyNavigationSurfaceMatch { readonly position: PartyNavigationPosition; readonly triangleId: number; readonly distance: number; readonly polygonId?: number; } const DISTANCE_EPSILON = 1e-9; function pointComponents(point: PartyNavigationPoint): PartyNavigationPosition { const values: PartyNavigationPosition = "x" in point ? [point.x, point.y, point.z] : [point[0], point[1], point[2]]; if (!values.every(Number.isFinite)) { throw new Error("Party navigation points must contain finite coordinates."); } return values; } function distanceSquared(left: PartyNavigationPosition, right: PartyNavigationPosition): number { const dx = right[0] - left[0]; const dy = right[1] - left[1]; const dz = right[2] - left[2]; return dx * dx + dy * dy + dz * dz; } function distance(left: PartyNavigationPosition, right: PartyNavigationPosition): number { return Math.sqrt(distanceSquared(left, right)); } function bucketCoordinate(value: number, tolerance: number): number { return Math.floor(value / tolerance); } function bucketKey(x: number, y: number, z: number): string { return `${x},${y},${z}`; } function validatedTolerance(value: number | undefined): number { const tolerance = value ?? PARTY_NAVIGATION_WELD_TOLERANCE; if (!Number.isFinite(tolerance) || tolerance <= 0) { throw new Error("Party navigation weld tolerance must be a positive finite number."); } return tolerance; } function connectedComponents(nodes: readonly MutableWeldedNode[]): number[][] { const visited = new Uint8Array(nodes.length); const components: number[][] = []; for (let seed = 0; seed < nodes.length; seed += 1) { if (visited[seed]) continue; visited[seed] = 1; const pending = [seed]; const component: number[] = []; while (pending.length) { const id = pending.pop()!; component.push(id); const neighbors = Array.from(nodes[id].neighbors).sort((left, right) => right - left); for (const neighbor of neighbors) { if (visited[neighbor]) continue; visited[neighbor] = 1; pending.push(neighbor); } } component.sort((left, right) => left - right); components.push(component); } return components; } export function buildPartyNavigationTriangleNodes( triangles: readonly PartyNavigationTriangle[], positions: readonly PartyNavigationPosition[], ): readonly PartyNavigationTriangleNode[] { const centroids = triangles.map(([a, b, c]): PartyNavigationPosition => Object.freeze([ (positions[a][0] + positions[b][0] + positions[c][0]) / 3, (positions[a][1] + positions[b][1] + positions[c][1]) / 3, (positions[a][2] + positions[b][2] + positions[c][2]) / 3, ]) as PartyNavigationPosition); const edgeOwners = new Map>(); triangles.forEach(([a, b, c], triangleId) => { for (const [left, right] of [[a, b], [b, c], [c, a]] as const) { const portal = Object.freeze([ Math.min(left, right), Math.max(left, right), ]) as PartyNavigationPortal; const key = `${portal[0]},${portal[1]}`; const owners = edgeOwners.get(key); if (owners) owners.push({ triangleId, portal }); else edgeOwners.set(key, [{ triangleId, portal }]); } }); const neighbors: PartyNavigationTriangleEdge[][] = triangles.map(() => []); for (const owners of edgeOwners.values()) { for (let leftIndex = 0; leftIndex < owners.length; leftIndex += 1) { for (let rightIndex = leftIndex + 1; rightIndex < owners.length; rightIndex += 1) { const left = owners[leftIndex]; const right = owners[rightIndex]; const cost = distance(centroids[left.triangleId], centroids[right.triangleId]); neighbors[left.triangleId].push(Object.freeze({ to: right.triangleId, portal: left.portal, cost, })); neighbors[right.triangleId].push(Object.freeze({ to: left.triangleId, portal: right.portal, cost, })); } } } return Object.freeze(centroids.map((centroid, id): PartyNavigationTriangleNode => Object.freeze({ id, centroid, neighbors: Object.freeze(neighbors[id].sort((left, right) => ( left.to - right.to || left.portal[0] - right.portal[0] || left.portal[1] - right.portal[1] ))), }))); } /** * Converts an indexed or non-indexed triangle geometry into a deterministic * navigation graph. Export seams within one millimeter are welded. By default * only the largest connected surface is retained; source imports can opt into * preserving all disconnected surfaces for local queries and routing. */ export function buildPartyNavigationGraph( geometry: BufferGeometry, options: PartyNavigationBuildOptions = {}, ): PartyNavigationGraph { const tolerance = validatedTolerance(options.weldTolerance); const position = geometry.getAttribute("position"); if (!position || position.itemSize < 3 || position.count < 3) { throw new Error("Party navigation geometry requires at least three 3D positions."); } const index = geometry.getIndex(); const indexCount = index?.count ?? position.count; if (indexCount < 3 || indexCount % 3 !== 0) { throw new Error("Party navigation geometry must contain complete triangles."); } const sourceTriangleCount = indexCount / 3; if ( options.maxSourceTriangles !== undefined && ( !Number.isInteger(options.maxSourceTriangles) || options.maxSourceTriangles <= 0 ) ) { throw new Error("Party navigation maximum source triangles must be a positive integer."); } if ( options.maxSourceTriangles !== undefined && sourceTriangleCount > options.maxSourceTriangles ) { throw new Error( `Party navigation geometry has ${sourceTriangleCount} triangles; maximum is ${options.maxSourceTriangles}.`, ); } const sourceToWelded = new Int32Array(position.count); const welded: MutableWeldedNode[] = []; const buckets = new Map(); const toleranceSquared = tolerance * tolerance; for (let sourceId = 0; sourceId < position.count; sourceId += 1) { const x = position.getX(sourceId); const y = position.getY(sourceId); const z = position.getZ(sourceId); if (![x, y, z].every(Number.isFinite)) { throw new Error(`Party navigation vertex ${sourceId} contains a non-finite coordinate.`); } const bucketX = bucketCoordinate(x, tolerance); const bucketY = bucketCoordinate(y, tolerance); const bucketZ = bucketCoordinate(z, tolerance); let bestId = -1; let bestDistanceSquared = Number.POSITIVE_INFINITY; for (let offsetX = -1; offsetX <= 1; offsetX += 1) { for (let offsetY = -1; offsetY <= 1; offsetY += 1) { for (let offsetZ = -1; offsetZ <= 1; offsetZ += 1) { const candidates = buckets.get(bucketKey( bucketX + offsetX, bucketY + offsetY, bucketZ + offsetZ, )); if (!candidates) continue; for (const candidateId of candidates) { const candidate = welded[candidateId].representative; const dx = candidate[0] - x; const dy = candidate[1] - y; const dz = candidate[2] - z; const candidateDistanceSquared = dx * dx + dy * dy + dz * dz; if (candidateDistanceSquared > toleranceSquared + DISTANCE_EPSILON) continue; if ( candidateDistanceSquared < bestDistanceSquared - DISTANCE_EPSILON || (Math.abs(candidateDistanceSquared - bestDistanceSquared) <= DISTANCE_EPSILON && candidateId < bestId) ) { bestId = candidateId; bestDistanceSquared = candidateDistanceSquared; } } } } } if (bestId < 0) { bestId = welded.length; welded.push({ representative: [x, y, z], sumX: x, sumY: y, sumZ: z, samples: 1, neighbors: new Set(), }); const key = bucketKey(bucketX, bucketY, bucketZ); const contents = buckets.get(key); if (contents) contents.push(bestId); else buckets.set(key, [bestId]); } else { const node = welded[bestId]; node.sumX += x; node.sumY += y; node.sumZ += z; node.samples += 1; } sourceToWelded[sourceId] = bestId; } const sourceIndex = (offset: number): number => { const value = index ? index.getX(offset) : offset; if (!Number.isInteger(value) || value < 0 || value >= position.count) { throw new Error(`Party navigation index ${offset} references an invalid vertex.`); } return value; }; const connect = (left: number, right: number) => { if (left === right) return; welded[left].neighbors.add(right); welded[right].neighbors.add(left); }; const weldedTriangles: PartyNavigationTriangle[] = []; const blockedOffMeshDirections = new Set(); for (let offset = 0; offset < indexCount; offset += 3) { const a = sourceToWelded[sourceIndex(offset)]; const b = sourceToWelded[sourceIndex(offset + 1)]; const c = sourceToWelded[sourceIndex(offset + 2)]; connect(a, b); connect(b, c); connect(c, a); if (a !== b && b !== c && c !== a) { weldedTriangles.push(Object.freeze([a, b, c])); } } const nearestWeldedNode = (point: PartyNavigationPosition): number => { let nearest = 0; let nearestDistance = Number.POSITIVE_INFINITY; welded.forEach((node, id) => { const candidate = distanceSquared(node.representative, point); if (candidate < nearestDistance) { nearest = id; nearestDistance = candidate; } }); return nearest; }; for (const link of options.offMeshLinks ?? []) { const start = nearestWeldedNode(link.start); const end = nearestWeldedNode(link.end); connect(start, end); // Weak connectivity determines which authored surfaces are retained. if (!link.bidirectional) blockedOffMeshDirections.add(`${end}:${start}`); } const components = connectedComponents(welded); const largest = components.reduce((best, candidate) => { if (candidate.length > best.length) return candidate; if (candidate.length < best.length) return best; return (candidate[0] ?? Number.POSITIVE_INFINITY) < (best[0] ?? Number.POSITIVE_INFINITY) ? candidate : best; }, []); if (!largest.length) throw new Error("Party navigation geometry produced no usable graph nodes."); const retained = options.retainAllComponents ? components.flat() : largest; const oldToNew = new Int32Array(welded.length); oldToNew.fill(-1); retained.forEach((oldId, newId) => { oldToNew[oldId] = newId; }); const retainedPositions = retained.map((oldId): PartyNavigationPosition => { const node = welded[oldId]; return Object.freeze([ node.sumX / node.samples, node.sumY / node.samples, node.sumZ / node.samples, ]) as PartyNavigationPosition; }); const nodes = retained.map((oldId, id): PartyNavigationNode => { const neighbors = Array.from(welded[oldId].neighbors) .filter((neighbor) => oldToNew[neighbor] >= 0) .filter((neighbor) => !blockedOffMeshDirections.has(`${oldId}:${neighbor}`)) .sort((left, right) => left - right) .map((neighbor): PartyNavigationEdge => { const to = oldToNew[neighbor]; return Object.freeze({ to, cost: distance(retainedPositions[id], retainedPositions[to]) }); }); return Object.freeze({ id, position: retainedPositions[id], neighbors: Object.freeze(neighbors), }); }); const triangles = weldedTriangles .filter(([a, b, c]) => oldToNew[a] >= 0 && oldToNew[b] >= 0 && oldToNew[c] >= 0) .map(([a, b, c]): PartyNavigationTriangle => Object.freeze([ oldToNew[a], oldToNew[b], oldToNew[c], ])); if (!triangles.length) { throw new Error("Party navigation geometry produced no usable retained triangles."); } const usesOffMeshLinks = Boolean(options.offMeshLinks?.length); const triangleNodes = usesOffMeshLinks ? undefined : buildPartyNavigationTriangleNodes(triangles, retainedPositions); return Object.freeze({ nodes: Object.freeze(nodes), ...(usesOffMeshLinks && !options.retainSurfaceTriangles ? {} : { triangles: Object.freeze(triangles), triangleNodes, }), weldTolerance: tolerance, sourceVertexCount: position.count, sourceTriangleCount, weldedNodeCount: welded.length, componentCount: components.length, discardedNodeCount: welded.length - nodes.length, }); } function topologyRecordNumber(record: readonly number[], index: number, label: string): number { const value = record[index]; if (typeof value !== "number" || !Number.isFinite(value)) { throw new Error(`Party navigation Detour topology ${label} contains a non-finite value.`); } return value; } function topologyInteger( record: readonly number[], index: number, label: string, minimum = 0, ): number { const value = topologyRecordNumber(record, index, label); if (!Number.isInteger(value) || value < minimum) { throw new Error(`Party navigation Detour topology ${label} contains an invalid integer.`); } return value; } /** * Builds the runtime graph from Recast's serialized Detour polygons and links. * Surface projection continues to use the original detail triangles, while * A* follows only the directed links authored by Detour. */ export function buildPartyNavigationGraphFromDetourTopology( geometry: BufferGeometry, rawTopology: unknown, ): PartyNavigationGraph { if (!rawTopology || typeof rawTopology !== "object") { throw new Error("Party navigation Detour topology must be an object."); } const topology = rawTopology as Partial & { readonly unrepresentedLinkCount?: number; }; if (topology.schemaVersion !== 1 || topology.kind !== "recast-detour-surface-links") { throw new Error("Party navigation Detour topology has an unsupported schema."); } const polygonCount = topology.polygonCount; const detailTriangleCount = topology.detailTriangleCount; const geometryTriangleCount = topology.geometryTriangleCount; const directedLinkCount = topology.directedLinkCount; if ( typeof polygonCount !== "number" || !Number.isInteger(polygonCount) || polygonCount <= 0 || polygonCount > MAX_PARTY_NAVIGATION_DETOUR_POLYGONS ) { throw new Error(`Party navigation Detour topology exceeds the ${MAX_PARTY_NAVIGATION_DETOUR_POLYGONS}-polygon limit.`); } if ( typeof directedLinkCount !== "number" || !Number.isInteger(directedLinkCount) || directedLinkCount < 0 || directedLinkCount > MAX_PARTY_NAVIGATION_DETOUR_LINKS ) { throw new Error(`Party navigation Detour topology exceeds the ${MAX_PARTY_NAVIGATION_DETOUR_LINKS}-link limit.`); } if ( typeof detailTriangleCount !== "number" || !Number.isInteger(detailTriangleCount) || detailTriangleCount <= 0 || detailTriangleCount > MAX_PARTY_NAVIGATION_DETAIL_TRIANGLES || typeof geometryTriangleCount !== "number" || !Number.isInteger(geometryTriangleCount) || geometryTriangleCount !== detailTriangleCount ) { throw new Error(`Party navigation Detour detail geometry exceeds the ${MAX_PARTY_NAVIGATION_DETAIL_TRIANGLES}-triangle limit or has inconsistent counts.`); } if ( !Array.isArray(topology.polygons) || topology.polygons.length !== polygonCount || !Array.isArray(topology.links) || topology.links.length !== directedLinkCount || topology.unrepresentedLinkCount !== 0 ) { throw new Error("Party navigation Detour topology records are incomplete."); } const position = geometry.getAttribute("position"); if (!position || position.itemSize < 3 || position.count < 3) { throw new Error("Party navigation detail geometry requires at least three 3D positions."); } const index = geometry.getIndex(); const indexCount = index?.count ?? position.count; if (indexCount % 3 !== 0 || indexCount / 3 !== geometryTriangleCount) { throw new Error("Party navigation Detour topology does not match the detail triangle geometry."); } const polygonByTriangle = new Int32Array(detailTriangleCount); polygonByTriangle.fill(-1); const polygonVertices: number[] = []; for (let polygonId = 0; polygonId < polygonCount; polygonId += 1) { const record = topology.polygons[polygonId]; if (!Array.isArray(record) || record.length !== 9) { throw new Error(`Party navigation Detour polygon ${polygonId} has an invalid record.`); } const id = topologyInteger(record, 0, `polygon ${polygonId} id`); const tileIndex = topologyInteger(record, 1, `polygon ${polygonId} tile`); topologyInteger(record, 2, `polygon ${polygonId} tile x`, Number.MIN_SAFE_INTEGER); topologyInteger(record, 3, `polygon ${polygonId} tile y`, Number.MIN_SAFE_INTEGER); topologyInteger(record, 4, `polygon ${polygonId} tile layer`, Number.MIN_SAFE_INTEGER); topologyInteger(record, 5, `polygon ${polygonId} tile polygon`); const firstTriangle = topologyInteger(record, 6, `polygon ${polygonId} first triangle`); const triangleCount = topologyInteger(record, 7, `polygon ${polygonId} triangle count`); const vertexCount = topologyInteger(record, 8, `polygon ${polygonId} vertex count`); if ( id !== polygonId || tileIndex < 0 || triangleCount <= 0 || vertexCount < 3 || vertexCount > 12 || firstTriangle + triangleCount > detailTriangleCount ) { throw new Error(`Party navigation Detour polygon ${polygonId} has an invalid detail range.`); } for (let triangleId = firstTriangle; triangleId < firstTriangle + triangleCount; triangleId += 1) { if (polygonByTriangle[triangleId] !== -1) { throw new Error(`Party navigation Detour detail triangle ${triangleId} is assigned more than once.`); } polygonByTriangle[triangleId] = polygonId; } polygonVertices.push(vertexCount); } for (let triangleId = 0; triangleId < detailTriangleCount; triangleId += 1) { if (polygonByTriangle[triangleId] < 0) { throw new Error(`Party navigation Detour detail triangle ${triangleId} has no source polygon.`); } } const trianglePositions = new Float32Array(detailTriangleCount * 9); const weightedCenters = new Float64Array(polygonCount * 4); const triangleIndex = (offset: number): number => { const value = index ? index.getX(offset) : offset; if (!Number.isInteger(value) || value < 0 || value >= position.count) { throw new Error(`Party navigation detail index ${offset} references an invalid vertex.`); } return value; }; const bucketSize = PARTY_NAVIGATION_SURFACE_BUCKET_SIZE; const buckets = new Map(); let bucketReferences = 0; for (let triangleId = 0; triangleId < detailTriangleCount; triangleId += 1) { const positionOffset = triangleId * 9; const sourceOffset = triangleId * 3; const vertexIds = [ triangleIndex(sourceOffset), triangleIndex(sourceOffset + 1), triangleIndex(sourceOffset + 2), ]; const points = vertexIds.map((vertexId): PartyNavigationPosition => { const point: PartyNavigationPosition = [ position.getX(vertexId), position.getY(vertexId), position.getZ(vertexId), ]; if (!point.every(Number.isFinite)) { throw new Error(`Party navigation detail triangle ${triangleId} has a non-finite vertex.`); } return point; }); for (let vertex = 0; vertex < 3; vertex += 1) { trianglePositions[positionOffset + vertex * 3] = points[vertex][0]; trianglePositions[positionOffset + vertex * 3 + 1] = points[vertex][1]; trianglePositions[positionOffset + vertex * 3 + 2] = points[vertex][2]; } const [a, b, c] = points; const ab: PartyNavigationPosition = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; const ac: PartyNavigationPosition = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]; const crossX = ab[1] * ac[2] - ab[2] * ac[1]; const crossY = ab[2] * ac[0] - ab[0] * ac[2]; const crossZ = ab[0] * ac[1] - ab[1] * ac[0]; const area = Math.max(1e-12, Math.hypot(crossX, crossY, crossZ) * 0.5); const polygonId = polygonByTriangle[triangleId]; const centerOffset = polygonId * 4; weightedCenters[centerOffset] += ((a[0] + b[0] + c[0]) / 3) * area; weightedCenters[centerOffset + 1] += ((a[1] + b[1] + c[1]) / 3) * area; weightedCenters[centerOffset + 2] += ((a[2] + b[2] + c[2]) / 3) * area; weightedCenters[centerOffset + 3] += area; const minCellX = Math.floor(Math.min(a[0], b[0], c[0]) / bucketSize); const maxCellX = Math.floor(Math.max(a[0], b[0], c[0]) / bucketSize); const minCellZ = Math.floor(Math.min(a[2], b[2], c[2]) / bucketSize); const maxCellZ = Math.floor(Math.max(a[2], b[2], c[2]) / bucketSize); const references = (maxCellX - minCellX + 1) * (maxCellZ - minCellZ + 1); if ( !Number.isSafeInteger(references) || references <= 0 || bucketReferences + references > MAX_PARTY_NAVIGATION_SURFACE_BUCKET_REFERENCES ) { throw new Error("Party navigation Detour detail surface exceeds the spatial-index budget."); } bucketReferences += references; for (let cellX = minCellX; cellX <= maxCellX; cellX += 1) { for (let cellZ = minCellZ; cellZ <= maxCellZ; cellZ += 1) { const key = bucketKey(cellX, cellZ, 0); const contents = buckets.get(key); if (contents) contents.push(triangleId); else buckets.set(key, [triangleId]); } } } const centers: PartyNavigationPosition[] = []; const weakNeighbors = Array.from({ length: polygonCount }, () => new Set()); const outgoing: Array = Array.from({ length: polygonCount }, () => []); for (let polygonId = 0; polygonId < polygonCount; polygonId += 1) { const offset = polygonId * 4; const weight = weightedCenters[offset + 3]; if (!Number.isFinite(weight) || weight <= 0) { throw new Error(`Party navigation Detour polygon ${polygonId} has no usable detail surface.`); } centers.push(Object.freeze([ weightedCenters[offset] / weight, weightedCenters[offset + 1] / weight, weightedCenters[offset + 2] / weight, ]) as PartyNavigationPosition); } for (let linkId = 0; linkId < directedLinkCount; linkId += 1) { const link = topology.links[linkId]; if (!Array.isArray(link) || link.length !== 14) { throw new Error(`Party navigation Detour link ${linkId} has an invalid record.`); } const from = topologyInteger(link, 0, `link ${linkId} source`); const to = topologyInteger(link, 1, `link ${linkId} destination`); const edge = topologyInteger(link, 2, `link ${linkId} edge`); const side = topologyInteger(link, 3, `link ${linkId} side`); const bmin = topologyInteger(link, 4, `link ${linkId} portal start`); const bmax = topologyInteger(link, 5, `link ${linkId} portal end`); topologyInteger(link, 6, `link ${linkId} first vertex`); topologyInteger(link, 7, `link ${linkId} second vertex`); if ( from >= polygonCount || to >= polygonCount || edge >= polygonVertices[from] || (side > 7 && side !== 255) || bmin > 255 || bmax > 255 || bmin > bmax ) { throw new Error(`Party navigation Detour link ${linkId} references an invalid source portal.`); } const start: PartyNavigationPosition = [ topologyRecordNumber(link, 8, `link ${linkId} start x`), topologyRecordNumber(link, 9, `link ${linkId} start y`), topologyRecordNumber(link, 10, `link ${linkId} start z`), ]; const end: PartyNavigationPosition = [ topologyRecordNumber(link, 11, `link ${linkId} end x`), topologyRecordNumber(link, 12, `link ${linkId} end y`), topologyRecordNumber(link, 13, `link ${linkId} end z`), ]; // Detour uses side=0xff for an internal full-edge link. Boundary links // encode the traversable edge interval as bmin/bmax in byte coordinates. const ratio = side === 255 ? 0.5 : (bmin + bmax) / 510; const portal = Object.freeze([ start[0] + (end[0] - start[0]) * ratio, start[1] + (end[1] - start[1]) * ratio, start[2] + (end[2] - start[2]) * ratio, ]) as PartyNavigationPosition; outgoing[from].push(Object.freeze({ to, cost: distance(centers[from], centers[to]), point: portal, })); weakNeighbors[from].add(to); weakNeighbors[to].add(from); } const nodes = centers.map((center, id): PartyNavigationNode => Object.freeze({ id, position: center, neighbors: Object.freeze(outgoing[id].sort((left, right) => ( left.to - right.to || (left.point?.[0] ?? 0) - (right.point?.[0] ?? 0) || (left.point?.[1] ?? 0) - (right.point?.[1] ?? 0) || (left.point?.[2] ?? 0) - (right.point?.[2] ?? 0) ))), })); const visited = new Uint8Array(polygonCount); let componentCount = 0; for (let seed = 0; seed < polygonCount; seed += 1) { if (visited[seed]) continue; componentCount += 1; visited[seed] = 1; const pending = [seed]; while (pending.length) { const current = pending.pop()!; for (const neighbor of weakNeighbors[current]) { if (visited[neighbor]) continue; visited[neighbor] = 1; pending.push(neighbor); } } } const detourSurface: PartyNavigationDetourSurface = Object.freeze({ positions: trianglePositions, polygonByTriangle: Uint32Array.from(polygonByTriangle), bucketSize, buckets: new Map(Array.from(buckets, ([key, values]) => [key, Object.freeze(values)])), }); return Object.freeze({ nodes: Object.freeze(nodes), detourSurface, weldTolerance: PARTY_NAVIGATION_WELD_TOLERANCE, sourceVertexCount: position.count, sourceTriangleCount: geometryTriangleCount, weldedNodeCount: polygonCount, componentCount, discardedNodeCount: 0, }); } export function findNearestPartyNavigationNode( graph: PartyNavigationGraph, point: PartyNavigationPoint, maxDistance = Number.POSITIVE_INFINITY, ): PartyNavigationNearestNode | null { const target = pointComponents(point); const limit = Number.isFinite(maxDistance) ? Math.max(0, maxDistance) : Number.POSITIVE_INFINITY; let nearest: PartyNavigationNode | null = null; let nearestDistanceSquared = limit * limit; for (const node of graph.nodes) { const candidateDistanceSquared = distanceSquared(target, node.position); if ( candidateDistanceSquared < nearestDistanceSquared - DISTANCE_EPSILON || (Math.abs(candidateDistanceSquared - nearestDistanceSquared) <= DISTANCE_EPSILON && (nearest === null || node.id < nearest.id)) ) { nearest = node; nearestDistanceSquared = candidateDistanceSquared; } } return nearest ? Object.freeze({ node: nearest, distance: Math.sqrt(nearestDistanceSquared) }) : null; } function closestPointOnSegment( point: PartyNavigationPosition, start: PartyNavigationPosition, end: PartyNavigationPosition, ): PartyNavigationPosition { const segmentX = end[0] - start[0]; const segmentY = end[1] - start[1]; const segmentZ = end[2] - start[2]; const lengthSquared = segmentX * segmentX + segmentY * segmentY + segmentZ * segmentZ; if (lengthSquared <= DISTANCE_EPSILON) return [start[0], start[1], start[2]]; const projection = Math.max(0, Math.min(1, ( (point[0] - start[0]) * segmentX + (point[1] - start[1]) * segmentY + (point[2] - start[2]) * segmentZ ) / lengthSquared)); return [ start[0] + segmentX * projection, start[1] + segmentY * projection, start[2] + segmentZ * projection, ]; } /** Returns the closest point on a filled 3D triangle, including its edges. */ function closestPointOnTriangle( point: PartyNavigationPosition, a: PartyNavigationPosition, b: PartyNavigationPosition, c: PartyNavigationPosition, ): PartyNavigationPosition { const ab: PartyNavigationPosition = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; const ac: PartyNavigationPosition = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]; const crossX = ab[1] * ac[2] - ab[2] * ac[1]; const crossY = ab[2] * ac[0] - ab[0] * ac[2]; const crossZ = ab[0] * ac[1] - ab[1] * ac[0]; if (crossX * crossX + crossY * crossY + crossZ * crossZ <= DISTANCE_EPSILON) { const candidates = [ closestPointOnSegment(point, a, b), closestPointOnSegment(point, b, c), closestPointOnSegment(point, c, a), ]; return candidates.reduce((best, candidate) => ( distanceSquared(point, candidate) < distanceSquared(point, best) - DISTANCE_EPSILON ? candidate : best )); } const ap: PartyNavigationPosition = [point[0] - a[0], point[1] - a[1], point[2] - a[2]]; const d1 = ab[0] * ap[0] + ab[1] * ap[1] + ab[2] * ap[2]; const d2 = ac[0] * ap[0] + ac[1] * ap[1] + ac[2] * ap[2]; if (d1 <= 0 && d2 <= 0) return [a[0], a[1], a[2]]; const bp: PartyNavigationPosition = [point[0] - b[0], point[1] - b[1], point[2] - b[2]]; const d3 = ab[0] * bp[0] + ab[1] * bp[1] + ab[2] * bp[2]; const d4 = ac[0] * bp[0] + ac[1] * bp[1] + ac[2] * bp[2]; if (d3 >= 0 && d4 <= d3) return [b[0], b[1], b[2]]; const vc = d1 * d4 - d3 * d2; if (vc <= 0 && d1 >= 0 && d3 <= 0) { const along = d1 / (d1 - d3); return [a[0] + ab[0] * along, a[1] + ab[1] * along, a[2] + ab[2] * along]; } const cp: PartyNavigationPosition = [point[0] - c[0], point[1] - c[1], point[2] - c[2]]; const d5 = ab[0] * cp[0] + ab[1] * cp[1] + ab[2] * cp[2]; const d6 = ac[0] * cp[0] + ac[1] * cp[1] + ac[2] * cp[2]; if (d6 >= 0 && d5 <= d6) return [c[0], c[1], c[2]]; const vb = d5 * d2 - d1 * d6; if (vb <= 0 && d2 >= 0 && d6 <= 0) { const along = d2 / (d2 - d6); return [a[0] + ac[0] * along, a[1] + ac[1] * along, a[2] + ac[2] * along]; } const va = d3 * d6 - d5 * d4; if (va <= 0 && d4 - d3 >= 0 && d5 - d6 >= 0) { const along = (d4 - d3) / ((d4 - d3) + (d5 - d6)); return [ b[0] + (c[0] - b[0]) * along, b[1] + (c[1] - b[1]) * along, b[2] + (c[2] - b[2]) * along, ]; } const denominator = 1 / (va + vb + vc); const alongB = vb * denominator; const alongC = vc * denominator; return [ a[0] + ab[0] * alongB + ac[0] * alongC, a[1] + ab[1] * alongB + ac[1] * alongC, a[2] + ab[2] * alongB + ac[2] * alongC, ]; } export function findNearestPartyNavigationSurface( graph: PartyNavigationGraph, point: PartyNavigationPoint, maxDistance = Number.POSITIVE_INFINITY, ): PartyNavigationSurfaceMatch | null { if (graph.detourSurface) { const target = pointComponents(point); const limit = Number.isFinite(maxDistance) ? Math.max(0, maxDistance) : Number.POSITIVE_INFINITY; const surface = graph.detourSurface; const candidates: number[] = []; if (!Number.isFinite(limit)) { for (let triangleId = 0; triangleId < surface.polygonByTriangle.length; triangleId += 1) { candidates.push(triangleId); } } else { const minCellX = Math.floor((target[0] - limit) / surface.bucketSize); const maxCellX = Math.floor((target[0] + limit) / surface.bucketSize); const minCellZ = Math.floor((target[2] - limit) / surface.bucketSize); const maxCellZ = Math.floor((target[2] + limit) / surface.bucketSize); const cellCount = (maxCellX - minCellX + 1) * (maxCellZ - minCellZ + 1); if (!Number.isSafeInteger(cellCount) || cellCount > 4_096) { for (let triangleId = 0; triangleId < surface.polygonByTriangle.length; triangleId += 1) { candidates.push(triangleId); } } else { const found = new Set(); for (let cellX = minCellX; cellX <= maxCellX; cellX += 1) { for (let cellZ = minCellZ; cellZ <= maxCellZ; cellZ += 1) { for (const triangleId of surface.buckets.get(bucketKey(cellX, cellZ, 0)) ?? []) { found.add(triangleId); } } } candidates.push(...Array.from(found).sort((left, right) => left - right)); } } let nearestPosition: PartyNavigationPosition | null = null; let nearestTriangleId = -1; let nearestDistanceSquared = limit * limit; for (const triangleId of candidates) { const offset = triangleId * 9; const candidate = closestPointOnTriangle( target, [surface.positions[offset], surface.positions[offset + 1], surface.positions[offset + 2]], [surface.positions[offset + 3], surface.positions[offset + 4], surface.positions[offset + 5]], [surface.positions[offset + 6], surface.positions[offset + 7], surface.positions[offset + 8]], ); const candidateDistanceSquared = distanceSquared(target, candidate); if ( candidateDistanceSquared < nearestDistanceSquared - DISTANCE_EPSILON || (Math.abs(candidateDistanceSquared - nearestDistanceSquared) <= DISTANCE_EPSILON && (nearestTriangleId < 0 || triangleId < nearestTriangleId)) ) { nearestPosition = candidate; nearestTriangleId = triangleId; nearestDistanceSquared = candidateDistanceSquared; } } return nearestPosition ? Object.freeze({ position: Object.freeze([...nearestPosition]) as PartyNavigationPosition, triangleId: nearestTriangleId, polygonId: surface.polygonByTriangle[nearestTriangleId], distance: Math.sqrt(nearestDistanceSquared), }) : null; } const triangles = graph.triangles; if (!triangles?.length) { const nearest = findNearestPartyNavigationNode(graph, point, maxDistance); return nearest ? Object.freeze({ position: nearest.node.position, triangleId: -1, distance: nearest.distance, }) : null; } const target = pointComponents(point); const limit = Number.isFinite(maxDistance) ? Math.max(0, maxDistance) : Number.POSITIVE_INFINITY; let nearestPosition: PartyNavigationPosition | null = null; let nearestTriangleId = -1; let nearestDistanceSquared = limit * limit; for (let triangleId = 0; triangleId < triangles.length; triangleId += 1) { const [a, b, c] = triangles[triangleId]; const candidate = closestPointOnTriangle( target, graph.nodes[a].position, graph.nodes[b].position, graph.nodes[c].position, ); const candidateDistanceSquared = distanceSquared(target, candidate); if ( candidateDistanceSquared < nearestDistanceSquared - DISTANCE_EPSILON || (Math.abs(candidateDistanceSquared - nearestDistanceSquared) <= DISTANCE_EPSILON && (nearestTriangleId < 0 || triangleId < nearestTriangleId)) ) { nearestPosition = candidate; nearestTriangleId = triangleId; nearestDistanceSquared = candidateDistanceSquared; } } return nearestPosition ? Object.freeze({ position: Object.freeze([...nearestPosition]) as PartyNavigationPosition, triangleId: nearestTriangleId, distance: Math.sqrt(nearestDistanceSquared), }) : null; } function pointToSegmentDistance( point: PartyNavigationPosition, start: PartyNavigationPosition, end: PartyNavigationPosition, ): number { return distance(point, closestPointOnSegment(point, start, end)); } /** Removes duplicate and effectively-collinear points without cutting corners. */ export function simplifyPartyNavigationPath( points: readonly PartyNavigationPoint[], tolerance = PARTY_NAVIGATION_WELD_TOLERANCE, ): readonly PartyNavigationPosition[] { const safeTolerance = Number.isFinite(tolerance) ? Math.max(0, tolerance) : 0; const simplified: PartyNavigationPosition[] = []; for (const rawPoint of points) { const point = Object.freeze([...pointComponents(rawPoint)]) as PartyNavigationPosition; if (simplified.length && distance(simplified[simplified.length - 1], point) <= safeTolerance) continue; simplified.push(point); while (simplified.length >= 3) { const end = simplified[simplified.length - 1]; const middle = simplified[simplified.length - 2]; const start = simplified[simplified.length - 3]; if (pointToSegmentDistance(middle, start, end) > safeTolerance) break; simplified.splice(simplified.length - 2, 1); } } return Object.freeze(simplified); } function compareOpenEntries(left: OpenEntry, right: OpenEntry): number { return left.f - right.f || left.h - right.h || left.id - right.id; } class OpenHeap { private readonly entries: OpenEntry[] = []; get length(): number { return this.entries.length; } push(entry: OpenEntry): void { const entries = this.entries; entries.push(entry); let index = entries.length - 1; while (index > 0) { const parent = Math.floor((index - 1) / 2); if (compareOpenEntries(entries[parent], entry) <= 0) break; entries[index] = entries[parent]; index = parent; } entries[index] = entry; } pop(): OpenEntry | null { const entries = this.entries; if (!entries.length) return null; const first = entries[0]; const last = entries.pop()!; if (entries.length) { let index = 0; while (true) { const left = index * 2 + 1; if (left >= entries.length) break; const right = left + 1; const bestChild = right < entries.length && compareOpenEntries(entries[right], entries[left]) < 0 ? right : left; if (compareOpenEntries(entries[bestChild], last) >= 0) break; entries[index] = entries[bestChild]; index = bestChild; } entries[index] = last; } return first; } } function findPartyNavigationNodePath( graph: PartyNavigationGraph, startId: number, endId: number, blocked?: Uint8Array, ): readonly number[] | null { if (blocked?.[startId] || blocked?.[endId]) return null; if (startId === endId) return Object.freeze([startId]); const nodeCount = graph.nodes.length; const cameFrom = new Int32Array(nodeCount); cameFrom.fill(-1); const scores = new Float64Array(nodeCount); scores.fill(Number.POSITIVE_INFINITY); scores[startId] = 0; const closed = new Uint8Array(nodeCount); const open = new OpenHeap(); const initialHeuristic = distance(graph.nodes[startId].position, graph.nodes[endId].position); open.push({ id: startId, g: 0, h: initialHeuristic, f: initialHeuristic }); while (open.length) { const current = open.pop()!; if (closed[current.id] || current.g > scores[current.id] + DISTANCE_EPSILON) continue; if (current.id === endId) break; closed[current.id] = 1; for (const edge of graph.nodes[current.id].neighbors) { if (blocked?.[edge.to] || closed[edge.to]) continue; const tentative = scores[current.id] + edge.cost; if (tentative >= scores[edge.to] - DISTANCE_EPSILON) continue; cameFrom[edge.to] = current.id; scores[edge.to] = tentative; const heuristic = distance(graph.nodes[edge.to].position, graph.nodes[endId].position); open.push({ id: edge.to, g: tentative, h: heuristic, f: tentative + heuristic }); } } if (cameFrom[endId] < 0) return null; const nodeIds = [endId]; let cursor = endId; while (cursor !== startId && nodeIds.length <= nodeCount) { cursor = cameFrom[cursor]; if (cursor < 0) return null; nodeIds.push(cursor); } if (cursor !== startId) return null; nodeIds.reverse(); return Object.freeze(nodeIds); } function findPartyNavigationTrianglePath( triangleNodes: readonly PartyNavigationTriangleNode[], startId: number, endId: number, blocked?: Uint8Array, ): readonly number[] | null { if (blocked?.[startId] || blocked?.[endId]) return null; if (startId === endId) return Object.freeze([startId]); const nodeCount = triangleNodes.length; const cameFrom = new Int32Array(nodeCount); cameFrom.fill(-1); const scores = new Float64Array(nodeCount); scores.fill(Number.POSITIVE_INFINITY); scores[startId] = 0; const closed = new Uint8Array(nodeCount); const open = new OpenHeap(); const initialHeuristic = distance(triangleNodes[startId].centroid, triangleNodes[endId].centroid); open.push({ id: startId, g: 0, h: initialHeuristic, f: initialHeuristic }); while (open.length) { const current = open.pop()!; if (closed[current.id] || current.g > scores[current.id] + DISTANCE_EPSILON) continue; if (current.id === endId) break; closed[current.id] = 1; for (const edge of triangleNodes[current.id].neighbors) { if (blocked?.[edge.to] || closed[edge.to]) continue; const tentative = scores[current.id] + edge.cost; if (tentative >= scores[edge.to] - DISTANCE_EPSILON) continue; cameFrom[edge.to] = current.id; scores[edge.to] = tentative; const heuristic = distance(triangleNodes[edge.to].centroid, triangleNodes[endId].centroid); open.push({ id: edge.to, g: tentative, h: heuristic, f: tentative + heuristic }); } } if (cameFrom[endId] < 0) return null; const triangleIds = [endId]; let cursor = endId; while (cursor !== startId && triangleIds.length <= nodeCount) { cursor = cameFrom[cursor]; if (cursor < 0) return null; triangleIds.push(cursor); } if (cursor !== startId) return null; triangleIds.reverse(); return Object.freeze(triangleIds); } /** Compatibility for hand-authored graphs that predate retained triangle data. */ function findPartyNavigationNodeSnappedPath( graph: PartyNavigationGraph, start: PartyNavigationPoint, end: PartyNavigationPoint, options: PartyNavigationPathOptions, ): readonly PartyNavigationPosition[] | null { const maxSnapDistance = options.maxSnapDistance ?? Number.POSITIVE_INFINITY; const startMatch = findNearestPartyNavigationNode( graph, start, options.maxStartSnapDistance ?? maxSnapDistance, ); const endMatch = findNearestPartyNavigationNode( graph, end, options.maxEndSnapDistance ?? maxSnapDistance, ); if (!startMatch || !endMatch) return null; const nodeIds = findPartyNavigationNodePath(graph, startMatch.node.id, endMatch.node.id); return nodeIds ? simplifyPartyNavigationPath( nodeIds.map((id) => graph.nodes[id].position), options.simplifyTolerance ?? graph.weldTolerance, ) : null; } /** * Projects both endpoints to the retained surface, runs deterministic A* over * triangle centroids, and crosses each shared edge through its midpoint. */ export function findPartyNavigationPath( graph: PartyNavigationGraph, start: PartyNavigationPoint, end: PartyNavigationPoint, options: PartyNavigationPathOptions = {}, ): readonly PartyNavigationPosition[] | null { if (graph.detourSurface) { const maxSnapDistance = options.maxSnapDistance ?? Number.POSITIVE_INFINITY; const startMatch = findNearestPartyNavigationSurface( graph, start, options.maxStartSnapDistance ?? maxSnapDistance, ); const endMatch = findNearestPartyNavigationSurface( graph, end, options.maxEndSnapDistance ?? maxSnapDistance, ); if ( !startMatch || !endMatch || startMatch.polygonId === undefined || endMatch.polygonId === undefined ) return null; const blocked = new Uint8Array(graph.nodes.length); const maximumReroutes = Math.min(64, graph.nodes.length); for (let reroute = 0; reroute <= maximumReroutes; reroute += 1) { const polygonIds = findPartyNavigationNodePath( graph, startMatch.polygonId, endMatch.polygonId, blocked, ); if (!polygonIds) return null; const path: PartyNavigationPosition[] = [startMatch.position]; let missingPortal = false; for (let index = 0; index < polygonIds.length - 1; index += 1) { const edge = graph.nodes[polygonIds[index]].neighbors.find((candidate) => ( candidate.to === polygonIds[index + 1] && candidate.point !== undefined )); if (!edge?.point) { missingPortal = true; break; } path.push(edge.point); } if (missingPortal) return null; path.push(endMatch.position); let rejectedSegment = -1; if (options.routeSegmentAllowed) { for (let index = 0; index < path.length - 1; index += 1) { if (!options.routeSegmentAllowed(path[index], path[index + 1])) { rejectedSegment = index; break; } } } if (rejectedSegment < 0) return Object.freeze(path); if (polygonIds.length <= 1 || reroute >= maximumReroutes) return null; const blockedPolygonId = rejectedSegment === 0 ? polygonIds[1] : rejectedSegment === polygonIds.length - 1 ? polygonIds[polygonIds.length - 2] : polygonIds[rejectedSegment]; if (blocked[blockedPolygonId]) return null; blocked[blockedPolygonId] = 1; } return null; } if (!graph.triangles?.length) { return findPartyNavigationNodeSnappedPath(graph, start, end, options); } const maxSnapDistance = options.maxSnapDistance ?? Number.POSITIVE_INFINITY; const startMatch = findNearestPartyNavigationSurface( graph, start, options.maxStartSnapDistance ?? maxSnapDistance, ); const endMatch = findNearestPartyNavigationSurface( graph, end, options.maxEndSnapDistance ?? maxSnapDistance, ); if (!startMatch || !endMatch) return null; const triangleNodes = graph.triangleNodes?.length === graph.triangles.length ? graph.triangleNodes : buildPartyNavigationTriangleNodes( graph.triangles, graph.nodes.map((node) => node.position), ); const blocked = new Uint8Array(triangleNodes.length); const maximumReroutes = Math.min(64, triangleNodes.length); for (let reroute = 0; reroute <= maximumReroutes; reroute += 1) { const triangleIds = findPartyNavigationTrianglePath( triangleNodes, startMatch.triangleId, endMatch.triangleId, blocked, ); if (!triangleIds) return null; const path: PartyNavigationPosition[] = [startMatch.position]; for (let index = 0; index < triangleIds.length - 1; index += 1) { const from = triangleIds[index]; const to = triangleIds[index + 1]; const transition = triangleNodes[from].neighbors.find((edge) => edge.to === to); if (!transition) return null; const [a, b] = transition.portal; const left = graph.nodes[a].position; const right = graph.nodes[b].position; path.push(Object.freeze([ (left[0] + right[0]) / 2, (left[1] + right[1]) / 2, (left[2] + right[2]) / 2, ]) as PartyNavigationPosition); } path.push(endMatch.position); let rejectedSegment = -1; if (options.routeSegmentAllowed) { for (let index = 0; index < path.length - 1; index += 1) { if (!options.routeSegmentAllowed(path[index], path[index + 1])) { rejectedSegment = index; break; } } } if (rejectedSegment < 0) return Object.freeze(path); if (triangleIds.length <= 1 || reroute >= maximumReroutes) return null; const blockedTriangleId = rejectedSegment === 0 ? triangleIds[1] : rejectedSegment === triangleIds.length - 1 ? triangleIds[triangleIds.length - 2] : triangleIds[rejectedSegment]; if (blocked[blockedTriangleId]) return null; blocked[blockedTriangleId] = 1; } return null; }