220 lines
7.4 KiB
JavaScript
220 lines
7.4 KiB
JavaScript
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,
|
|
};
|
|
}
|