export const DEFAULT_TILED_NAVMESH_SIZE = 512; export const MIN_TILED_NAVMESH_SIZE = 128; export const MAX_SOLO_HEIGHTFIELD_AXIS = 4096; export const MAX_SOLO_HEIGHTFIELD_CELLS = MAX_SOLO_HEIGHTFIELD_AXIS ** 2; export const MAX_DETOUR_TILES = 16384; function finiteBounds(bounds) { return Array.isArray(bounds) && bounds.length === 2 && bounds.every((corner) => Array.isArray(corner) && corner.length === 3 && corner.every(Number.isFinite)); } export function planNavigationBuild(bounds, cellSize, { tileSize = DEFAULT_TILED_NAVMESH_SIZE, maxSoloAxis = MAX_SOLO_HEIGHTFIELD_AXIS, maxSoloCells = MAX_SOLO_HEIGHTFIELD_CELLS, maxTiles = MAX_DETOUR_TILES, } = {}) { if (!finiteBounds(bounds)) throw new Error("Navigation bounds must contain two finite XYZ coordinates."); if (!Number.isFinite(cellSize) || cellSize <= 0) throw new Error("Navigation cell size must be positive and finite."); if (!Number.isInteger(tileSize) || tileSize <= 0) throw new Error("Navigation tile size must be a positive integer."); const [minimum, maximum] = bounds; const gridWidth = Math.max(1, Math.ceil((maximum[0] - minimum[0]) / cellSize)); const gridHeight = Math.max(1, Math.ceil((maximum[2] - minimum[2]) / cellSize)); if (maximum.some((value, index) => value < minimum[index])) { throw new Error("Navigation bounds maximum must not be below its minimum."); } const gridCells = gridWidth * gridHeight; const mode = gridWidth > maxSoloAxis || gridHeight > maxSoloAxis || gridCells > maxSoloCells ? "tiled" : "solo"; const tileColumns = mode === "tiled" ? Math.ceil(gridWidth / tileSize) : 1; const tileRows = mode === "tiled" ? Math.ceil(gridHeight / tileSize) : 1; const tileCount = tileColumns * tileRows; if (mode === "tiled" && tileCount > maxTiles) { throw new Error( `Navigation bounds require ${tileCount} tiles, above Detour's ${maxTiles}-tile pipeline limit.`, ); } return { mode, gridWidth, gridHeight, gridCells, tileSize: mode === "tiled" ? tileSize : null, tileColumns, tileRows, tileCount, }; } function tileKey(x, y) { return `${x},${y}`; } function tileCoordinatesFromLog(message) { const match = /^Building tile at x:\s*(-?\d+), y:\s*(-?\d+)$/.exec(message); return match ? { x: Number(match[1]), y: Number(match[2]) } : null; } function count(intermediate, property, method) { return intermediate?.[property]?.[method]?.() ?? null; } export function validateTiledBuild(intermediates, { errorCategory = 3, warningCategory = 2, intermediateRetention = "retained", populatedTileCount = null, } = {}) { if (!["retained", "released"].includes(intermediateRetention)) { throw new Error(`Unsupported tiled intermediate retention mode: ${intermediateRetention}.`); } const tiles = new Map( (intermediates?.tileIntermediates ?? []).map((tile) => [tileKey(tile.x, tile.y), tile]), ); const failures = []; const ignoredEmptyTilePackingFailures = []; let currentTile = null; for (const log of intermediates?.buildContext?.logs ?? []) { currentTile = tileCoordinatesFromLog(log.msg) ?? currentTile; const failedToAddTile = log.category === warningCategory && log.msg.startsWith("Failed to add tile to nav mesh"); if (log.category !== errorCategory && !failedToAddTile) continue; const tile = currentTile ? tiles.get(tileKey(currentTile.x, currentTile.y)) : null; const polygonCount = count(tile, "polyMesh", "npolys"); const contourCount = count(tile, "contourSet", "nconts"); const confirmedEmptyPackingFailure = log.msg === "Failed to create Detour navmesh data" && ( (polygonCount === 0 && contourCount === 0) || ( intermediateRetention === "released" && polygonCount === null && contourCount === null ) ); const issue = { x: currentTile?.x ?? null, y: currentTile?.y ?? null, message: log.msg, polygonCount, contourCount, verification: intermediateRetention, }; if (confirmedEmptyPackingFailure) ignoredEmptyTilePackingFailures.push(issue); else failures.push(issue); } const populatedTiles = [...tiles.values()].filter( (tile) => (count(tile, "polyMesh", "npolys") ?? 0) > 0, ); const effectivePopulatedTileCount = populatedTileCount ?? populatedTiles.length; return { valid: failures.length === 0 && effectivePopulatedTileCount > 0, failures, ignoredEmptyTilePackingFailures, populatedTileCount: effectivePopulatedTileCount, tileCount: tiles.size, intermediateRetention, }; } export function nextTiledNavMeshSize(validation, currentTileSize, { minimumTileSize = MIN_TILED_NAVMESH_SIZE, } = {}) { const retryable = validation?.failures?.length > 0 && validation.failures.every((failure) => ( /^rcBuildContours: Bad outline for region \d+,/.test(failure.message) || /^rcBuildRegions: \d+ overlapping regions\.$/.test(failure.message) )); if (!retryable || currentTileSize <= minimumTileSize) return null; return Math.max(minimumTileSize, Math.floor(currentTileSize / 2)); } export function shouldUseMonotonePartition(validation) { return validation?.failures?.length > 0 && validation.failures.every( (failure) => /^rcBuildRegions: \d+ overlapping regions\.$/.test(failure.message), ); }