823 lines
32 KiB
JavaScript
823 lines
32 KiB
JavaScript
import { mkdir, readFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { Document, NodeIO } from "@gltf-transform/core";
|
|
import {
|
|
freeCompactHeightfield,
|
|
freeContourSet,
|
|
freeHeightfield,
|
|
freePolyMesh,
|
|
freePolyMeshDetail,
|
|
init,
|
|
NavMeshQuery,
|
|
Recast,
|
|
} from "@recast-navigation/core";
|
|
import { threeToSoloNavMesh, threeToTiledNavMesh, NavMeshHelper } from "@recast-navigation/three";
|
|
import { Vector3 } from "three";
|
|
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
|
import { MeshoptDecoder } from "meshoptimizer";
|
|
import { unpackMeshesForRecast } from "./recast-mesh-adapter.mjs";
|
|
import {
|
|
exists,
|
|
fixturesRoot,
|
|
loadRecipe,
|
|
projectRoot,
|
|
readJson,
|
|
relativeProjectPath,
|
|
sha256,
|
|
workRoot,
|
|
writeJson,
|
|
} from "./lib.mjs";
|
|
import {
|
|
nextTiledNavMeshSize,
|
|
planNavigationBuild,
|
|
shouldRetryFailedTiledBuildWithMonotone,
|
|
shouldUseMonotonePartition,
|
|
validateTiledBuild,
|
|
} from "./recast-policy.mjs";
|
|
import { threeToMonotoneTiledNavMesh } from "./recast-monotone-fallback.mjs";
|
|
import {
|
|
buildDetourGeometryRanges,
|
|
extractDetourTopology,
|
|
stableSha256,
|
|
} from "./detour-topology.mjs";
|
|
import {
|
|
countSurfaceBucketReferences,
|
|
DETOUR_COMPONENT_SELECTION_LIMITS,
|
|
selectDetourAnchorComponents,
|
|
} from "./detour-component-selection.mjs";
|
|
|
|
globalThis.self = globalThis;
|
|
globalThis.ProgressEvent ??= class ProgressEvent {
|
|
constructor(type, values = {}) { this.type = type; Object.assign(this, values); }
|
|
};
|
|
globalThis.createImageBitmap ??= async () => ({ width: 1, height: 1, close() {} });
|
|
|
|
async function loadScene(file) {
|
|
await MeshoptDecoder.ready;
|
|
const bytes = await readFile(file);
|
|
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
return new Promise((resolve, reject) => new GLTFLoader().setMeshoptDecoder(MeshoptDecoder).parse(buffer, "", (gltf) => resolve(gltf.scene), reject));
|
|
}
|
|
|
|
function meshesIn(scene) {
|
|
scene.updateMatrixWorld(true);
|
|
const meshes = [];
|
|
scene.traverse((object) => {
|
|
if (object.isMesh && object.geometry?.getAttribute("position")) meshes.push(object);
|
|
});
|
|
return meshes;
|
|
}
|
|
|
|
function inspectNavigationInput(meshes) {
|
|
const minimum = [Infinity, Infinity, Infinity];
|
|
const maximum = [-Infinity, -Infinity, -Infinity];
|
|
const a = new Vector3();
|
|
const b = new Vector3();
|
|
const c = new Vector3();
|
|
const edgeAb = new Vector3();
|
|
const edgeAc = new Vector3();
|
|
const normal = new Vector3();
|
|
let vertexCount = 0;
|
|
let triangleCount = 0;
|
|
let degenerateTriangleCount = 0;
|
|
let invalidIndexCount = 0;
|
|
let nonFiniteCoordinateCount = 0;
|
|
|
|
for (const mesh of meshes) {
|
|
const positions = mesh.geometry.getAttribute("position");
|
|
if (positions.itemSize !== 3) throw new Error(`${mesh.name || "collision mesh"}: POSITION must be VEC3.`);
|
|
const indices = mesh.geometry.getIndex();
|
|
const elementCount = indices?.count ?? positions.count;
|
|
if (elementCount % 3 !== 0) {
|
|
throw new Error(`${mesh.name || "collision mesh"}: triangle index count is not divisible by three.`);
|
|
}
|
|
vertexCount += positions.count;
|
|
triangleCount += elementCount / 3;
|
|
const vertexIndex = (element) => indices ? indices.getX(element) : element;
|
|
|
|
for (let element = 0; element < elementCount; element += 3) {
|
|
const triangleIndices = [
|
|
vertexIndex(element),
|
|
vertexIndex(element + 1),
|
|
vertexIndex(element + 2),
|
|
];
|
|
for (const index of triangleIndices) {
|
|
if (!Number.isInteger(index) || index < 0 || index >= positions.count) invalidIndexCount += 1;
|
|
}
|
|
if (triangleIndices.some((index) => !Number.isInteger(index) || index < 0 || index >= positions.count)) {
|
|
continue;
|
|
}
|
|
|
|
a.fromBufferAttribute(positions, triangleIndices[0]).applyMatrix4(mesh.matrixWorld);
|
|
b.fromBufferAttribute(positions, triangleIndices[1]).applyMatrix4(mesh.matrixWorld);
|
|
c.fromBufferAttribute(positions, triangleIndices[2]).applyMatrix4(mesh.matrixWorld);
|
|
for (const point of [a, b, c]) {
|
|
for (let axis = 0; axis < 3; axis += 1) {
|
|
const value = point.getComponent(axis);
|
|
if (!Number.isFinite(value)) {
|
|
nonFiniteCoordinateCount += 1;
|
|
} else {
|
|
minimum[axis] = Math.min(minimum[axis], value);
|
|
maximum[axis] = Math.max(maximum[axis], value);
|
|
}
|
|
}
|
|
}
|
|
normal.crossVectors(edgeAb.subVectors(b, a), edgeAc.subVectors(c, a));
|
|
if (normal.lengthSq() <= 1e-16) degenerateTriangleCount += 1;
|
|
}
|
|
}
|
|
|
|
if (triangleCount === 0) throw new Error("Collision input contains no triangles.");
|
|
if (invalidIndexCount) throw new Error(`Collision input contains ${invalidIndexCount} invalid triangle indices.`);
|
|
if (nonFiniteCoordinateCount) {
|
|
throw new Error(`Collision input contains ${nonFiniteCoordinateCount} non-finite world coordinates.`);
|
|
}
|
|
if (degenerateTriangleCount === triangleCount) throw new Error("Every collision triangle is degenerate.");
|
|
return {
|
|
bounds: [minimum, maximum],
|
|
meshCount: meshes.length,
|
|
vertexCount,
|
|
triangleCount,
|
|
degenerateTriangleCount,
|
|
invalidIndexCount,
|
|
nonFiniteCoordinateCount,
|
|
};
|
|
}
|
|
|
|
function releaseTiledIntermediates(intermediates) {
|
|
for (const tile of intermediates?.tileIntermediates ?? []) {
|
|
if (tile.heightfield) freeHeightfield(tile.heightfield);
|
|
if (tile.compactHeightfield) freeCompactHeightfield(tile.compactHeightfield);
|
|
if (tile.contourSet) freeContourSet(tile.contourSet);
|
|
if (tile.polyMesh) freePolyMesh(tile.polyMesh);
|
|
if (tile.polyMeshDetail) freePolyMeshDetail(tile.polyMeshDetail);
|
|
tile.heightfield = undefined;
|
|
tile.compactHeightfield = undefined;
|
|
tile.contourSet = undefined;
|
|
tile.polyMesh = undefined;
|
|
tile.polyMeshDetail = undefined;
|
|
}
|
|
}
|
|
|
|
function populatedNavMeshTileCount(navMesh) {
|
|
let populated = 0;
|
|
for (let index = 0; index < navMesh.getMaxTiles(); index += 1) {
|
|
const header = navMesh.getTile(index).header();
|
|
if (header && header.polyCount() > 0) populated += 1;
|
|
}
|
|
return populated;
|
|
}
|
|
|
|
function queryDetourRouteProbes(navMesh, probes) {
|
|
if (!probes.length) return [];
|
|
const query = new NavMeshQuery(navMesh);
|
|
try {
|
|
return probes.map((probe) => {
|
|
const startRadius = probe.maxStartSnapDistance ?? 3;
|
|
const endRadius = probe.maxEndSnapDistance ?? 6;
|
|
const start = query.findNearestPoly(
|
|
{ x: probe.start[0], y: probe.start[1], z: probe.start[2] },
|
|
{ halfExtents: { x: startRadius, y: startRadius, z: startRadius } },
|
|
);
|
|
const end = query.findNearestPoly(
|
|
{ x: probe.end[0], y: probe.end[1], z: probe.end[2] },
|
|
{ halfExtents: { x: endRadius, y: endRadius, z: endRadius } },
|
|
);
|
|
const pointDistance = (source, target) => Math.hypot(
|
|
source[0] - target.x,
|
|
source[1] - target.y,
|
|
source[2] - target.z,
|
|
);
|
|
const startFound = start.success && start.nearestRef !== 0;
|
|
const endFound = end.success && end.nearestRef !== 0;
|
|
const startDistance = startFound ? pointDistance(probe.start, start.nearestPoint) : null;
|
|
const endDistance = endFound ? pointDistance(probe.end, end.nearestPoint) : null;
|
|
if (!startFound || !endFound
|
|
|| startDistance > startRadius
|
|
|| endDistance > endRadius) {
|
|
return {
|
|
id: probe.id,
|
|
from: probe.from ?? null,
|
|
to: probe.to ?? null,
|
|
start: {
|
|
found: startFound,
|
|
polygonRef: startFound ? start.nearestRef : null,
|
|
distance: startDistance,
|
|
maximumDistance: startRadius,
|
|
status: start.status,
|
|
},
|
|
end: {
|
|
found: endFound,
|
|
polygonRef: endFound ? end.nearestRef : null,
|
|
distance: endDistance,
|
|
maximumDistance: endRadius,
|
|
status: end.status,
|
|
},
|
|
reachable: false,
|
|
reason: "endpoint-not-found-within-query-snap-limit",
|
|
};
|
|
}
|
|
|
|
const path = query.findPath(
|
|
start.nearestRef,
|
|
end.nearestRef,
|
|
start.nearestPoint,
|
|
end.nearestPoint,
|
|
{ maxPathPolys: 100_000 },
|
|
);
|
|
try {
|
|
const refs = path.polys.toTypedArray();
|
|
const usedRefs = [];
|
|
for (const ref of refs) {
|
|
if (ref === 0) break;
|
|
usedRefs.push(ref);
|
|
}
|
|
const complete = path.success
|
|
&& usedRefs.length > 0
|
|
&& usedRefs[usedRefs.length - 1] === end.nearestRef;
|
|
return {
|
|
id: probe.id,
|
|
from: probe.from ?? null,
|
|
to: probe.to ?? null,
|
|
start: {
|
|
found: true,
|
|
polygonRef: start.nearestRef,
|
|
point: [start.nearestPoint.x, start.nearestPoint.y, start.nearestPoint.z],
|
|
distance: startDistance,
|
|
maximumDistance: startRadius,
|
|
},
|
|
end: {
|
|
found: true,
|
|
polygonRef: end.nearestRef,
|
|
point: [end.nearestPoint.x, end.nearestPoint.y, end.nearestPoint.z],
|
|
distance: endDistance,
|
|
maximumDistance: endRadius,
|
|
},
|
|
reachable: complete,
|
|
partial: path.success && usedRefs.length > 0 && !complete,
|
|
status: path.status,
|
|
polygonCount: usedRefs.length,
|
|
// Raw Detour refs are useful for tracing this exact bake, but are not stable asset IDs.
|
|
polygonRefs: usedRefs,
|
|
};
|
|
} finally {
|
|
path.polys.destroy();
|
|
}
|
|
});
|
|
} finally {
|
|
query.destroy();
|
|
}
|
|
}
|
|
|
|
function mapDetourAnchorsToPolygons(navMesh, topology, anchors) {
|
|
if (!Array.isArray(anchors) || anchors.length === 0) return [];
|
|
const polygonIdByReference = new Map();
|
|
for (const [polygonId, tileIndex, , , , polygonIndex] of topology.polygons) {
|
|
const tile = navMesh.getTile(tileIndex);
|
|
const reference = navMesh.getPolyRefBase(tile) + polygonIndex;
|
|
polygonIdByReference.set(String(reference), polygonId);
|
|
}
|
|
const query = new NavMeshQuery(navMesh);
|
|
try {
|
|
return anchors.map((anchor) => {
|
|
if (!anchor || typeof anchor.id !== "string" || !anchor.id.trim()
|
|
|| !Array.isArray(anchor.position) || anchor.position.length !== 3
|
|
|| !anchor.position.every(Number.isFinite)
|
|
|| !Number.isFinite(anchor.maximumDistance) || anchor.maximumDistance <= 0) {
|
|
throw new Error("Detour source anchors require an ID, finite XYZ position, and positive maximumDistance.");
|
|
}
|
|
const radius = anchor.maximumDistance;
|
|
const nearest = query.findNearestPoly(
|
|
{ x: anchor.position[0], y: anchor.position[1], z: anchor.position[2] },
|
|
{ halfExtents: { x: radius, y: radius, z: radius } },
|
|
);
|
|
const found = nearest.success && String(nearest.nearestRef) !== "0";
|
|
if (!found) {
|
|
return {
|
|
...anchor,
|
|
id: anchor.id,
|
|
kind: anchor.kind ?? null,
|
|
position: [...anchor.position],
|
|
maximumDistance: radius,
|
|
polygonId: null,
|
|
distance: null,
|
|
nearestPoint: null,
|
|
reason: "no-detour-surface-within-source-projection-search-box",
|
|
};
|
|
}
|
|
const nearestPoint = [nearest.nearestPoint.x, nearest.nearestPoint.y, nearest.nearestPoint.z];
|
|
const distance = Math.hypot(
|
|
anchor.position[0] - nearestPoint[0],
|
|
anchor.position[1] - nearestPoint[1],
|
|
anchor.position[2] - nearestPoint[2],
|
|
);
|
|
const polygonId = polygonIdByReference.get(String(nearest.nearestRef));
|
|
if (polygonId === undefined) {
|
|
return {
|
|
...anchor,
|
|
id: anchor.id,
|
|
kind: anchor.kind ?? null,
|
|
position: [...anchor.position],
|
|
maximumDistance: radius,
|
|
polygonId: null,
|
|
distance,
|
|
nearestPoint,
|
|
reason: "nearest-detour-reference-is-not-a-topology-surface-polygon",
|
|
};
|
|
}
|
|
return {
|
|
...anchor,
|
|
id: anchor.id,
|
|
kind: anchor.kind ?? null,
|
|
position: [...anchor.position],
|
|
maximumDistance: radius,
|
|
polygonId,
|
|
distance,
|
|
nearestPoint,
|
|
};
|
|
});
|
|
} finally {
|
|
query.destroy();
|
|
}
|
|
}
|
|
|
|
async function writeGeometry(file, geometry, inputBounds, boundsTolerance, detourTopology = null, detourSelection = null) {
|
|
const sourcePositions = geometry.getAttribute("position")?.array;
|
|
const sourceIndices = geometry.getIndex()?.array;
|
|
if (!sourcePositions) throw new Error("Recast helper produced no position buffer.");
|
|
const sourceElementCount = sourceIndices?.length ?? sourcePositions.length / 3;
|
|
const sourceTopology = detourSelection?.sourceTopology ?? detourTopology;
|
|
const outputTopology = detourSelection?.topology ?? detourTopology;
|
|
if (sourceTopology && sourceTopology.detailTriangleCount !== sourceElementCount / 3) {
|
|
throw new Error(
|
|
`Detour topology describes ${sourceTopology.detailTriangleCount} detail triangles, `
|
|
+ `but the helper emitted ${sourceElementCount / 3}.`,
|
|
);
|
|
}
|
|
const [minimumInput, maximumInput] = inputBounds;
|
|
const compactPositions = [];
|
|
const compactIndices = [];
|
|
const exportedTrianglePolygonIds = outputTopology ? [] : null;
|
|
const remappedVertices = new Map();
|
|
let rejectedOutOfBoundsTriangles = 0;
|
|
|
|
const vertexWithinInputBounds = (index) => {
|
|
const offset = index * 3;
|
|
if (offset < 0 || offset + 2 >= sourcePositions.length) return false;
|
|
for (let axis = 0; axis < 3; axis += 1) {
|
|
const value = sourcePositions[offset + axis];
|
|
if (
|
|
!Number.isFinite(value)
|
|
|| value < minimumInput[axis] - boundsTolerance
|
|
|| value > maximumInput[axis] + boundsTolerance
|
|
) return false;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
const sourceTrianglePolygonIds = sourceTopology
|
|
? new Uint32Array(sourceElementCount / 3)
|
|
: null;
|
|
if (sourceTopology) {
|
|
for (const [polygonId, , , , , , firstTriangle, triangleCount] of sourceTopology.polygons) {
|
|
if (firstTriangle < 0 || triangleCount < 0 || firstTriangle + triangleCount > sourceTrianglePolygonIds.length) {
|
|
throw new Error(`Detour topology polygon ${polygonId} has an invalid detail-triangle range.`);
|
|
}
|
|
for (let triangle = firstTriangle; triangle < firstTriangle + triangleCount; triangle += 1) {
|
|
if (sourceTrianglePolygonIds[triangle] !== 0) {
|
|
throw new Error(`Detour topology polygons overlap at detail triangle ${triangle}.`);
|
|
}
|
|
sourceTrianglePolygonIds[triangle] = polygonId + 1;
|
|
}
|
|
}
|
|
for (let triangle = 0; triangle < sourceTrianglePolygonIds.length; triangle += 1) {
|
|
if (sourceTrianglePolygonIds[triangle] === 0) {
|
|
throw new Error(`Detour topology is missing source detail triangle ${triangle}.`);
|
|
}
|
|
sourceTrianglePolygonIds[triangle] -= 1;
|
|
}
|
|
}
|
|
|
|
for (let offset = 0; offset < sourceElementCount; offset += 3) {
|
|
const sourceTriangleIndex = offset / 3;
|
|
const sourcePolygonId = sourceTrianglePolygonIds?.[sourceTriangleIndex];
|
|
if (detourSelection && !detourSelection.selectedSourcePolygonIds.has(sourcePolygonId)) continue;
|
|
const triangle = [
|
|
sourceIndices?.[offset] ?? offset,
|
|
sourceIndices?.[offset + 1] ?? offset + 1,
|
|
sourceIndices?.[offset + 2] ?? offset + 2,
|
|
];
|
|
if (!triangle.every(vertexWithinInputBounds)) {
|
|
rejectedOutOfBoundsTriangles += 1;
|
|
continue;
|
|
}
|
|
if (outputTopology) {
|
|
const polygonId = detourSelection
|
|
? detourSelection.sourceToSelectedPolygonId.get(sourcePolygonId)
|
|
: sourcePolygonId;
|
|
if (!Number.isInteger(polygonId)) throw new Error(`Selected detail triangle ${sourceTriangleIndex} has no remapped polygon ID.`);
|
|
exportedTrianglePolygonIds.push(polygonId);
|
|
}
|
|
for (const sourceIndex of triangle) {
|
|
if (!sourceIndices) {
|
|
const nextIndex = compactPositions.length / 3;
|
|
const sourceOffset = sourceIndex * 3;
|
|
compactPositions.push(
|
|
sourcePositions[sourceOffset],
|
|
sourcePositions[sourceOffset + 1],
|
|
sourcePositions[sourceOffset + 2],
|
|
);
|
|
compactIndices.push(nextIndex);
|
|
continue;
|
|
}
|
|
if (!remappedVertices.has(sourceIndex)) {
|
|
const nextIndex = compactPositions.length / 3;
|
|
const sourceOffset = sourceIndex * 3;
|
|
remappedVertices.set(sourceIndex, nextIndex);
|
|
compactPositions.push(
|
|
sourcePositions[sourceOffset],
|
|
sourcePositions[sourceOffset + 1],
|
|
sourcePositions[sourceOffset + 2],
|
|
);
|
|
}
|
|
compactIndices.push(remappedVertices.get(sourceIndex));
|
|
}
|
|
}
|
|
if (!compactIndices.length) {
|
|
throw new Error("Recast helper produced no triangles within the authoritative collision bounds.");
|
|
}
|
|
if (outputTopology) {
|
|
const geometryRanges = buildDetourGeometryRanges(
|
|
outputTopology.polygonCount,
|
|
exportedTrianglePolygonIds,
|
|
);
|
|
for (const [polygonId, polygonRecord] of outputTopology.polygons.entries()) {
|
|
[polygonRecord[6], polygonRecord[7]] = geometryRanges[polygonId];
|
|
}
|
|
outputTopology.geometryTriangleCount = compactIndices.length / 3;
|
|
}
|
|
|
|
const positions = new Float32Array(compactPositions);
|
|
const indices = new Uint32Array(compactIndices);
|
|
const surfaceBucketReferenceCount = outputTopology
|
|
? countSurfaceBucketReferences(positions, indices, 8)
|
|
: null;
|
|
if (detourSelection) {
|
|
if (surfaceBucketReferenceCount > DETOUR_COMPONENT_SELECTION_LIMITS.surfaceBucketReferences) {
|
|
throw new Error(
|
|
`Source-anchored Detour subset exceeds the surfaceBucketReferences budget `
|
|
+ `(${surfaceBucketReferenceCount} > ${DETOUR_COMPONENT_SELECTION_LIMITS.surfaceBucketReferences}).`,
|
|
);
|
|
}
|
|
detourSelection.summary.selected.surfaceBucketReferenceCount = surfaceBucketReferenceCount;
|
|
outputTopology.sourceAnchorSelection.selected.surfaceBucketReferenceCount = surfaceBucketReferenceCount;
|
|
}
|
|
const minimum = [Infinity, Infinity, Infinity];
|
|
const maximum = [-Infinity, -Infinity, -Infinity];
|
|
for (let offset = 0; offset < positions.length; offset += 3) {
|
|
for (let axis = 0; axis < 3; axis += 1) {
|
|
minimum[axis] = Math.min(minimum[axis], positions[offset + axis]);
|
|
maximum[axis] = Math.max(maximum[axis], positions[offset + axis]);
|
|
}
|
|
}
|
|
const document = new Document();
|
|
const buffer = document.createBuffer("navigation-buffer");
|
|
const positionAccessor = document.createAccessor("navigation-position", buffer).setType("VEC3").setArray(positions);
|
|
const indexAccessor = document.createAccessor("navigation-indices", buffer).setType("SCALAR").setArray(indices);
|
|
const primitive = document.createPrimitive().setAttribute("POSITION", positionAccessor).setIndices(indexAccessor);
|
|
document.createMesh("navigation").addPrimitive(primitive);
|
|
document.createNode("navigation").setMesh(document.getRoot().listMeshes()[0]);
|
|
document.createScene("navigation").addChild(document.getRoot().listNodes()[0]);
|
|
if (outputTopology) {
|
|
document.getRoot().setExtras({
|
|
runewakerNavigationTopology: outputTopology,
|
|
});
|
|
}
|
|
await new NodeIO().write(file, document);
|
|
return {
|
|
vertices: positions.length / 3,
|
|
triangles: indices.length / 3,
|
|
sourceVertices: sourcePositions.length / 3,
|
|
sourceTriangles: sourceElementCount / 3,
|
|
rejectedOutOfBoundsTriangles,
|
|
...(surfaceBucketReferenceCount !== null ? { surfaceBucketReferenceCount } : {}),
|
|
bounds: [minimum, maximum],
|
|
boundsTolerance,
|
|
};
|
|
}
|
|
|
|
export async function bakeNavigationFromCollisionAssets({
|
|
slug,
|
|
collisionAssets,
|
|
settings,
|
|
offMeshLinks = [],
|
|
output = path.join(workRoot, slug, "staging", `${slug}-navigation.glb`),
|
|
reportFile = path.join(workRoot, slug, "recast-report.json"),
|
|
source = undefined,
|
|
includeDetourTopology = false,
|
|
detourAnchorPositions = [],
|
|
detourRouteProbes = [],
|
|
}) {
|
|
if (!collisionAssets.length) throw new Error(`${slug}: no collision chunks are available for Recast.`);
|
|
if (detourAnchorPositions.length && !includeDetourTopology) {
|
|
throw new Error(`${slug}: source-anchor Detour selection requires includeDetourTopology.`);
|
|
}
|
|
const normalizedPaths = collisionAssets.map((asset) => asset.path.replace(/\\/g, "/"));
|
|
if (new Set(normalizedPaths.map((value) => value.toLowerCase())).size !== normalizedPaths.length) {
|
|
throw new Error(`${slug}: collision chunks contain duplicate paths.`);
|
|
}
|
|
const resolvedInputs = collisionAssets.map((asset) => path.resolve(projectRoot, asset.path));
|
|
for (const [index, file] of resolvedInputs.entries()) {
|
|
if (!await exists(file)) throw new Error(`${slug}: missing collision chunk ${collisionAssets[index].path}.`);
|
|
}
|
|
const inputChunkChecksums = Object.fromEntries(await Promise.all(collisionAssets.map(async (asset, index) => [
|
|
normalizedPaths[index],
|
|
await sha256(resolvedInputs[index]),
|
|
])));
|
|
const scenes = await Promise.all(resolvedInputs.map(loadScene));
|
|
const sourceMeshes = scenes.flatMap(meshesIn);
|
|
if (!sourceMeshes.length) throw new Error(`${slug}: collision chunks contain no triangle meshes.`);
|
|
const adapted = unpackMeshesForRecast(sourceMeshes);
|
|
const meshes = adapted.meshes;
|
|
const input = inspectNavigationInput(meshes);
|
|
const navigationPlan = (tileSize) => planNavigationBuild(input.bounds, settings.cellSize, {
|
|
tileSize,
|
|
...(settings.forceTiled === true ? { maxSoloAxis: 0, maxSoloCells: 0 } : {}),
|
|
});
|
|
let build = navigationPlan(settings.tileSize);
|
|
const generatorConfig = {
|
|
cs: settings.cellSize,
|
|
ch: settings.cellHeight,
|
|
walkableHeight: Math.ceil(settings.agentHeight / settings.cellHeight),
|
|
walkableRadius: Math.ceil(settings.agentRadius / settings.cellSize),
|
|
walkableClimb: Math.floor(settings.agentMaxClimb / settings.cellHeight),
|
|
walkableSlopeAngle: settings.agentMaxSlope,
|
|
maxEdgeLen: Math.round(12 / settings.cellSize),
|
|
maxSimplificationError: 1.3,
|
|
minRegionArea: 8,
|
|
mergeRegionArea: 20,
|
|
maxVertsPerPoly: 6,
|
|
detailSampleDist: 6,
|
|
detailSampleMaxError: 1,
|
|
offMeshConnections: offMeshLinks,
|
|
};
|
|
await init();
|
|
let generated = null;
|
|
let tiledValidation = null;
|
|
const buildAttempts = [];
|
|
const retainTileIntermediates = settings.retainTileIntermediates !== false;
|
|
const requestedPartition = settings.partition ?? "watershed";
|
|
if (!["watershed", "monotone"].includes(requestedPartition)) {
|
|
throw new Error(`${slug}: unsupported Recast partition ${requestedPartition}.`);
|
|
}
|
|
let tiledPartition = requestedPartition;
|
|
let monotoneProvenance = null;
|
|
let helper = null;
|
|
let geometry;
|
|
let detourTopology = null;
|
|
let detourSelection = null;
|
|
let detourRoutes = [];
|
|
try {
|
|
if (build.mode === "tiled") {
|
|
while (true) {
|
|
if (tiledPartition === "monotone") {
|
|
const monotone = await threeToMonotoneTiledNavMesh(
|
|
meshes,
|
|
{ ...generatorConfig, tileSize: build.tileSize },
|
|
retainTileIntermediates,
|
|
);
|
|
generated = monotone.result;
|
|
monotoneProvenance = monotone.provenance;
|
|
} else {
|
|
generated = threeToTiledNavMesh(
|
|
meshes,
|
|
{ ...generatorConfig, tileSize: build.tileSize },
|
|
retainTileIntermediates,
|
|
);
|
|
}
|
|
if (!generated.success) {
|
|
buildAttempts.push({
|
|
partition: tiledPartition,
|
|
tileSize: build.tileSize,
|
|
tileCount: build.tileCount,
|
|
valid: false,
|
|
failures: [{ x: null, y: null, message: generated.error }],
|
|
});
|
|
if (shouldRetryFailedTiledBuildWithMonotone(generated.error, tiledPartition)) {
|
|
tiledPartition = "monotone";
|
|
continue;
|
|
}
|
|
throw new Error(`${slug}: Recast generation failed: ${generated.error}`);
|
|
}
|
|
tiledValidation = validateTiledBuild(generated.intermediates, {
|
|
errorCategory: Recast.RC_LOG_ERROR,
|
|
warningCategory: Recast.RC_LOG_WARNING,
|
|
intermediateRetention: retainTileIntermediates ? "retained" : "released",
|
|
populatedTileCount: retainTileIntermediates
|
|
? null
|
|
: populatedNavMeshTileCount(generated.navMesh),
|
|
});
|
|
buildAttempts.push({
|
|
partition: tiledPartition,
|
|
tileSize: build.tileSize,
|
|
tileCount: build.tileCount,
|
|
valid: tiledValidation.valid,
|
|
failures: tiledValidation.failures.map((failure) => ({
|
|
x: failure.x,
|
|
y: failure.y,
|
|
message: failure.message,
|
|
})),
|
|
});
|
|
if (tiledValidation.valid) break;
|
|
const nextTileSize = nextTiledNavMeshSize(tiledValidation, build.tileSize);
|
|
if (nextTileSize !== null) {
|
|
releaseTiledIntermediates(generated.intermediates);
|
|
generated.navMesh.destroy();
|
|
generated = null;
|
|
build = navigationPlan(nextTileSize);
|
|
continue;
|
|
}
|
|
if (
|
|
tiledPartition === "watershed"
|
|
&& shouldUseMonotonePartition(tiledValidation)
|
|
) {
|
|
releaseTiledIntermediates(generated.intermediates);
|
|
generated.navMesh.destroy();
|
|
generated = null;
|
|
tiledPartition = "monotone";
|
|
build = navigationPlan(settings.tileSize);
|
|
continue;
|
|
}
|
|
const sample = tiledValidation.failures
|
|
.slice(0, 3)
|
|
.map((failure) => `(${failure.x},${failure.y}) ${failure.message}`)
|
|
.join("; ");
|
|
throw new Error(`${slug}: tiled Recast validation failed: ${sample || "no populated navigation tiles"}`);
|
|
}
|
|
} else {
|
|
generated = threeToSoloNavMesh(meshes, generatorConfig);
|
|
buildAttempts.push({
|
|
tileSize: null,
|
|
tileCount: 1,
|
|
valid: generated.success,
|
|
failures: [],
|
|
});
|
|
if (!generated.success) throw new Error(`${slug}: Recast generation failed: ${generated.error}`);
|
|
}
|
|
helper = new NavMeshHelper(generated.navMesh);
|
|
if (!helper.navMeshGeometry.getAttribute("position")?.count) {
|
|
throw new Error(`${slug}: Recast produced no navigation geometry.`);
|
|
}
|
|
if (includeDetourTopology) {
|
|
detourTopology = extractDetourTopology(generated.navMesh, {
|
|
sourceCollisionSha256: stableSha256(inputChunkChecksums),
|
|
settingsSha256: stableSha256({ settings, generatorConfig }),
|
|
});
|
|
if (detourAnchorPositions.length) {
|
|
const anchorMappings = mapDetourAnchorsToPolygons(
|
|
generated.navMesh,
|
|
detourTopology,
|
|
detourAnchorPositions,
|
|
);
|
|
for (const anchor of detourAnchorPositions.filter(anchor => anchor.required === true)) {
|
|
const mapping = anchorMappings.find(mapping => mapping.id === anchor.id);
|
|
if (mapping?.polygonId === null || mapping?.polygonId === undefined
|
|
|| !Number.isFinite(mapping.distance) || mapping.distance > anchor.maximumDistance) {
|
|
throw new Error(`${slug}: required source navigation anchor ${anchor.id} is outside its projection limit.`);
|
|
}
|
|
}
|
|
detourSelection = selectDetourAnchorComponents(detourTopology, anchorMappings);
|
|
detourSelection.sourceTopology = detourTopology;
|
|
}
|
|
}
|
|
detourRoutes = queryDetourRouteProbes(generated.navMesh, detourRouteProbes);
|
|
await mkdir(path.dirname(output), { recursive: true });
|
|
geometry = await writeGeometry(
|
|
output,
|
|
helper.navMeshGeometry,
|
|
input.bounds,
|
|
Math.max(settings.cellSize, settings.cellHeight) * 2,
|
|
detourTopology,
|
|
detourSelection,
|
|
);
|
|
} finally {
|
|
helper?.navMeshGeometry.dispose();
|
|
helper?.navMeshMaterial.dispose();
|
|
if (generated && build.mode === "tiled") releaseTiledIntermediates(generated.intermediates);
|
|
generated?.navMesh?.destroy();
|
|
}
|
|
const report = {
|
|
schemaVersion: 1,
|
|
dungeonId: slug,
|
|
generator: "@recast-navigation/three@0.43.1",
|
|
settings,
|
|
input,
|
|
navigationGeometryAdapter: adapted.report,
|
|
build: {
|
|
...build,
|
|
partition: build.mode === "tiled" ? tiledPartition : "watershed",
|
|
...(monotoneProvenance ? { partitionProvenance: monotoneProvenance } : {}),
|
|
populatedTileCount: tiledValidation?.populatedTileCount ?? 1,
|
|
ignoredEmptyTilePackingFailures:
|
|
tiledValidation?.ignoredEmptyTilePackingFailures.length ?? 0,
|
|
intermediateRetention: tiledValidation?.intermediateRetention ?? null,
|
|
attempts: buildAttempts,
|
|
},
|
|
inputChunks: normalizedPaths,
|
|
inputChunkChecksums,
|
|
output: relativeProjectPath(output),
|
|
geometry,
|
|
...(detourRouteProbes.length ? { detourRouteProbes: detourRoutes } : {}),
|
|
...(detourTopology ? {
|
|
detourTopology: {
|
|
schemaVersion: detourTopology.schemaVersion,
|
|
tileCount: detourSelection?.topology.tileCount ?? detourTopology.tileCount,
|
|
polygonCount: detourSelection?.topology.polygonCount ?? detourTopology.polygonCount,
|
|
detailTriangleCount: detourSelection?.topology.detailTriangleCount ?? detourTopology.detailTriangleCount,
|
|
polygonFanTriangleCount: detourSelection?.topology.polygonFanTriangleCount ?? detourTopology.polygonFanTriangleCount,
|
|
geometryTriangleCount: detourSelection?.topology.geometryTriangleCount ?? detourTopology.geometryTriangleCount,
|
|
directedLinkCount: detourSelection?.topology.directedLinkCount ?? detourTopology.directedLinkCount,
|
|
unrepresentedLinkCount: detourTopology.unrepresentedLinkCount,
|
|
offMeshPolygonCount: detourSelection?.topology.offMeshPolygonCount ?? detourTopology.offMeshPolygonCount,
|
|
weakComponents: {
|
|
count: detourSelection?.topology.weakComponents.count ?? detourTopology.weakComponents.count,
|
|
largestPolygonCount: detourSelection?.topology.weakComponents.largestPolygonCount ?? detourTopology.weakComponents.largestPolygonCount,
|
|
},
|
|
sourceCollisionSha256: detourTopology.sourceCollisionSha256,
|
|
settingsSha256: detourTopology.settingsSha256,
|
|
},
|
|
} : {}),
|
|
...(detourSelection ? { detourComponentSelection: detourSelection.summary } : {}),
|
|
...(source ? { source } : {}),
|
|
};
|
|
await writeJson(reportFile, report);
|
|
return { status: "green", ...report };
|
|
}
|
|
|
|
function navigationSettings(recipe) {
|
|
return {
|
|
cellSize: 0.2,
|
|
cellHeight: 0.1,
|
|
agentHeight: 1.8,
|
|
agentRadius: 0.36,
|
|
agentMaxClimb: 0.5,
|
|
agentMaxSlope: 50,
|
|
...(recipe.navigation ?? {}),
|
|
};
|
|
}
|
|
|
|
export async function bakeDungeonNavigation(slug) {
|
|
const recipe = await loadRecipe(slug);
|
|
const fixture = await readJson(path.join(fixturesRoot, slug, "runtime-fixture.json"));
|
|
const collisionAssets = fixture.assets.filter((asset) => asset.role === "collision");
|
|
return bakeNavigationFromCollisionAssets({
|
|
slug,
|
|
collisionAssets,
|
|
settings: navigationSettings(recipe),
|
|
offMeshLinks: fixture.offMeshLinks ?? [],
|
|
source: {
|
|
kind: "reviewed-runtime-fixture",
|
|
path: relativeProjectPath(path.join(fixturesRoot, slug, "runtime-fixture.json")),
|
|
},
|
|
});
|
|
}
|
|
|
|
export function collisionAssetsFromConversionReport(slug, conversionFile, conversion) {
|
|
if (!["green", "review-required"].includes(conversion.status)) {
|
|
throw new Error(`${slug}: conversion report is not reviewable.`);
|
|
}
|
|
if (!Array.isArray(conversion.collision) || !conversion.collision.length) {
|
|
throw new Error(`${slug}: conversion report has no collision chunks.`);
|
|
}
|
|
return conversion.collision.map((chunk, index) => {
|
|
if (!chunk.file) throw new Error(`${slug}: collision chunk ${index} has no file.`);
|
|
if (chunk.triangles > 100_000) {
|
|
throw new Error(`${slug}: collision chunk ${chunk.file} exceeds the 100000-triangle limit.`);
|
|
}
|
|
return {
|
|
id: chunk.id ?? `collision-${String(index).padStart(3, "0")}`,
|
|
role: "collision",
|
|
path: relativeProjectPath(path.join(path.dirname(conversionFile), chunk.file)),
|
|
};
|
|
});
|
|
}
|
|
|
|
export async function bakeConversionNavigation(slug) {
|
|
const recipe = await loadRecipe(slug);
|
|
const conversionFile = path.join(workRoot, slug, "staging/environment/conversion-report.json");
|
|
if (!await exists(conversionFile)) throw new Error(`${slug}: conversion report is missing.`);
|
|
const conversion = await readJson(conversionFile);
|
|
const collisionAssets = collisionAssetsFromConversionReport(slug, conversionFile, conversion);
|
|
return bakeNavigationFromCollisionAssets({
|
|
slug,
|
|
collisionAssets,
|
|
settings: navigationSettings(recipe),
|
|
source: {
|
|
kind: "blender-conversion-report",
|
|
path: relativeProjectPath(conversionFile),
|
|
checksum: await sha256(conversionFile),
|
|
collisionChunkCount: collisionAssets.length,
|
|
},
|
|
});
|
|
}
|