From b7ebe061ca207c2e7cd8273809359f48781e5d59 Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Fri, 20 Mar 2026 10:53:57 +0000 Subject: [PATCH 01/18] latest --- src/cli/index.ts | 62 ++-- src/lib/index.ts | 2 + src/lib/types.ts | 6 + src/lib/voxel/nav-simplify.ts | 507 +++++++++++++++++++++++++++++++++ src/lib/write.ts | 2 + src/lib/writers/write-voxel.ts | 26 +- test/nav-simplify.test.mjs | 254 +++++++++++++++++ 7 files changed, 829 insertions(+), 30 deletions(-) create mode 100644 src/lib/voxel/nav-simplify.ts create mode 100644 test/nav-simplify.test.mjs diff --git a/src/cli/index.ts b/src/cli/index.ts index ed1fe58..bf906d7 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -87,6 +87,9 @@ const parseArguments = async () => { 'opacity-cutoff': { type: 'string', short: 'A', default: '0.1' }, 'collision-mesh': { type: 'boolean', short: 'K', default: false }, 'mesh-simplify': { type: 'string', short: 'T', default: '0.25' }, + 'nav-capsule-height': { type: 'string', default: '' }, + 'nav-capsule-radius': { type: 'string', default: '' }, + 'nav-seed': { type: 'string', default: '' }, // per-file options translate: { type: 'string', short: 't', multiple: true }, @@ -167,6 +170,28 @@ const parseArguments = async () => { const viewerSettingsPath = v['viewer-settings']; + // Parse nav capsule options + const navSeedStr = v['nav-seed']; + const hasNavCapsuleArgs = !!(v['nav-capsule-height'] || v['nav-capsule-radius']); + let navCapsule: { height: number; radius: number } | undefined; + let navSeed: { x: number; y: number; z: number } | undefined; + + if (hasNavCapsuleArgs && !navSeedStr) { + throw new Error('--nav-seed is required when using --nav-capsule-height or --nav-capsule-radius'); + } + + if (navSeedStr) { + const parts = navSeedStr.split(',').map(parseNumber); + if (parts.length !== 3) { + throw new Error(`Invalid nav-seed value: ${navSeedStr}. Expected x,y,z`); + } + navSeed = { x: parts[0], y: parts[1], z: parts[2] }; + navCapsule = { + height: v['nav-capsule-height'] ? parseNumber(v['nav-capsule-height']) : 1.5, + radius: v['nav-capsule-radius'] ? parseNumber(v['nav-capsule-radius']) : 0.2 + }; + } + const options: CliOptions = { overwrite: v.overwrite, help: v.help, @@ -183,7 +208,9 @@ const parseArguments = async () => { voxelResolution: parseNumber(v['voxel-resolution']), opacityCutoff: parseNumber(v['opacity-cutoff']), collisionMesh: v['collision-mesh'], - meshSimplify: parseNumber(v['mesh-simplify']) + meshSimplify: parseNumber(v['mesh-simplify']), + navCapsule, + navSeed }; if (!Number.isFinite(options.meshSimplify) || options.meshSimplify < 0 || options.meshSimplify > 1) { @@ -403,39 +430,18 @@ GLOBAL OPTIONS -A, --opacity-cutoff Opacity threshold for solid voxels. Default: 0.1 -K, --collision-mesh Generate collision mesh (.collision.glb) with voxel output -T, --mesh-simplify Ratio of triangles to keep for collision mesh (0-1). Default: 0.25 + --nav-seed Seed position for capsule navigation simplification + --nav-capsule-height Capsule height for nav simplification. Default: 1.5 + --nav-capsule-radius Capsule radius for nav simplification. Default: 0.2 EXAMPLES - # Scale then translate + # Scale and translate splat-transform bunny.ply -s 0.5 -t 0,0,10 bunny-scaled.ply - # Merge two files with transforms and compress to SOG format + # Merge two files with per-file transforms splat-transform -w cloudA.ply -r 0,90,0 cloudB.ply -s 2 merged.sog - # Generate unbundled HTML viewer with separate CSS, JS and SOG files - splat-transform -U bunny.ply bunny-viewer.html - - # Generate synthetic splats using a generator script - splat-transform gen-grid.mjs -p width=500,height=500,scale=0.1 grid.ply - - # Generate LOD with custom chunk size and node split size - splat-transform -O 0,1,2 -C 1024 -X 32 input.lcc output/lod-meta.json - - # Generate voxel data - splat-transform input.ply output.voxel.json - - # Generate voxel data with collision mesh - splat-transform -K input.ply output.voxel.json - - # Generate voxel data with custom resolution and opacity threshold - splat-transform -R 0.1 -A 0.3 input.ply output.voxel.json - - # Convert voxel data back to PLY for visualization - splat-transform scene.voxel.json scene-voxels.ply - - # Print statistical summary, then write output - splat-transform bunny.ply --summary output.ply - - # Print summary without writing a file (discard output) + # Print summary without writing a file splat-transform bunny.ply -m null `; diff --git a/src/lib/index.ts b/src/lib/index.ts index ff40601..d371d24 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -60,6 +60,8 @@ export { writeLod } from './writers/write-lod'; export { writeGlb } from './writers/write-glb'; export { writeVoxel } from './writers/write-voxel'; export type { WriteVoxelOptions, VoxelMetadata } from './writers/write-voxel'; +export { simplifyForCapsule } from './voxel/nav-simplify'; +export type { NavSeed } from './voxel/nav-simplify'; export { marchingCubes } from './voxel/marching-cubes'; export type { MarchingCubesMesh } from './voxel/marching-cubes'; diff --git a/src/lib/types.ts b/src/lib/types.ts index 1ab00d3..f2a452d 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -31,6 +31,12 @@ type Options = { /** Ratio of triangles to keep when simplifying the collision mesh (0-1). Default: 0.25 */ meshSimplify?: number; + + /** Capsule dimensions for navigation simplification. When set with navSeed, simplifies voxel output. */ + navCapsule?: { height: number; radius: number }; + + /** Seed position in world space for navigation flood fill. Required when navCapsule is set. */ + navSeed?: { x: number; y: number; z: number }; }; /** diff --git a/src/lib/voxel/nav-simplify.ts b/src/lib/voxel/nav-simplify.ts new file mode 100644 index 0000000..2919601 --- /dev/null +++ b/src/lib/voxel/nav-simplify.ts @@ -0,0 +1,507 @@ +import { + BlockAccumulator, + mortonToXYZ, + xyzToMorton, + type Bounds +} from './sparse-octree'; +import { logger } from '../utils/logger'; + +/** + * Seed position for capsule navigation simplification. + */ +type NavSeed = { + x: number; + y: number; + z: number; +}; + +/** + * Build a list of (dx, dz) offsets forming a filled circle in the XZ plane. + * + * @param radius - Circle radius in voxel units. + * @returns Array of [dx, dz] offset pairs within the circle. + */ +const buildCircularKernel = (radius: number): number[][] => { + const offsets: number[][] = []; + const r2 = radius * radius; + for (let dx = -radius; dx <= radius; dx++) { + for (let dz = -radius; dz <= radius; dz++) { + if (dx * dx + dz * dz <= r2) { + offsets.push([dx, dz]); + } + } + } + return offsets; +}; + +/** + * Populate a dense solid grid from a BlockAccumulator. + * + * @param accumulator - Source block data. + * @param grid - Pre-allocated Uint8Array (nx * ny * nz), zeroed. + * @param nx - Grid X dimension in voxels. + * @param ny - Grid Y dimension. + * @param nz - Grid Z dimension. + */ +const fillDenseSolidGrid = ( + accumulator: BlockAccumulator, + grid: Uint8Array, + nx: number, ny: number, nz: number +): void => { + const stride = nx * ny; + + const solidMortons = accumulator.getSolidBlocks(); + for (let i = 0; i < solidMortons.length; i++) { + const [bx, by, bz] = mortonToXYZ(solidMortons[i]); + const baseX = bx << 2; + const baseY = by << 2; + const baseZ = bz << 2; + for (let lz = 0; lz < 4; lz++) { + const iz = baseZ + lz; + if (iz >= nz) continue; + for (let ly = 0; ly < 4; ly++) { + const iy = baseY + ly; + if (iy >= ny) continue; + const rowOff = iz * stride + iy * nx; + for (let lx = 0; lx < 4; lx++) { + const ix = baseX + lx; + if (ix < nx) grid[rowOff + ix] = 1; + } + } + } + } + + const mixed = accumulator.getMixedBlocks(); + for (let i = 0; i < mixed.morton.length; i++) { + const [bx, by, bz] = mortonToXYZ(mixed.morton[i]); + const lo = mixed.masks[i * 2]; + const hi = mixed.masks[i * 2 + 1]; + const baseX = bx << 2; + const baseY = by << 2; + const baseZ = bz << 2; + for (let lz = 0; lz < 4; lz++) { + const iz = baseZ + lz; + if (iz >= nz) continue; + for (let ly = 0; ly < 4; ly++) { + const iy = baseY + ly; + if (iy >= ny) continue; + const rowOff = iz * stride + iy * nx; + for (let lx = 0; lx < 4; lx++) { + const bitIdx = lx + (ly << 2) + (lz << 4); + const word = bitIdx < 32 ? lo : hi; + const bit = bitIdx < 32 ? bitIdx : bitIdx - 32; + if ((word >>> bit) & 1) { + const ix = baseX + lx; + if (ix < nx) grid[rowOff + ix] = 1; + } + } + } + } + } +}; + +/** + * XZ morphological dilation: for each cell matching `matchValue` in `src`, + * mark all cells within the circular kernel in `dst`. + * + * @param src - Source grid. + * @param dst - Destination grid (must be pre-zeroed). + * @param nx - Grid X dimension. + * @param ny - Grid Y dimension. + * @param nz - Grid Z dimension. + * @param kernel - Circular kernel offsets [dx, dz]. + * @param matchValue - Value to match in src. + */ +const dilateXZ = ( + src: Uint8Array, + dst: Uint8Array, + nx: number, ny: number, nz: number, + kernel: number[][], + matchValue: number +): void => { + const stride = nx * ny; + const kLen = kernel.length; + + for (let iz = 0; iz < nz; iz++) { + const zOff = iz * stride; + for (let iy = 0; iy < ny; iy++) { + const yzOff = zOff + iy * nx; + for (let ix = 0; ix < nx; ix++) { + if (src[yzOff + ix] !== matchValue) continue; + for (let k = 0; k < kLen; k++) { + const kx = ix + kernel[k][0]; + const kz = iz + kernel[k][1]; + if (kx >= 0 && kx < nx && kz >= 0 && kz < nz) { + dst[kz * stride + iy * nx + kx] = 1; + } + } + } + } + } +}; + +/** + * Y-axis morphological dilation via sliding window. + * For each column, a cell is marked if any cell within `halfExtent` in Y + * is set in the source. + * + * @param src - Source grid. + * @param dst - Destination grid (must be pre-zeroed). + * @param nx - Grid X dimension. + * @param ny - Grid Y dimension. + * @param nz - Grid Z dimension. + * @param halfExtent - Half-window size in voxels. + */ +const dilateY = ( + src: Uint8Array, + dst: Uint8Array, + nx: number, ny: number, nz: number, + halfExtent: number +): void => { + const stride = nx * ny; + + for (let iz = 0; iz < nz; iz++) { + const zOff = iz * stride; + for (let ix = 0; ix < nx; ix++) { + let count = 0; + const winEnd = Math.min(halfExtent, ny - 1); + for (let iy = 0; iy <= winEnd; iy++) { + if (src[zOff + iy * nx + ix]) count++; + } + + for (let iy = 0; iy < ny; iy++) { + if (count > 0) dst[zOff + iy * nx + ix] = 1; + + const exitY = iy - halfExtent; + if (exitY >= 0 && src[zOff + exitY * nx + ix]) count--; + + const enterY = iy + halfExtent + 1; + if (enterY < ny && src[zOff + enterY * nx + ix]) count++; + } + } + } +}; + +/** + * XZ morphological erosion: a cell remains solid only if ALL cells within the + * circular kernel are solid in `src`. Out-of-bounds cells are treated as solid + * (grid boundary convention). + * + * @param src - Source grid. + * @param dst - Destination grid (must be pre-zeroed). + * @param nx - Grid X dimension. + * @param ny - Grid Y dimension. + * @param nz - Grid Z dimension. + * @param kernel - Circular kernel offsets [dx, dz]. + */ +const erodeXZ = ( + src: Uint8Array, + dst: Uint8Array, + nx: number, ny: number, nz: number, + kernel: number[][] +): void => { + const stride = nx * ny; + const kLen = kernel.length; + + for (let iz = 0; iz < nz; iz++) { + const zOff = iz * stride; + for (let iy = 0; iy < ny; iy++) { + const yzOff = zOff + iy * nx; + for (let ix = 0; ix < nx; ix++) { + let allSolid = true; + for (let k = 0; k < kLen; k++) { + const kx = ix + kernel[k][0]; + const kz = iz + kernel[k][1]; + if (kx >= 0 && kx < nx && kz >= 0 && kz < nz) { + if (!src[kz * stride + iy * nx + kx]) { + allSolid = false; + break; + } + } + } + if (allSolid) dst[yzOff + ix] = 1; + } + } + } +}; + +/** + * Y-axis morphological erosion via sliding window. + * A cell remains solid only if ALL cells within `halfExtent` in Y are solid. + * Out-of-bounds cells are treated as solid (grid boundary convention). + * + * @param src - Source grid. + * @param dst - Destination grid (must be pre-zeroed). + * @param nx - Grid X dimension. + * @param ny - Grid Y dimension. + * @param nz - Grid Z dimension. + * @param halfExtent - Half-window size in voxels. + */ +const erodeY = ( + src: Uint8Array, + dst: Uint8Array, + nx: number, ny: number, nz: number, + halfExtent: number +): void => { + const stride = nx * ny; + + for (let iz = 0; iz < nz; iz++) { + const zOff = iz * stride; + for (let ix = 0; ix < nx; ix++) { + let zeroCount = 0; + const winEnd = Math.min(halfExtent, ny - 1); + for (let iy = 0; iy <= winEnd; iy++) { + if (!src[zOff + iy * nx + ix]) zeroCount++; + } + + for (let iy = 0; iy < ny; iy++) { + if (zeroCount === 0) dst[zOff + iy * nx + ix] = 1; + + const exitY = iy - halfExtent; + if (exitY >= 0 && !src[zOff + exitY * nx + ix]) zeroCount--; + + const enterY = iy + halfExtent + 1; + if (enterY < ny && !src[zOff + enterY * nx + ix]) zeroCount++; + } + } + } +}; + +/** + * Convert a dense boolean grid back into a BlockAccumulator. + * + * @param grid - Dense grid with 1 = solid. + * @param nx - Grid X dimension (must be divisible by 4). + * @param ny - Grid Y dimension (must be divisible by 4). + * @param nz - Grid Z dimension (must be divisible by 4). + * @returns New BlockAccumulator with blocks matching the grid. + */ +const denseGridToAccumulator = ( + grid: Uint8Array, + nx: number, ny: number, nz: number +): BlockAccumulator => { + const acc = new BlockAccumulator(); + const nbx = nx >> 2; + const nby = ny >> 2; + const nbz = nz >> 2; + const stride = nx * ny; + + for (let bz = 0; bz < nbz; bz++) { + for (let by = 0; by < nby; by++) { + for (let bx = 0; bx < nbx; bx++) { + let lo = 0; + let hi = 0; + const baseX = bx << 2; + const baseY = by << 2; + const baseZ = bz << 2; + + for (let lz = 0; lz < 4; lz++) { + for (let ly = 0; ly < 4; ly++) { + for (let lx = 0; lx < 4; lx++) { + if (grid[(baseX + lx) + (baseY + ly) * nx + (baseZ + lz) * stride]) { + const bitIdx = lx + (ly << 2) + (lz << 4); + if (bitIdx < 32) { + lo |= (1 << bitIdx); + } else { + hi |= (1 << (bitIdx - 32)); + } + } + } + } + } + + if (lo !== 0 || hi !== 0) { + acc.addBlock(xyzToMorton(bx, by, bz), lo, hi); + } + } + } + } + + return acc; +}; + +const FREE = 0; +const BLOCKED = 1; +const REACHABLE = 2; + +/** + * Simplify voxel collision data for upright capsule navigation. + * + * Algorithm: + * 1. Build dense solid grid from the accumulator. + * 2. Dilate solid by the capsule shape (Minkowski sum) to get the clearance + * grid -- cells where the capsule center cannot be placed. + * 3. BFS flood fill from the seed through free (non-blocked) cells to find + * all reachable capsule-center positions. + * 4. Invert: every non-reachable cell becomes solid (negative space carving). + * 5. Erode the solid by the capsule shape (Minkowski subtraction) to shrink + * surfaces back to their original positions, undoing the inflation from + * step 2 so the runtime capsule query produces correct collisions. + * + * Grid boundaries are treated as solid, so the fill is always bounded even + * in unsealed scenes. + * + * @param accumulator - BlockAccumulator with filtered voxelization results. + * @param gridBounds - Grid bounds aligned to block boundaries. + * @param voxelResolution - Size of each voxel in world units. + * @param capsuleHeight - Total capsule height in world units. + * @param capsuleRadius - Capsule radius in world units. + * @param seed - Seed position in world space (must be in a free region). + * @returns New BlockAccumulator with simplified collision voxels. + */ +const simplifyForCapsule = ( + accumulator: BlockAccumulator, + gridBounds: Bounds, + voxelResolution: number, + capsuleHeight: number, + capsuleRadius: number, + seed: NavSeed +): BlockAccumulator => { + const nx = Math.round((gridBounds.max.x - gridBounds.min.x) / voxelResolution); + const ny = Math.round((gridBounds.max.y - gridBounds.min.y) / voxelResolution); + const nz = Math.round((gridBounds.max.z - gridBounds.min.z) / voxelResolution); + const totalVoxels = nx * ny * nz; + const stride = nx * ny; + + const kernelR = Math.ceil(capsuleRadius / voxelResolution) + 1; + const yHalfExtent = Math.ceil(capsuleHeight / (2 * voxelResolution)) + 1; + const kernel = buildCircularKernel(kernelR); + + const memoryMB = Math.round(totalVoxels * 3 / (1024 * 1024)); + logger.debug(`nav simplify: grid ${nx}x${ny}x${nz} (${totalVoxels} voxels, ~${memoryMB} MB), clearance r=${kernelR} (${kernel.length} cells), y half=${yHalfExtent}`); + + if (memoryMB > 512) { + logger.warn(`nav simplify: large grid requires ~${memoryMB} MB. Consider using a coarser -R value to reduce memory.`); + } + + // Phase 1: build dense solid grid from accumulator + const solidGrid = new Uint8Array(totalVoxels); + fillDenseSolidGrid(accumulator, solidGrid, nx, ny, nz); + + let solidCount = 0; + for (let i = 0; i < totalVoxels; i++) { + if (solidGrid[i]) solidCount++; + } + logger.debug(`nav simplify: ${solidCount} solid voxels`); + + // Phase 2: capsule clearance grid (Minkowski dilation of solid by capsule) + const tempA = new Uint8Array(totalVoxels); + dilateXZ(solidGrid, tempA, nx, ny, nz, kernel, 1); + + const tempB = new Uint8Array(totalVoxels); + dilateY(tempA, tempB, nx, ny, nz, yHalfExtent); + + // Phase 3: flood fill from seed through free (non-blocked) cells + const seedIx = Math.floor((seed.x - gridBounds.min.x) / voxelResolution); + const seedIy = Math.floor((seed.y - gridBounds.min.y) / voxelResolution); + const seedIz = Math.floor((seed.z - gridBounds.min.z) / voxelResolution); + + if (seedIx < 0 || seedIx >= nx || seedIy < 0 || seedIy >= ny || seedIz < 0 || seedIz >= nz) { + logger.warn(`nav simplify: seed (${seed.x}, ${seed.y}, ${seed.z}) outside grid, skipping`); + return accumulator; + } + + const seedIdx = seedIx + seedIy * nx + seedIz * stride; + if (tempB[seedIdx] !== FREE) { + logger.warn(`nav simplify: seed (${seed.x}, ${seed.y}, ${seed.z}) in blocked region, skipping`); + return accumulator; + } + + // BFS flood fill using a Uint32Array circular buffer. + // A JS Array would OOM on large grids because it stores every visited + // cell. The circular buffer only holds the active frontier. + const QUEUE_BITS = 25; + const QUEUE_CAP = 1 << QUEUE_BITS; // 32M entries = 128 MB + const QUEUE_MASK = QUEUE_CAP - 1; + const bfsQueue = new Uint32Array(QUEUE_CAP); + let qHead = 0; + let qTail = 0; + let reachableCount = 0; + + tempB[seedIdx] = REACHABLE; + bfsQueue[qTail] = seedIdx; + qTail = (qTail + 1) & QUEUE_MASK; + + while (qHead !== qTail) { + const idx = bfsQueue[qHead]; + qHead = (qHead + 1) & QUEUE_MASK; + reachableCount++; + + const ix = idx % nx; + const iy = Math.floor((idx % stride) / nx); + const iz = Math.floor(idx / stride); + + if (ix > 0 && tempB[idx - 1] === FREE) { + tempB[idx - 1] = REACHABLE; + bfsQueue[qTail] = idx - 1; + qTail = (qTail + 1) & QUEUE_MASK; + } + if (ix < nx - 1 && tempB[idx + 1] === FREE) { + tempB[idx + 1] = REACHABLE; + bfsQueue[qTail] = idx + 1; + qTail = (qTail + 1) & QUEUE_MASK; + } + if (iy > 0 && tempB[idx - nx] === FREE) { + tempB[idx - nx] = REACHABLE; + bfsQueue[qTail] = idx - nx; + qTail = (qTail + 1) & QUEUE_MASK; + } + if (iy < ny - 1 && tempB[idx + nx] === FREE) { + tempB[idx + nx] = REACHABLE; + bfsQueue[qTail] = idx + nx; + qTail = (qTail + 1) & QUEUE_MASK; + } + if (iz > 0 && tempB[idx - stride] === FREE) { + tempB[idx - stride] = REACHABLE; + bfsQueue[qTail] = idx - stride; + qTail = (qTail + 1) & QUEUE_MASK; + } + if (iz < nz - 1 && tempB[idx + stride] === FREE) { + tempB[idx + stride] = REACHABLE; + bfsQueue[qTail] = idx + stride; + qTail = (qTail + 1) & QUEUE_MASK; + } + } + + logger.debug(`nav simplify: ${reachableCount} reachable cells (${(reachableCount / totalVoxels * 100).toFixed(1)}%)`); + + // Phase 4: invert reachable to solid + // Everything the capsule cannot reach becomes solid. This produces a + // "negative space" carving: the reachable volume is empty, everything + // else is filled. Large contiguous solid regions compress to single + // SOLID_LEAF_MARKER nodes in the octree, and there are no surface + // holes since every non-reachable cell is solid. + let outputCount = 0; + for (let i = 0; i < totalVoxels; i++) { + if (tempB[i] !== REACHABLE) { + solidGrid[i] = 1; + outputCount++; + } else { + solidGrid[i] = 0; + } + } + + logger.debug(`nav simplify: ${outputCount} solid voxels after inversion`); + + // Phase 5: erode solid by capsule shape (Minkowski subtraction) + // The inversion inflated the solid boundary by the capsule clearance. + // Eroding by the same kernel shrinks it back to the original surface + // positions so the runtime capsule query produces correct collisions. + tempA.fill(0); + erodeXZ(solidGrid, tempA, nx, ny, nz, kernel); + + solidGrid.fill(0); + erodeY(tempA, solidGrid, nx, ny, nz, yHalfExtent); + + let finalCount = 0; + for (let i = 0; i < totalVoxels; i++) { + if (solidGrid[i]) finalCount++; + } + + logger.log(`nav simplify: ${finalCount} solid voxels (from ${solidCount} original, ${reachableCount} reachable carved out)`); + + return denseGridToAccumulator(solidGrid, nx, ny, nz); +}; + +export { simplifyForCapsule }; +export type { NavSeed }; diff --git a/src/lib/write.ts b/src/lib/write.ts index 226d291..f3a85b7 100644 --- a/src/lib/write.ts +++ b/src/lib/write.ts @@ -174,6 +174,8 @@ const writeFile = async (writeOptions: WriteOptions, fs: FileSystem) => { opacityCutoff: options.opacityCutoff, collisionMesh: options.collisionMesh, meshSimplify: options.meshSimplify, + navCapsule: options.navCapsule, + navSeed: options.navSeed, createDevice }, fs); break; diff --git a/src/lib/writers/write-voxel.ts b/src/lib/writers/write-voxel.ts index 12c9b71..69d6635 100644 --- a/src/lib/writers/write-voxel.ts +++ b/src/lib/writers/write-voxel.ts @@ -15,6 +15,7 @@ import { type MultiBatchResult } from '../voxel/index'; import { marchingCubes } from '../voxel/marching-cubes'; +import { simplifyForCapsule, type NavSeed } from '../voxel/nav-simplify'; import { BlockAccumulator, xyzToMorton, @@ -46,6 +47,12 @@ type WriteVoxelOptions = { /** Ratio of triangles to keep when simplifying the collision mesh (0-1). Default: 0.25 */ meshSimplify?: number; + + /** Capsule dimensions for navigation simplification. When set, only voxels contactable from the seed are kept. */ + navCapsule?: { height: number; radius: number }; + + /** Seed position in world space for navigation flood fill. Required when navCapsule is set. */ + navSeed?: NavSeed; }; /** @@ -171,14 +178,20 @@ const writeVoxel = async (options: WriteVoxelOptions, fs: FileSystem): Promise>> 0; +const SOLID_HI = 0xFFFFFFFF >>> 0; + +/** + * Build a Set of all solid voxel indices (ix, iy, iz) from a BlockAccumulator. + */ +function extractSolidVoxels(acc) { + const set = new Set(); + const solid = acc.getSolidBlocks(); + for (let i = 0; i < solid.length; i++) { + const [bx, by, bz] = mortonToXYZ(solid[i]); + for (let lz = 0; lz < 4; lz++) { + for (let ly = 0; ly < 4; ly++) { + for (let lx = 0; lx < 4; lx++) { + set.add(`${(bx << 2) + lx},${(by << 2) + ly},${(bz << 2) + lz}`); + } + } + } + } + const mixed = acc.getMixedBlocks(); + for (let i = 0; i < mixed.morton.length; i++) { + const [bx, by, bz] = mortonToXYZ(mixed.morton[i]); + const lo = mixed.masks[i * 2]; + const hi = mixed.masks[i * 2 + 1]; + for (let lz = 0; lz < 4; lz++) { + for (let ly = 0; ly < 4; ly++) { + for (let lx = 0; lx < 4; lx++) { + const bitIdx = lx + ly * 4 + lz * 16; + const word = bitIdx < 32 ? lo : hi; + const bit = bitIdx < 32 ? bitIdx : bitIdx - 32; + if ((word >>> bit) & 1) { + set.add(`${(bx << 2) + lx},${(by << 2) + ly},${(bz << 2) + lz}`); + } + } + } + } + } + return set; +} + +/** + * Count total solid voxels in a BlockAccumulator. + */ +function countSolidVoxels(acc) { + let count = 0; + const solid = acc.getSolidBlocks(); + count += solid.length * 64; + const mixed = acc.getMixedBlocks(); + for (let i = 0; i < mixed.morton.length; i++) { + count += popcount(mixed.masks[i * 2]) + popcount(mixed.masks[i * 2 + 1]); + } + return count; +} + +/** + * Build a hollow box of solid blocks. The box has solid walls of 1 block thick + * and an empty interior. Returns the accumulator and grid bounds. + * + * @param {number} sizeBlocks - Size of the box in blocks per axis (must be >= 3). + * @param {number} voxelResolution - Voxel resolution. + */ +function buildHollowBox(sizeBlocks, voxelResolution) { + const acc = new BlockAccumulator(); + for (let bz = 0; bz < sizeBlocks; bz++) { + for (let by = 0; by < sizeBlocks; by++) { + for (let bx = 0; bx < sizeBlocks; bx++) { + const isWall = bx === 0 || bx === sizeBlocks - 1 || + by === 0 || by === sizeBlocks - 1 || + bz === 0 || bz === sizeBlocks - 1; + if (isWall) { + acc.addBlock(xyzToMorton(bx, by, bz), SOLID_LO, SOLID_HI); + } + } + } + } + + const worldSize = sizeBlocks * 4 * voxelResolution; + const gridBounds = alignGridBounds(0, 0, 0, worldSize, worldSize, worldSize, voxelResolution); + return { acc, gridBounds }; +} + +describe('simplifyForCapsule', function () { + const voxelResolution = 0.25; + const capsuleHeight = 1.5; + const capsuleRadius = 0.2; + + describe('hollow box', function () { + it('should produce solid voxels for non-reachable space', function () { + const { acc, gridBounds } = buildHollowBox(6, voxelResolution); + + const centerWorld = (gridBounds.min.x + gridBounds.max.x) / 2; + const seed = { x: centerWorld, y: centerWorld, z: centerWorld }; + + const result = simplifyForCapsule(acc, gridBounds, voxelResolution, capsuleHeight, capsuleRadius, seed); + const resultCount = countSolidVoxels(result); + + assert.ok(resultCount > 0, + 'Should produce solid voxels for non-reachable space'); + }); + + it('should have more solid voxels than original walls (fills non-reachable space)', function () { + const { acc, gridBounds } = buildHollowBox(6, voxelResolution); + + const centerWorld = (gridBounds.min.x + gridBounds.max.x) / 2; + const seed = { x: centerWorld, y: centerWorld, z: centerWorld }; + + const originalCount = countSolidVoxels(acc); + const result = simplifyForCapsule(acc, gridBounds, voxelResolution, capsuleHeight, capsuleRadius, seed); + const resultCount = countSolidVoxels(result); + + assert.ok(resultCount >= originalCount, + `Result (${resultCount}) should have at least as many solids as original walls (${originalCount})`); + }); + + it('should not include reachable cells as solid', function () { + const { acc, gridBounds } = buildHollowBox(6, voxelResolution); + + const centerWorld = (gridBounds.min.x + gridBounds.max.x) / 2; + const seed = { x: centerWorld, y: centerWorld, z: centerWorld }; + + const result = simplifyForCapsule(acc, gridBounds, voxelResolution, capsuleHeight, capsuleRadius, seed); + + const resultCount = countSolidVoxels(result); + const nx = Math.round((gridBounds.max.x - gridBounds.min.x) / voxelResolution); + const totalCells = nx * nx * nx; + + assert.ok(resultCount < totalCells, + `Result (${resultCount}) must leave reachable cells empty (total grid: ${totalCells})`); + }); + }); + + describe('seed validation', function () { + it('should return original accumulator if seed is outside grid', function () { + const { acc, gridBounds } = buildHollowBox(4, voxelResolution); + + const seed = { x: -100, y: -100, z: -100 }; + const result = simplifyForCapsule(acc, gridBounds, voxelResolution, capsuleHeight, capsuleRadius, seed); + + assert.strictEqual(countSolidVoxels(result), countSolidVoxels(acc), + 'Should return original when seed is outside grid'); + }); + + it('should return original accumulator if seed is in solid region', function () { + const { acc, gridBounds } = buildHollowBox(4, voxelResolution); + + // Seed at (0,0,0) which is inside a wall block + const seed = { + x: gridBounds.min.x + voxelResolution, + y: gridBounds.min.y + voxelResolution, + z: gridBounds.min.z + voxelResolution + }; + const result = simplifyForCapsule(acc, gridBounds, voxelResolution, capsuleHeight, capsuleRadius, seed); + + assert.strictEqual(countSolidVoxels(result), countSolidVoxels(acc), + 'Should return original when seed is in blocked region'); + }); + }); + + describe('empty accumulator', function () { + it('should carve out all reachable space (no obstacles)', function () { + const acc = new BlockAccumulator(); + const gridBounds = alignGridBounds(0, 0, 0, 1, 1, 1, voxelResolution); + const seed = { x: 0.5, y: 0.5, z: 0.5 }; + + const result = simplifyForCapsule(acc, gridBounds, voxelResolution, capsuleHeight, capsuleRadius, seed); + const resultCount = countSolidVoxels(result); + const nx = Math.round((gridBounds.max.x - gridBounds.min.x) / voxelResolution); + const ny = Math.round((gridBounds.max.y - gridBounds.min.y) / voxelResolution); + const nz = Math.round((gridBounds.max.z - gridBounds.min.z) / voxelResolution); + const totalCells = nx * ny * nz; + + assert.ok(resultCount < totalCells, + 'With no obstacles the entire grid is reachable; most cells should be empty'); + }); + }); + + describe('single solid block', function () { + it('should fill non-reachable space around the block', function () { + const acc = new BlockAccumulator(); + acc.addBlock(xyzToMorton(2, 2, 2), SOLID_LO, SOLID_HI); + + const gridBounds = alignGridBounds(0, 0, 0, 5, 5, 5, voxelResolution); + const blockMinX = 2 * 4 * voxelResolution; + const seed = { x: blockMinX - capsuleRadius - voxelResolution, y: 2 * 4 * voxelResolution + 2 * voxelResolution, z: 2 * 4 * voxelResolution + 2 * voxelResolution }; + + const result = simplifyForCapsule(acc, gridBounds, voxelResolution, capsuleHeight, capsuleRadius, seed); + + const resultCount = countSolidVoxels(result); + assert.ok(resultCount >= 64, + 'Should include at least the original block plus non-reachable cells'); + }); + }); + + describe('unreachable regions', function () { + it('should fill unreachable space outside a sealed room as solid', function () { + const sizeBlocks = 6; + const acc = new BlockAccumulator(); + + for (let bz = 0; bz < sizeBlocks; bz++) { + for (let by = 0; by < sizeBlocks; by++) { + for (let bx = 0; bx < sizeBlocks; bx++) { + const isWall = bx === 0 || bx === sizeBlocks - 1 || + by === 0 || by === sizeBlocks - 1 || + bz === 0 || bz === sizeBlocks - 1; + if (isWall) { + acc.addBlock(xyzToMorton(bx, by, bz), SOLID_LO, SOLID_HI); + } + } + } + } + + const totalSize = (sizeBlocks + 4) * 4 * voxelResolution; + const gridBounds = alignGridBounds(0, 0, 0, totalSize, totalSize, totalSize, voxelResolution); + + const centerWorld = sizeBlocks * 4 * voxelResolution / 2; + const seed = { x: centerWorld, y: centerWorld, z: centerWorld }; + + const originalCount = countSolidVoxels(acc); + const result = simplifyForCapsule(acc, gridBounds, voxelResolution, capsuleHeight, capsuleRadius, seed); + const resultCount = countSolidVoxels(result); + + assert.ok(resultCount > originalCount, + `Result (${resultCount}) should be larger than original walls (${originalCount}) because all unreachable exterior space is filled`); + + const nx = Math.round((gridBounds.max.x - gridBounds.min.x) / voxelResolution); + const ny = Math.round((gridBounds.max.y - gridBounds.min.y) / voxelResolution); + const nz = Math.round((gridBounds.max.z - gridBounds.min.z) / voxelResolution); + const totalCells = nx * ny * nz; + assert.ok(resultCount < totalCells, + `Result (${resultCount}) should leave interior reachable cells empty (total: ${totalCells})`); + }); + }); +}); From d20839f94689572862bc8d01a909ae89f3680837 Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Fri, 20 Mar 2026 13:27:19 +0000 Subject: [PATCH 02/18] latest --- test/nav-simplify.test.mjs | 34 ++++++++++------------------------ 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/test/nav-simplify.test.mjs b/test/nav-simplify.test.mjs index dd75b0e..e16c2a4 100644 --- a/test/nav-simplify.test.mjs +++ b/test/nav-simplify.test.mjs @@ -2,8 +2,8 @@ * Tests for capsule-traced navigation voxel simplification. * * Constructs small voxel scenes (hollow boxes, corridors) using BlockAccumulator, - * runs simplifyForCapsule, and verifies the output uses negative space carving: - * reachable cells are empty, everything else is solid. + * runs simplifyForCapsule, and verifies the output uses negative space carving + * with erosion to restore correct surface positions. */ import { describe, it } from 'node:test'; @@ -106,7 +106,7 @@ describe('simplifyForCapsule', function () { const capsuleRadius = 0.2; describe('hollow box', function () { - it('should produce solid voxels for non-reachable space', function () { + it('should produce solid voxels around the navigable space', function () { const { acc, gridBounds } = buildHollowBox(6, voxelResolution); const centerWorld = (gridBounds.min.x + gridBounds.max.x) / 2; @@ -116,21 +116,7 @@ describe('simplifyForCapsule', function () { const resultCount = countSolidVoxels(result); assert.ok(resultCount > 0, - 'Should produce solid voxels for non-reachable space'); - }); - - it('should have more solid voxels than original walls (fills non-reachable space)', function () { - const { acc, gridBounds } = buildHollowBox(6, voxelResolution); - - const centerWorld = (gridBounds.min.x + gridBounds.max.x) / 2; - const seed = { x: centerWorld, y: centerWorld, z: centerWorld }; - - const originalCount = countSolidVoxels(acc); - const result = simplifyForCapsule(acc, gridBounds, voxelResolution, capsuleHeight, capsuleRadius, seed); - const resultCount = countSolidVoxels(result); - - assert.ok(resultCount >= originalCount, - `Result (${resultCount}) should have at least as many solids as original walls (${originalCount})`); + 'Should produce solid voxels around the navigable space'); }); it('should not include reachable cells as solid', function () { @@ -196,7 +182,7 @@ describe('simplifyForCapsule', function () { }); describe('single solid block', function () { - it('should fill non-reachable space around the block', function () { + it('should retain solid voxels around the block', function () { const acc = new BlockAccumulator(); acc.addBlock(xyzToMorton(2, 2, 2), SOLID_LO, SOLID_HI); @@ -207,13 +193,13 @@ describe('simplifyForCapsule', function () { const result = simplifyForCapsule(acc, gridBounds, voxelResolution, capsuleHeight, capsuleRadius, seed); const resultCount = countSolidVoxels(result); - assert.ok(resultCount >= 64, - 'Should include at least the original block plus non-reachable cells'); + assert.ok(resultCount > 0, + 'Should retain solid voxels near the reachable space'); }); }); describe('unreachable regions', function () { - it('should fill unreachable space outside a sealed room as solid', function () { + it('should fill unreachable exterior as solid', function () { const sizeBlocks = 6; const acc = new BlockAccumulator(); @@ -241,14 +227,14 @@ describe('simplifyForCapsule', function () { const resultCount = countSolidVoxels(result); assert.ok(resultCount > originalCount, - `Result (${resultCount}) should be larger than original walls (${originalCount}) because all unreachable exterior space is filled`); + `Result (${resultCount}) should be larger than original walls (${originalCount}) because unreachable exterior is filled solid`); const nx = Math.round((gridBounds.max.x - gridBounds.min.x) / voxelResolution); const ny = Math.round((gridBounds.max.y - gridBounds.min.y) / voxelResolution); const nz = Math.round((gridBounds.max.z - gridBounds.min.z) / voxelResolution); const totalCells = nx * ny * nz; assert.ok(resultCount < totalCells, - `Result (${resultCount}) should leave interior reachable cells empty (total: ${totalCells})`); + `Result (${resultCount}) should leave reachable interior empty (total: ${totalCells})`); }); }); }); From af7777de73d1375a662b3c4d83f8d8b219514376 Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Sat, 21 Mar 2026 22:16:56 +0000 Subject: [PATCH 03/18] latest --- src/cli/index.ts | 2 +- src/lib/voxel/nav-simplify.ts | 592 ++++++++++++++++++++++------------ test/nav-simplify.test.mjs | 7 +- 3 files changed, 395 insertions(+), 206 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index bf906d7..5486936 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -476,7 +476,7 @@ const main = async () => { const prev = Math.round(displaySteps * (node.step - 1) / node.totalSteps); if (curr > prev) process.stderr.write('#'.repeat(curr - prev)); if (node.step === node.totalSteps) { - process.stderr.write(` done in ${hrtimeDelta(start, hrtime()).toFixed(3)}s 🎉\n`); + process.stderr.write(` took ${hrtimeDelta(start, hrtime()).toFixed(3)}s\n`); } } } diff --git a/src/lib/voxel/nav-simplify.ts b/src/lib/voxel/nav-simplify.ts index 2919601..62bc8c7 100644 --- a/src/lib/voxel/nav-simplify.ts +++ b/src/lib/voxel/nav-simplify.ts @@ -1,6 +1,9 @@ +import { Vec3 } from 'playcanvas'; + import { BlockAccumulator, mortonToXYZ, + popcount, xyzToMorton, type Bounds } from './sparse-octree'; @@ -16,36 +19,18 @@ type NavSeed = { }; /** - * Build a list of (dx, dz) offsets forming a filled circle in the XZ plane. - * - * @param radius - Circle radius in voxel units. - * @returns Array of [dx, dz] offset pairs within the circle. - */ -const buildCircularKernel = (radius: number): number[][] => { - const offsets: number[][] = []; - const r2 = radius * radius; - for (let dx = -radius; dx <= radius; dx++) { - for (let dz = -radius; dz <= radius; dz++) { - if (dx * dx + dz * dz <= r2) { - offsets.push([dx, dz]); - } - } - } - return offsets; -}; - -/** - * Populate a dense solid grid from a BlockAccumulator. + * Populate a bitfield grid from a BlockAccumulator. + * Each bit in the Uint32Array represents one voxel (1 = solid). * * @param accumulator - Source block data. - * @param grid - Pre-allocated Uint8Array (nx * ny * nz), zeroed. + * @param grid - Pre-allocated Uint32Array (ceil(nx*ny*nz / 32)), zeroed. * @param nx - Grid X dimension in voxels. * @param ny - Grid Y dimension. * @param nz - Grid Z dimension. */ const fillDenseSolidGrid = ( accumulator: BlockAccumulator, - grid: Uint8Array, + grid: Uint32Array, nx: number, ny: number, nz: number ): void => { const stride = nx * ny; @@ -65,7 +50,10 @@ const fillDenseSolidGrid = ( const rowOff = iz * stride + iy * nx; for (let lx = 0; lx < 4; lx++) { const ix = baseX + lx; - if (ix < nx) grid[rowOff + ix] = 1; + if (ix < nx) { + const idx = rowOff + ix; + grid[idx >>> 5] |= (1 << (idx & 31)); + } } } } @@ -92,7 +80,10 @@ const fillDenseSolidGrid = ( const bit = bitIdx < 32 ? bitIdx : bitIdx - 32; if ((word >>> bit) & 1) { const ix = baseX + lx; - if (ix < nx) grid[rowOff + ix] = 1; + if (ix < nx) { + const idx = rowOff + ix; + grid[idx >>> 5] |= (1 << (idx & 31)); + } } } } @@ -101,39 +92,43 @@ const fillDenseSolidGrid = ( }; /** - * XZ morphological dilation: for each cell matching `matchValue` in `src`, - * mark all cells within the circular kernel in `dst`. + * X-axis morphological dilation via sliding window (bitfield version). + * A cell is marked if any cell within `halfExtent` in X is set. * - * @param src - Source grid. - * @param dst - Destination grid (must be pre-zeroed). + * @param src - Source bitfield. + * @param dst - Destination bitfield (must be pre-zeroed). * @param nx - Grid X dimension. * @param ny - Grid Y dimension. * @param nz - Grid Z dimension. - * @param kernel - Circular kernel offsets [dx, dz]. - * @param matchValue - Value to match in src. + * @param halfExtent - Half-window size in voxels. */ -const dilateXZ = ( - src: Uint8Array, - dst: Uint8Array, +const dilateX = ( + src: Uint32Array, dst: Uint32Array, nx: number, ny: number, nz: number, - kernel: number[][], - matchValue: number + halfExtent: number ): void => { const stride = nx * ny; - const kLen = kernel.length; - for (let iz = 0; iz < nz; iz++) { - const zOff = iz * stride; for (let iy = 0; iy < ny; iy++) { - const yzOff = zOff + iy * nx; + const rowOff = iz * stride + iy * nx; + let count = 0; + const winEnd = Math.min(halfExtent, nx - 1); + for (let ix = 0; ix <= winEnd; ix++) { + const idx = rowOff + ix; + if ((src[idx >>> 5] >>> (idx & 31)) & 1) count++; + } for (let ix = 0; ix < nx; ix++) { - if (src[yzOff + ix] !== matchValue) continue; - for (let k = 0; k < kLen; k++) { - const kx = ix + kernel[k][0]; - const kz = iz + kernel[k][1]; - if (kx >= 0 && kx < nx && kz >= 0 && kz < nz) { - dst[kz * stride + iy * nx + kx] = 1; - } + const idx = rowOff + ix; + if (count > 0) dst[idx >>> 5] |= (1 << (idx & 31)); + const exitX = ix - halfExtent; + if (exitX >= 0) { + const ei = rowOff + exitX; + if ((src[ei >>> 5] >>> (ei & 31)) & 1) count--; + } + const enterX = ix + halfExtent + 1; + if (enterX < nx) { + const ni = rowOff + enterX; + if ((src[ni >>> 5] >>> (ni & 31)) & 1) count++; } } } @@ -141,154 +136,254 @@ const dilateXZ = ( }; /** - * Y-axis morphological dilation via sliding window. - * For each column, a cell is marked if any cell within `halfExtent` in Y - * is set in the source. + * Y-axis morphological dilation via sliding window (bitfield version). + * A cell is marked if any cell within `halfExtent` in Y is set. * - * @param src - Source grid. - * @param dst - Destination grid (must be pre-zeroed). + * @param src - Source bitfield. + * @param dst - Destination bitfield (must be pre-zeroed). * @param nx - Grid X dimension. * @param ny - Grid Y dimension. * @param nz - Grid Z dimension. * @param halfExtent - Half-window size in voxels. */ const dilateY = ( - src: Uint8Array, - dst: Uint8Array, + src: Uint32Array, dst: Uint32Array, nx: number, ny: number, nz: number, halfExtent: number ): void => { const stride = nx * ny; - for (let iz = 0; iz < nz; iz++) { const zOff = iz * stride; for (let ix = 0; ix < nx; ix++) { let count = 0; const winEnd = Math.min(halfExtent, ny - 1); for (let iy = 0; iy <= winEnd; iy++) { - if (src[zOff + iy * nx + ix]) count++; + const idx = zOff + iy * nx + ix; + if ((src[idx >>> 5] >>> (idx & 31)) & 1) count++; } - for (let iy = 0; iy < ny; iy++) { - if (count > 0) dst[zOff + iy * nx + ix] = 1; - + const idx = zOff + iy * nx + ix; + if (count > 0) dst[idx >>> 5] |= (1 << (idx & 31)); const exitY = iy - halfExtent; - if (exitY >= 0 && src[zOff + exitY * nx + ix]) count--; - + if (exitY >= 0) { + const ei = zOff + exitY * nx + ix; + if ((src[ei >>> 5] >>> (ei & 31)) & 1) count--; + } const enterY = iy + halfExtent + 1; - if (enterY < ny && src[zOff + enterY * nx + ix]) count++; + if (enterY < ny) { + const ni = zOff + enterY * nx + ix; + if ((src[ni >>> 5] >>> (ni & 31)) & 1) count++; + } } } } }; /** - * XZ morphological erosion: a cell remains solid only if ALL cells within the - * circular kernel are solid in `src`. Out-of-bounds cells are treated as solid - * (grid boundary convention). + * Z-axis morphological dilation via sliding window (bitfield version). + * A cell is marked if any cell within `halfExtent` in Z is set. * - * @param src - Source grid. - * @param dst - Destination grid (must be pre-zeroed). + * @param src - Source bitfield. + * @param dst - Destination bitfield (must be pre-zeroed). * @param nx - Grid X dimension. * @param ny - Grid Y dimension. * @param nz - Grid Z dimension. - * @param kernel - Circular kernel offsets [dx, dz]. + * @param halfExtent - Half-window size in voxels. */ -const erodeXZ = ( - src: Uint8Array, - dst: Uint8Array, +const dilateZ = ( + src: Uint32Array, dst: Uint32Array, nx: number, ny: number, nz: number, - kernel: number[][] + halfExtent: number ): void => { const stride = nx * ny; - const kLen = kernel.length; + for (let iy = 0; iy < ny; iy++) { + for (let ix = 0; ix < nx; ix++) { + let count = 0; + const winEnd = Math.min(halfExtent, nz - 1); + for (let iz = 0; iz <= winEnd; iz++) { + const idx = iz * stride + iy * nx + ix; + if ((src[idx >>> 5] >>> (idx & 31)) & 1) count++; + } + for (let iz = 0; iz < nz; iz++) { + const idx = iz * stride + iy * nx + ix; + if (count > 0) dst[idx >>> 5] |= (1 << (idx & 31)); + const exitZ = iz - halfExtent; + if (exitZ >= 0) { + const ei = exitZ * stride + iy * nx + ix; + if ((src[ei >>> 5] >>> (ei & 31)) & 1) count--; + } + const enterZ = iz + halfExtent + 1; + if (enterZ < nz) { + const ni = enterZ * stride + iy * nx + ix; + if ((src[ni >>> 5] >>> (ni & 31)) & 1) count++; + } + } + } + } +}; +/** + * X-axis morphological erosion via sliding window (bitfield version). + * A cell remains solid only if ALL cells within `halfExtent` in X are solid. + * Out-of-bounds cells are treated as solid (grid boundary convention). + * + * @param src - Source bitfield. + * @param dst - Destination bitfield (must be pre-zeroed). + * @param nx - Grid X dimension. + * @param ny - Grid Y dimension. + * @param nz - Grid Z dimension. + * @param halfExtent - Half-window size in voxels. + */ +const erodeX = ( + src: Uint32Array, dst: Uint32Array, + nx: number, ny: number, nz: number, + halfExtent: number +): void => { + const stride = nx * ny; for (let iz = 0; iz < nz; iz++) { - const zOff = iz * stride; for (let iy = 0; iy < ny; iy++) { - const yzOff = zOff + iy * nx; + const rowOff = iz * stride + iy * nx; + let zeroCount = 0; + const winEnd = Math.min(halfExtent, nx - 1); + for (let ix = 0; ix <= winEnd; ix++) { + const idx = rowOff + ix; + if (!((src[idx >>> 5] >>> (idx & 31)) & 1)) zeroCount++; + } for (let ix = 0; ix < nx; ix++) { - let allSolid = true; - for (let k = 0; k < kLen; k++) { - const kx = ix + kernel[k][0]; - const kz = iz + kernel[k][1]; - if (kx >= 0 && kx < nx && kz >= 0 && kz < nz) { - if (!src[kz * stride + iy * nx + kx]) { - allSolid = false; - break; - } - } + const idx = rowOff + ix; + if (zeroCount === 0) dst[idx >>> 5] |= (1 << (idx & 31)); + const exitX = ix - halfExtent; + if (exitX >= 0) { + const ei = rowOff + exitX; + if (!((src[ei >>> 5] >>> (ei & 31)) & 1)) zeroCount--; + } + const enterX = ix + halfExtent + 1; + if (enterX < nx) { + const ni = rowOff + enterX; + if (!((src[ni >>> 5] >>> (ni & 31)) & 1)) zeroCount++; } - if (allSolid) dst[yzOff + ix] = 1; } } } }; /** - * Y-axis morphological erosion via sliding window. + * Y-axis morphological erosion via sliding window (bitfield version). * A cell remains solid only if ALL cells within `halfExtent` in Y are solid. * Out-of-bounds cells are treated as solid (grid boundary convention). * - * @param src - Source grid. - * @param dst - Destination grid (must be pre-zeroed). + * @param src - Source bitfield. + * @param dst - Destination bitfield (must be pre-zeroed). * @param nx - Grid X dimension. * @param ny - Grid Y dimension. * @param nz - Grid Z dimension. * @param halfExtent - Half-window size in voxels. */ const erodeY = ( - src: Uint8Array, - dst: Uint8Array, + src: Uint32Array, dst: Uint32Array, nx: number, ny: number, nz: number, halfExtent: number ): void => { const stride = nx * ny; - for (let iz = 0; iz < nz; iz++) { const zOff = iz * stride; for (let ix = 0; ix < nx; ix++) { let zeroCount = 0; const winEnd = Math.min(halfExtent, ny - 1); for (let iy = 0; iy <= winEnd; iy++) { - if (!src[zOff + iy * nx + ix]) zeroCount++; + const idx = zOff + iy * nx + ix; + if (!((src[idx >>> 5] >>> (idx & 31)) & 1)) zeroCount++; } - for (let iy = 0; iy < ny; iy++) { - if (zeroCount === 0) dst[zOff + iy * nx + ix] = 1; - + const idx = zOff + iy * nx + ix; + if (zeroCount === 0) dst[idx >>> 5] |= (1 << (idx & 31)); const exitY = iy - halfExtent; - if (exitY >= 0 && !src[zOff + exitY * nx + ix]) zeroCount--; - + if (exitY >= 0) { + const ei = zOff + exitY * nx + ix; + if (!((src[ei >>> 5] >>> (ei & 31)) & 1)) zeroCount--; + } const enterY = iy + halfExtent + 1; - if (enterY < ny && !src[zOff + enterY * nx + ix]) zeroCount++; + if (enterY < ny) { + const ni = zOff + enterY * nx + ix; + if (!((src[ni >>> 5] >>> (ni & 31)) & 1)) zeroCount++; + } } } } }; /** - * Convert a dense boolean grid back into a BlockAccumulator. + * Z-axis morphological erosion via sliding window (bitfield version). + * A cell remains solid only if ALL cells within `halfExtent` in Z are solid. + * Out-of-bounds cells are treated as solid (grid boundary convention). + * + * @param src - Source bitfield. + * @param dst - Destination bitfield (must be pre-zeroed). + * @param nx - Grid X dimension. + * @param ny - Grid Y dimension. + * @param nz - Grid Z dimension. + * @param halfExtent - Half-window size in voxels. + */ +const erodeZ = ( + src: Uint32Array, dst: Uint32Array, + nx: number, ny: number, nz: number, + halfExtent: number +): void => { + const stride = nx * ny; + for (let iy = 0; iy < ny; iy++) { + for (let ix = 0; ix < nx; ix++) { + let zeroCount = 0; + const winEnd = Math.min(halfExtent, nz - 1); + for (let iz = 0; iz <= winEnd; iz++) { + const idx = iz * stride + iy * nx + ix; + if (!((src[idx >>> 5] >>> (idx & 31)) & 1)) zeroCount++; + } + for (let iz = 0; iz < nz; iz++) { + const idx = iz * stride + iy * nx + ix; + if (zeroCount === 0) dst[idx >>> 5] |= (1 << (idx & 31)); + const exitZ = iz - halfExtent; + if (exitZ >= 0) { + const ei = exitZ * stride + iy * nx + ix; + if (!((src[ei >>> 5] >>> (ei & 31)) & 1)) zeroCount--; + } + const enterZ = iz + halfExtent + 1; + if (enterZ < nz) { + const ni = enterZ * stride + iy * nx + ix; + if (!((src[ni >>> 5] >>> (ni & 31)) & 1)) zeroCount++; + } + } + } + } +}; + +/** + * Convert a cropped region of a bitfield grid into a BlockAccumulator. + * Block coordinates in the output start at (0,0,0). * - * @param grid - Dense grid with 1 = solid. - * @param nx - Grid X dimension (must be divisible by 4). - * @param ny - Grid Y dimension (must be divisible by 4). - * @param nz - Grid Z dimension (must be divisible by 4). - * @returns New BlockAccumulator with blocks matching the grid. + * @param grid - Bitfield with 1 = solid. + * @param nx - Full grid X dimension. + * @param ny - Full grid Y dimension. + * @param nz - Full grid Z dimension. + * @param cropMinBx - Crop region start block X. + * @param cropMinBy - Crop region start block Y. + * @param cropMinBz - Crop region start block Z. + * @param cropMaxBx - Crop region end block X (exclusive). + * @param cropMaxBy - Crop region end block Y (exclusive). + * @param cropMaxBz - Crop region end block Z (exclusive). + * @returns New BlockAccumulator with blocks from the cropped region. */ const denseGridToAccumulator = ( - grid: Uint8Array, - nx: number, ny: number, nz: number + grid: Uint32Array, + nx: number, ny: number, nz: number, + cropMinBx: number, cropMinBy: number, cropMinBz: number, + cropMaxBx: number, cropMaxBy: number, cropMaxBz: number ): BlockAccumulator => { const acc = new BlockAccumulator(); - const nbx = nx >> 2; - const nby = ny >> 2; - const nbz = nz >> 2; const stride = nx * ny; - for (let bz = 0; bz < nbz; bz++) { - for (let by = 0; by < nby; by++) { - for (let bx = 0; bx < nbx; bx++) { + for (let bz = cropMinBz; bz < cropMaxBz; bz++) { + for (let by = cropMinBy; by < cropMaxBy; by++) { + for (let bx = cropMinBx; bx < cropMaxBx; bx++) { let lo = 0; let hi = 0; const baseX = bx << 2; @@ -298,7 +393,8 @@ const denseGridToAccumulator = ( for (let lz = 0; lz < 4; lz++) { for (let ly = 0; ly < 4; ly++) { for (let lx = 0; lx < 4; lx++) { - if (grid[(baseX + lx) + (baseY + ly) * nx + (baseZ + lz) * stride]) { + const idx = (baseX + lx) + (baseY + ly) * nx + (baseZ + lz) * stride; + if ((grid[idx >>> 5] >>> (idx & 31)) & 1) { const bitIdx = lx + (ly << 2) + (lz << 4); if (bitIdx < 32) { lo |= (1 << bitIdx); @@ -311,7 +407,10 @@ const denseGridToAccumulator = ( } if (lo !== 0 || hi !== 0) { - acc.addBlock(xyzToMorton(bx, by, bz), lo, hi); + acc.addBlock( + xyzToMorton(bx - cropMinBx, by - cropMinBy, bz - cropMinBz), + lo, hi + ); } } } @@ -320,23 +419,25 @@ const denseGridToAccumulator = ( return acc; }; -const FREE = 0; -const BLOCKED = 1; -const REACHABLE = 2; - /** * Simplify voxel collision data for upright capsule navigation. * + * Uses bitfield storage (1 bit per voxel) to reduce memory by 8x compared + * to byte-per-voxel. Two Uint32Array buffers are ping-ponged through the + * dilation, BFS, inversion, and erosion phases. + * * Algorithm: - * 1. Build dense solid grid from the accumulator. + * 1. Build dense bitfield grid from the accumulator. * 2. Dilate solid by the capsule shape (Minkowski sum) to get the clearance * grid -- cells where the capsule center cannot be placed. * 3. BFS flood fill from the seed through free (non-blocked) cells to find - * all reachable capsule-center positions. - * 4. Invert: every non-reachable cell becomes solid (negative space carving). + * all reachable capsule-center positions (uses a separate visited bitfield). + * 4. Invert: every non-reachable cell becomes solid (negative space carving), + * computed as a single bitwise operation per word. * 5. Erode the solid by the capsule shape (Minkowski subtraction) to shrink * surfaces back to their original positions, undoing the inflation from * step 2 so the runtime capsule query produces correct collisions. + * 6. Crop to bounding box of navigable cells. * * Grid boundaries are treated as solid, so the fill is always bounded even * in unsealed scenes. @@ -362,36 +463,39 @@ const simplifyForCapsule = ( const nz = Math.round((gridBounds.max.z - gridBounds.min.z) / voxelResolution); const totalVoxels = nx * ny * nz; const stride = nx * ny; + const wordCount = (totalVoxels + 31) >>> 5; const kernelR = Math.ceil(capsuleRadius / voxelResolution) + 1; const yHalfExtent = Math.ceil(capsuleHeight / (2 * voxelResolution)) + 1; - const kernel = buildCircularKernel(kernelR); - - const memoryMB = Math.round(totalVoxels * 3 / (1024 * 1024)); - logger.debug(`nav simplify: grid ${nx}x${ny}x${nz} (${totalVoxels} voxels, ~${memoryMB} MB), clearance r=${kernelR} (${kernel.length} cells), y half=${yHalfExtent}`); - if (memoryMB > 512) { - logger.warn(`nav simplify: large grid requires ~${memoryMB} MB. Consider using a coarser -R value to reduce memory.`); - } + const memoryMB = Math.round(wordCount * 4 * 2 / (1024 * 1024)); + logger.debug(`nav simplify: grid ${nx}x${ny}x${nz} (${totalVoxels} voxels, ~${memoryMB} MB bitfield), clearance r=${kernelR}, y half=${yHalfExtent}`); - // Phase 1: build dense solid grid from accumulator - const solidGrid = new Uint8Array(totalVoxels); - fillDenseSolidGrid(accumulator, solidGrid, nx, ny, nz); + // Phase 1: build dense bitfield grid from accumulator + let t0 = performance.now(); + const bitA = new Uint32Array(wordCount); + fillDenseSolidGrid(accumulator, bitA, nx, ny, nz); let solidCount = 0; - for (let i = 0; i < totalVoxels; i++) { - if (solidGrid[i]) solidCount++; + for (let w = 0; w < wordCount; w++) { + solidCount += popcount(bitA[w]); } - logger.debug(`nav simplify: ${solidCount} solid voxels`); + logger.debug(`nav simplify: phase 1 (dense grid) ${(performance.now() - t0).toFixed(0)}ms, ${solidCount} solid voxels`); // Phase 2: capsule clearance grid (Minkowski dilation of solid by capsule) - const tempA = new Uint8Array(totalVoxels); - dilateXZ(solidGrid, tempA, nx, ny, nz, kernel, 1); - - const tempB = new Uint8Array(totalVoxels); - dilateY(tempA, tempB, nx, ny, nz, yHalfExtent); - - // Phase 3: flood fill from seed through free (non-blocked) cells + // Three separable 1D sliding window passes (X, Z, Y). + t0 = performance.now(); + const bitB = new Uint32Array(wordCount); + + dilateX(bitA, bitB, nx, ny, nz, kernelR); + bitA.fill(0); + dilateZ(bitB, bitA, nx, ny, nz, kernelR); + bitB.fill(0); + dilateY(bitA, bitB, nx, ny, nz, yHalfExtent); + logger.debug(`nav simplify: phase 2 (dilation) ${(performance.now() - t0).toFixed(0)}ms`); + + // Phase 3: BFS flood fill from seed through free (non-blocked) cells. + // Uses bitB as blocked mask and bitA as visited mask. const seedIx = Math.floor((seed.x - gridBounds.min.x) / voxelResolution); const seedIy = Math.floor((seed.y - gridBounds.min.y) / voxelResolution); const seedIz = Math.floor((seed.z - gridBounds.min.z) / voxelResolution); @@ -402,23 +506,24 @@ const simplifyForCapsule = ( } const seedIdx = seedIx + seedIy * nx + seedIz * stride; - if (tempB[seedIdx] !== FREE) { + if ((bitB[seedIdx >>> 5] >>> (seedIdx & 31)) & 1) { logger.warn(`nav simplify: seed (${seed.x}, ${seed.y}, ${seed.z}) in blocked region, skipping`); return accumulator; } - // BFS flood fill using a Uint32Array circular buffer. - // A JS Array would OOM on large grids because it stores every visited - // cell. The circular buffer only holds the active frontier. + t0 = performance.now(); + bitA.fill(0); // reuse as visited bitfield + const QUEUE_BITS = 25; - const QUEUE_CAP = 1 << QUEUE_BITS; // 32M entries = 128 MB + const QUEUE_CAP = 1 << QUEUE_BITS; const QUEUE_MASK = QUEUE_CAP - 1; const bfsQueue = new Uint32Array(QUEUE_CAP); let qHead = 0; let qTail = 0; let reachableCount = 0; - tempB[seedIdx] = REACHABLE; + // Mark seed as visited + bitA[seedIdx >>> 5] |= (1 << (seedIdx & 31)); bfsQueue[qTail] = seedIdx; qTail = (qTail + 1) & QUEUE_MASK; @@ -431,76 +536,161 @@ const simplifyForCapsule = ( const iy = Math.floor((idx % stride) / nx); const iz = Math.floor(idx / stride); - if (ix > 0 && tempB[idx - 1] === FREE) { - tempB[idx - 1] = REACHABLE; - bfsQueue[qTail] = idx - 1; - qTail = (qTail + 1) & QUEUE_MASK; + // Check 6-connected neighbors: free = not blocked AND not visited. + // OR the blocked and visited words, then test a single bit. + let nIdx: number, w: number, m: number; + if (ix > 0) { + nIdx = idx - 1; + w = nIdx >>> 5; + m = 1 << (nIdx & 31); + if (!((bitB[w] | bitA[w]) & m)) { + bitA[w] |= m; + bfsQueue[qTail] = nIdx; + qTail = (qTail + 1) & QUEUE_MASK; + } } - if (ix < nx - 1 && tempB[idx + 1] === FREE) { - tempB[idx + 1] = REACHABLE; - bfsQueue[qTail] = idx + 1; - qTail = (qTail + 1) & QUEUE_MASK; + if (ix < nx - 1) { + nIdx = idx + 1; + w = nIdx >>> 5; + m = 1 << (nIdx & 31); + if (!((bitB[w] | bitA[w]) & m)) { + bitA[w] |= m; + bfsQueue[qTail] = nIdx; + qTail = (qTail + 1) & QUEUE_MASK; + } } - if (iy > 0 && tempB[idx - nx] === FREE) { - tempB[idx - nx] = REACHABLE; - bfsQueue[qTail] = idx - nx; - qTail = (qTail + 1) & QUEUE_MASK; + if (iy > 0) { + nIdx = idx - nx; + w = nIdx >>> 5; + m = 1 << (nIdx & 31); + if (!((bitB[w] | bitA[w]) & m)) { + bitA[w] |= m; + bfsQueue[qTail] = nIdx; + qTail = (qTail + 1) & QUEUE_MASK; + } } - if (iy < ny - 1 && tempB[idx + nx] === FREE) { - tempB[idx + nx] = REACHABLE; - bfsQueue[qTail] = idx + nx; - qTail = (qTail + 1) & QUEUE_MASK; + if (iy < ny - 1) { + nIdx = idx + nx; + w = nIdx >>> 5; + m = 1 << (nIdx & 31); + if (!((bitB[w] | bitA[w]) & m)) { + bitA[w] |= m; + bfsQueue[qTail] = nIdx; + qTail = (qTail + 1) & QUEUE_MASK; + } } - if (iz > 0 && tempB[idx - stride] === FREE) { - tempB[idx - stride] = REACHABLE; - bfsQueue[qTail] = idx - stride; - qTail = (qTail + 1) & QUEUE_MASK; + if (iz > 0) { + nIdx = idx - stride; + w = nIdx >>> 5; + m = 1 << (nIdx & 31); + if (!((bitB[w] | bitA[w]) & m)) { + bitA[w] |= m; + bfsQueue[qTail] = nIdx; + qTail = (qTail + 1) & QUEUE_MASK; + } } - if (iz < nz - 1 && tempB[idx + stride] === FREE) { - tempB[idx + stride] = REACHABLE; - bfsQueue[qTail] = idx + stride; - qTail = (qTail + 1) & QUEUE_MASK; + if (iz < nz - 1) { + nIdx = idx + stride; + w = nIdx >>> 5; + m = 1 << (nIdx & 31); + if (!((bitB[w] | bitA[w]) & m)) { + bitA[w] |= m; + bfsQueue[qTail] = nIdx; + qTail = (qTail + 1) & QUEUE_MASK; + } } } - logger.debug(`nav simplify: ${reachableCount} reachable cells (${(reachableCount / totalVoxels * 100).toFixed(1)}%)`); + logger.debug(`nav simplify: phase 3 (flood fill) ${(performance.now() - t0).toFixed(0)}ms, ${reachableCount} reachable cells (${(reachableCount / totalVoxels * 100).toFixed(1)}%)`); - // Phase 4: invert reachable to solid - // Everything the capsule cannot reach becomes solid. This produces a - // "negative space" carving: the reachable volume is empty, everything - // else is filled. Large contiguous solid regions compress to single - // SOLID_LEAF_MARKER nodes in the octree, and there are no surface - // holes since every non-reachable cell is solid. - let outputCount = 0; - for (let i = 0; i < totalVoxels; i++) { - if (tempB[i] !== REACHABLE) { - solidGrid[i] = 1; - outputCount++; - } else { - solidGrid[i] = 0; - } + // Phase 4: invert reachable to solid (bitwise operation). + // Reachable = visited AND NOT blocked = bitA AND NOT bitB. + // Solid = NOT reachable = NOT bitA OR bitB = ~bitA | bitB. + t0 = performance.now(); + for (let w = 0; w < wordCount; w++) { + bitB[w] |= ~bitA[w]; } - logger.debug(`nav simplify: ${outputCount} solid voxels after inversion`); + // Clear padding bits in the last word to avoid phantom solids + const tailBits = totalVoxels & 31; + if (tailBits) { + bitB[wordCount - 1] &= (1 << tailBits) - 1; + } + + let outputCount = 0; + for (let w = 0; w < wordCount; w++) { + outputCount += popcount(bitB[w]); + } + logger.debug(`nav simplify: phase 4 (invert) ${(performance.now() - t0).toFixed(0)}ms, ${outputCount} solid voxels`); // Phase 5: erode solid by capsule shape (Minkowski subtraction) - // The inversion inflated the solid boundary by the capsule clearance. - // Eroding by the same kernel shrinks it back to the original surface - // positions so the runtime capsule query produces correct collisions. - tempA.fill(0); - erodeXZ(solidGrid, tempA, nx, ny, nz, kernel); - - solidGrid.fill(0); - erodeY(tempA, solidGrid, nx, ny, nz, yHalfExtent); - - let finalCount = 0; - for (let i = 0; i < totalVoxels; i++) { - if (solidGrid[i]) finalCount++; + t0 = performance.now(); + bitA.fill(0); + erodeX(bitB, bitA, nx, ny, nz, kernelR); + + bitB.fill(0); + erodeZ(bitA, bitB, nx, ny, nz, kernelR); + + bitA.fill(0); + erodeY(bitB, bitA, nx, ny, nz, yHalfExtent); + logger.debug(`nav simplify: phase 5 (erosion) ${(performance.now() - t0).toFixed(0)}ms`); + + // Phase 6: crop to bounding box of empty (navigable) cells + t0 = performance.now(); + let minIx = nx, minIy = ny, minIz = nz; + let maxIx = 0, maxIy = 0, maxIz = 0; + + for (let iz = 0; iz < nz; iz++) { + const zOff = iz * stride; + for (let iy = 0; iy < ny; iy++) { + const rowOff = zOff + iy * nx; + for (let ix = 0; ix < nx; ix++) { + const idx = rowOff + ix; + if (!((bitA[idx >>> 5] >>> (idx & 31)) & 1)) { + if (ix < minIx) minIx = ix; + if (ix > maxIx) maxIx = ix; + if (iy < minIy) minIy = iy; + if (iy > maxIy) maxIy = iy; + if (iz < minIz) minIz = iz; + if (iz > maxIz) maxIz = iz; + } + } + } } - logger.log(`nav simplify: ${finalCount} solid voxels (from ${solidCount} original, ${reachableCount} reachable carved out)`); + const nbx = nx >> 2; + const nby = ny >> 2; + const nbz = nz >> 2; - return denseGridToAccumulator(solidGrid, nx, ny, nz); + const MARGIN = 1; + const cropMinBx = Math.max(0, (minIx >> 2) - MARGIN); + const cropMinBy = Math.max(0, (minIy >> 2) - MARGIN); + const cropMinBz = Math.max(0, (minIz >> 2) - MARGIN); + const cropMaxBx = Math.min(nbx, (maxIx >> 2) + 1 + MARGIN); + const cropMaxBy = Math.min(nby, (maxIy >> 2) + 1 + MARGIN); + const cropMaxBz = Math.min(nbz, (maxIz >> 2) + 1 + MARGIN); + + const blockSize = 4 * voxelResolution; + gridBounds.min = new Vec3( + gridBounds.min.x + cropMinBx * blockSize, + gridBounds.min.y + cropMinBy * blockSize, + gridBounds.min.z + cropMinBz * blockSize + ); + gridBounds.max = new Vec3( + gridBounds.min.x + (cropMaxBx - cropMinBx) * blockSize, + gridBounds.min.y + (cropMaxBy - cropMinBy) * blockSize, + gridBounds.min.z + (cropMaxBz - cropMinBz) * blockSize + ); + + const croppedBlocks = (cropMaxBx - cropMinBx) * (cropMaxBy - cropMinBy) * (cropMaxBz - cropMinBz); + const totalBlocks = nbx * nby * nbz; + logger.log(`nav simplify: phase 6 (crop) ${(performance.now() - t0).toFixed(0)}ms, ${cropMaxBx - cropMinBx}x${cropMaxBy - cropMinBy}x${cropMaxBz - cropMinBz} blocks (${croppedBlocks} of ${totalBlocks})`); + + return denseGridToAccumulator( + bitA, nx, ny, nz, + cropMinBx, cropMinBy, cropMinBz, + cropMaxBx, cropMaxBy, cropMaxBz + ); }; export { simplifyForCapsule }; diff --git a/test/nav-simplify.test.mjs b/test/nav-simplify.test.mjs index e16c2a4..22b9ae9 100644 --- a/test/nav-simplify.test.mjs +++ b/test/nav-simplify.test.mjs @@ -199,7 +199,7 @@ describe('simplifyForCapsule', function () { }); describe('unreachable regions', function () { - it('should fill unreachable exterior as solid', function () { + it('should crop exterior and preserve walls around navigable space', function () { const sizeBlocks = 6; const acc = new BlockAccumulator(); @@ -222,12 +222,11 @@ describe('simplifyForCapsule', function () { const centerWorld = sizeBlocks * 4 * voxelResolution / 2; const seed = { x: centerWorld, y: centerWorld, z: centerWorld }; - const originalCount = countSolidVoxels(acc); const result = simplifyForCapsule(acc, gridBounds, voxelResolution, capsuleHeight, capsuleRadius, seed); const resultCount = countSolidVoxels(result); - assert.ok(resultCount > originalCount, - `Result (${resultCount}) should be larger than original walls (${originalCount}) because unreachable exterior is filled solid`); + assert.ok(resultCount > 0, + 'Should preserve solid walls around the navigable space'); const nx = Math.round((gridBounds.max.x - gridBounds.min.x) / voxelResolution); const ny = Math.round((gridBounds.max.y - gridBounds.min.y) / voxelResolution); From 4e1bbf728ffffdaba6e798143d7bd2bafa3be78a Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Mon, 23 Mar 2026 14:44:58 +0000 Subject: [PATCH 04/18] latest --- src/cli/index.ts | 47 +++++++++++++++++++++++++++-------------------- src/lib/types.ts | 7 +++++-- src/lib/write.ts | 10 +++++++--- 3 files changed, 39 insertions(+), 25 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index 5486936..1182a3f 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -87,8 +87,8 @@ const parseArguments = async () => { 'opacity-cutoff': { type: 'string', short: 'A', default: '0.1' }, 'collision-mesh': { type: 'boolean', short: 'K', default: false }, 'mesh-simplify': { type: 'string', short: 'T', default: '0.25' }, - 'nav-capsule-height': { type: 'string', default: '' }, - 'nav-capsule-radius': { type: 'string', default: '' }, + 'nav-simplify': { type: 'boolean', short: 'n', default: false }, + 'nav-capsule': { type: 'string', default: '' }, 'nav-seed': { type: 'string', default: '' }, // per-file options @@ -170,26 +170,32 @@ const parseArguments = async () => { const viewerSettingsPath = v['viewer-settings']; - // Parse nav capsule options + // Parse nav simplification options + const navCapsuleStr = v['nav-capsule']; const navSeedStr = v['nav-seed']; - const hasNavCapsuleArgs = !!(v['nav-capsule-height'] || v['nav-capsule-radius']); + const navSimplify = v['nav-simplify'] || !!(navCapsuleStr || navSeedStr); let navCapsule: { height: number; radius: number } | undefined; let navSeed: { x: number; y: number; z: number } | undefined; - if (hasNavCapsuleArgs && !navSeedStr) { - throw new Error('--nav-seed is required when using --nav-capsule-height or --nav-capsule-radius'); - } - - if (navSeedStr) { - const parts = navSeedStr.split(',').map(parseNumber); - if (parts.length !== 3) { - throw new Error(`Invalid nav-seed value: ${navSeedStr}. Expected x,y,z`); + if (navSimplify) { + if (navCapsuleStr) { + const parts = navCapsuleStr.split(',').map(parseNumber); + if (parts.length !== 2) { + throw new Error(`Invalid nav-capsule value: ${navCapsuleStr}. Expected height,radius`); + } + navCapsule = { height: parts[0], radius: parts[1] }; + } else { + navCapsule = { height: 1.6, radius: 0.2 }; + } + if (navSeedStr) { + const parts = navSeedStr.split(',').map(parseNumber); + if (parts.length !== 3) { + throw new Error(`Invalid nav-seed value: ${navSeedStr}. Expected x,y,z`); + } + navSeed = { x: parts[0], y: parts[1], z: parts[2] }; + } else { + navSeed = { x: 0, y: 0, z: 0 }; } - navSeed = { x: parts[0], y: parts[1], z: parts[2] }; - navCapsule = { - height: v['nav-capsule-height'] ? parseNumber(v['nav-capsule-height']) : 1.5, - radius: v['nav-capsule-radius'] ? parseNumber(v['nav-capsule-radius']) : 0.2 - }; } const options: CliOptions = { @@ -209,6 +215,7 @@ const parseArguments = async () => { opacityCutoff: parseNumber(v['opacity-cutoff']), collisionMesh: v['collision-mesh'], meshSimplify: parseNumber(v['mesh-simplify']), + navSimplify, navCapsule, navSeed }; @@ -430,9 +437,9 @@ GLOBAL OPTIONS -A, --opacity-cutoff Opacity threshold for solid voxels. Default: 0.1 -K, --collision-mesh Generate collision mesh (.collision.glb) with voxel output -T, --mesh-simplify Ratio of triangles to keep for collision mesh (0-1). Default: 0.25 - --nav-seed Seed position for capsule navigation simplification - --nav-capsule-height Capsule height for nav simplification. Default: 1.5 - --nav-capsule-radius Capsule radius for nav simplification. Default: 0.2 + -n, --nav-simplify Enable capsule navigation simplification for voxel output + --nav-capsule Capsule dimensions for nav simplification. Default: 1.6,0.2 + --nav-seed Seed position for nav simplification. Default: 0,0,0 EXAMPLES # Scale and translate diff --git a/src/lib/types.ts b/src/lib/types.ts index f2a452d..d881ae1 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -32,10 +32,13 @@ type Options = { /** Ratio of triangles to keep when simplifying the collision mesh (0-1). Default: 0.25 */ meshSimplify?: number; - /** Capsule dimensions for navigation simplification. When set with navSeed, simplifies voxel output. */ + /** Enable navigation simplification with default capsule (height 1.6, radius 0.2) and seed (0,0,0). */ + navSimplify?: boolean; + + /** Capsule dimensions (height, radius) for navigation simplification. Default: { height: 1.6, radius: 0.2 } */ navCapsule?: { height: number; radius: number }; - /** Seed position in world space for navigation flood fill. Required when navCapsule is set. */ + /** Seed position in world space for navigation flood fill. Default: { x: 0, y: 0, z: 0 } */ navSeed?: { x: number; y: number; z: number }; }; diff --git a/src/lib/write.ts b/src/lib/write.ts index f3a85b7..07e645d 100644 --- a/src/lib/write.ts +++ b/src/lib/write.ts @@ -166,7 +166,10 @@ const writeFile = async (writeOptions: WriteOptions, fs: FileSystem) => { createDevice }, fs); break; - case 'voxel': + case 'voxel': { + const enableNav = options.navSimplify || !!(options.navCapsule && options.navSeed); + const navCapsule = enableNav ? (options.navCapsule ?? { height: 1.6, radius: 0.2 }) : undefined; + const navSeed = enableNav ? (options.navSeed ?? { x: 0, y: 0, z: 0 }) : undefined; await writeVoxel({ filename, dataTable, @@ -174,11 +177,12 @@ const writeFile = async (writeOptions: WriteOptions, fs: FileSystem) => { opacityCutoff: options.opacityCutoff, collisionMesh: options.collisionMesh, meshSimplify: options.meshSimplify, - navCapsule: options.navCapsule, - navSeed: options.navSeed, + navCapsule, + navSeed, createDevice }, fs); break; + } } }; From 8cbbc88107a2d8241decc37ead6e167125623f2b Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Mon, 23 Mar 2026 15:30:12 +0000 Subject: [PATCH 05/18] latest --- src/lib/voxel/nav-simplify.ts | 66 +++++++++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 6 deletions(-) diff --git a/src/lib/voxel/nav-simplify.ts b/src/lib/voxel/nav-simplify.ts index 62bc8c7..56fc64b 100644 --- a/src/lib/voxel/nav-simplify.ts +++ b/src/lib/voxel/nav-simplify.ts @@ -419,6 +419,47 @@ const denseGridToAccumulator = ( return acc; }; +/** + * Search outward from a blocked seed in expanding Chebyshev shells to find + * the nearest free (non-blocked) voxel in the dilated clearance grid. + * + * @param blocked - Dilated bitfield (1 = blocked). + * @param seedIx - Seed voxel X index. + * @param seedIy - Seed voxel Y index. + * @param seedIz - Seed voxel Z index. + * @param nx - Grid X dimension. + * @param ny - Grid Y dimension. + * @param nz - Grid Z dimension. + * @param stride - Row stride (nx * ny). + * @param maxRadius - Maximum Chebyshev distance to search. + * @returns Grid coordinates of the nearest free cell, or null if none found. + */ +const findNearestFreeCell = ( + blocked: Uint32Array, + seedIx: number, seedIy: number, seedIz: number, + nx: number, ny: number, nz: number, stride: number, + maxRadius: number +): { ix: number; iy: number; iz: number } | null => { + for (let r = 1; r <= maxRadius; r++) { + for (let dz = -r; dz <= r; dz++) { + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + if (Math.abs(dx) !== r && Math.abs(dy) !== r && Math.abs(dz) !== r) continue; + const ix = seedIx + dx; + const iy = seedIy + dy; + const iz = seedIz + dz; + if (ix < 0 || ix >= nx || iy < 0 || iy >= ny || iz < 0 || iz >= nz) continue; + const idx = ix + iy * nx + iz * stride; + if (!((blocked[idx >>> 5] >>> (idx & 31)) & 1)) { + return { ix, iy, iz }; + } + } + } + } + } + return null; +}; + /** * Simplify voxel collision data for upright capsule navigation. * @@ -496,19 +537,32 @@ const simplifyForCapsule = ( // Phase 3: BFS flood fill from seed through free (non-blocked) cells. // Uses bitB as blocked mask and bitA as visited mask. - const seedIx = Math.floor((seed.x - gridBounds.min.x) / voxelResolution); - const seedIy = Math.floor((seed.y - gridBounds.min.y) / voxelResolution); - const seedIz = Math.floor((seed.z - gridBounds.min.z) / voxelResolution); + let seedIx = Math.floor((seed.x - gridBounds.min.x) / voxelResolution); + let seedIy = Math.floor((seed.y - gridBounds.min.y) / voxelResolution); + let seedIz = Math.floor((seed.z - gridBounds.min.z) / voxelResolution); if (seedIx < 0 || seedIx >= nx || seedIy < 0 || seedIy >= ny || seedIz < 0 || seedIz >= nz) { logger.warn(`nav simplify: seed (${seed.x}, ${seed.y}, ${seed.z}) outside grid, skipping`); return accumulator; } - const seedIdx = seedIx + seedIy * nx + seedIz * stride; + let seedIdx = seedIx + seedIy * nx + seedIz * stride; if ((bitB[seedIdx >>> 5] >>> (seedIdx & 31)) & 1) { - logger.warn(`nav simplify: seed (${seed.x}, ${seed.y}, ${seed.z}) in blocked region, skipping`); - return accumulator; + const maxRadius = Math.max(kernelR, yHalfExtent) * 2; + const found = findNearestFreeCell(bitB, seedIx, seedIy, seedIz, nx, ny, nz, stride, maxRadius); + if (!found) { + logger.warn(`nav simplify: seed (${seed.x}, ${seed.y}, ${seed.z}) blocked after dilation, no free cell within ${maxRadius} voxels, skipping`); + return accumulator; + } + const dist = Math.max(Math.abs(found.ix - seedIx), Math.abs(found.iy - seedIy), Math.abs(found.iz - seedIz)); + const worldX = (gridBounds.min.x + (found.ix + 0.5) * voxelResolution).toFixed(2); + const worldY = (gridBounds.min.y + (found.iy + 0.5) * voxelResolution).toFixed(2); + const worldZ = (gridBounds.min.z + (found.iz + 0.5) * voxelResolution).toFixed(2); + logger.log(`nav simplify: seed (${seed.x}, ${seed.y}, ${seed.z}) blocked after dilation, adjusted to (${worldX}, ${worldY}, ${worldZ}) (distance: ${dist} voxels)`); + seedIx = found.ix; + seedIy = found.iy; + seedIz = found.iz; + seedIdx = seedIx + seedIy * nx + seedIz * stride; } t0 = performance.now(); From d62ccf807c979bf75cb3462ccde43daa6f27f114 Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Mon, 23 Mar 2026 15:32:03 +0000 Subject: [PATCH 06/18] latest --- src/cli/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index 1182a3f..7b600bb 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -87,7 +87,7 @@ const parseArguments = async () => { 'opacity-cutoff': { type: 'string', short: 'A', default: '0.1' }, 'collision-mesh': { type: 'boolean', short: 'K', default: false }, 'mesh-simplify': { type: 'string', short: 'T', default: '0.25' }, - 'nav-simplify': { type: 'boolean', short: 'n', default: false }, + 'nav-simplify': { type: 'boolean', short: 'n', default: true }, 'nav-capsule': { type: 'string', default: '' }, 'nav-seed': { type: 'string', default: '' }, @@ -437,7 +437,8 @@ GLOBAL OPTIONS -A, --opacity-cutoff Opacity threshold for solid voxels. Default: 0.1 -K, --collision-mesh Generate collision mesh (.collision.glb) with voxel output -T, --mesh-simplify Ratio of triangles to keep for collision mesh (0-1). Default: 0.25 - -n, --nav-simplify Enable capsule navigation simplification for voxel output + -n, --nav-simplify Capsule navigation simplification for voxel output (default: on) + --no-nav-simplify Disable navigation simplification --nav-capsule Capsule dimensions for nav simplification. Default: 1.6,0.2 --nav-seed Seed position for nav simplification. Default: 0,0,0 From de8e14bd5047eb05c76f52dcf1fd0fdf27cd2c6b Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Mon, 23 Mar 2026 15:50:16 +0000 Subject: [PATCH 07/18] latest --- src/cli/index.ts | 2 +- src/lib/index.ts | 2 +- src/lib/voxel/nav-simplify.ts | 158 ++++++++++++++------------------- src/lib/write.ts | 2 +- src/lib/writers/write-voxel.ts | 6 +- test/nav-simplify.test.mjs | 14 +-- 6 files changed, 83 insertions(+), 101 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index 7b600bb..abd8187 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -173,7 +173,7 @@ const parseArguments = async () => { // Parse nav simplification options const navCapsuleStr = v['nav-capsule']; const navSeedStr = v['nav-seed']; - const navSimplify = v['nav-simplify'] || !!(navCapsuleStr || navSeedStr); + const navSimplify = v['nav-simplify']; let navCapsule: { height: number; radius: number } | undefined; let navSeed: { x: number; y: number; z: number } | undefined; diff --git a/src/lib/index.ts b/src/lib/index.ts index d371d24..bb9df98 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -61,7 +61,7 @@ export { writeGlb } from './writers/write-glb'; export { writeVoxel } from './writers/write-voxel'; export type { WriteVoxelOptions, VoxelMetadata } from './writers/write-voxel'; export { simplifyForCapsule } from './voxel/nav-simplify'; -export type { NavSeed } from './voxel/nav-simplify'; +export type { NavSeed, NavSimplifyResult } from './voxel/nav-simplify'; export { marchingCubes } from './voxel/marching-cubes'; export type { MarchingCubesMesh } from './voxel/marching-cubes'; diff --git a/src/lib/voxel/nav-simplify.ts b/src/lib/voxel/nav-simplify.ts index 56fc64b..c09efde 100644 --- a/src/lib/voxel/nav-simplify.ts +++ b/src/lib/voxel/nav-simplify.ts @@ -18,6 +18,14 @@ type NavSeed = { z: number; }; +/** + * Result of capsule navigation simplification. + */ +type NavSimplifyResult = { + accumulator: BlockAccumulator; + gridBounds: Bounds; +}; + /** * Populate a bitfield grid from a BlockAccumulator. * Each bit in the Uint32Array represents one voxel (1 = solid). @@ -484,12 +492,12 @@ const findNearestFreeCell = ( * in unsealed scenes. * * @param accumulator - BlockAccumulator with filtered voxelization results. - * @param gridBounds - Grid bounds aligned to block boundaries. + * @param gridBounds - Grid bounds aligned to block boundaries (not mutated). * @param voxelResolution - Size of each voxel in world units. * @param capsuleHeight - Total capsule height in world units. * @param capsuleRadius - Capsule radius in world units. * @param seed - Seed position in world space (must be in a free region). - * @returns New BlockAccumulator with simplified collision voxels. + * @returns Simplified accumulator and cropped grid bounds. */ const simplifyForCapsule = ( accumulator: BlockAccumulator, @@ -498,7 +506,7 @@ const simplifyForCapsule = ( capsuleHeight: number, capsuleRadius: number, seed: NavSeed -): BlockAccumulator => { +): NavSimplifyResult => { const nx = Math.round((gridBounds.max.x - gridBounds.min.x) / voxelResolution); const ny = Math.round((gridBounds.max.y - gridBounds.min.y) / voxelResolution); const nz = Math.round((gridBounds.max.z - gridBounds.min.z) / voxelResolution); @@ -506,8 +514,10 @@ const simplifyForCapsule = ( const stride = nx * ny; const wordCount = (totalVoxels + 31) >>> 5; - const kernelR = Math.ceil(capsuleRadius / voxelResolution) + 1; - const yHalfExtent = Math.ceil(capsuleHeight / (2 * voxelResolution)) + 1; + // Capsule approximated as an axis-aligned box (square XZ cross-section). + // Conservative: may reject narrow diagonal passages a true capsule could fit. + const kernelR = Math.ceil(capsuleRadius / voxelResolution); + const yHalfExtent = Math.ceil(capsuleHeight / (2 * voxelResolution)); const memoryMB = Math.round(wordCount * 4 * 2 / (1024 * 1024)); logger.debug(`nav simplify: grid ${nx}x${ny}x${nz} (${totalVoxels} voxels, ~${memoryMB} MB bitfield), clearance r=${kernelR}, y half=${yHalfExtent}`); @@ -543,7 +553,7 @@ const simplifyForCapsule = ( if (seedIx < 0 || seedIx >= nx || seedIy < 0 || seedIy >= ny || seedIz < 0 || seedIz >= nz) { logger.warn(`nav simplify: seed (${seed.x}, ${seed.y}, ${seed.z}) outside grid, skipping`); - return accumulator; + return { accumulator, gridBounds }; } let seedIdx = seedIx + seedIy * nx + seedIz * stride; @@ -552,7 +562,7 @@ const simplifyForCapsule = ( const found = findNearestFreeCell(bitB, seedIx, seedIy, seedIz, nx, ny, nz, stride, maxRadius); if (!found) { logger.warn(`nav simplify: seed (${seed.x}, ${seed.y}, ${seed.z}) blocked after dilation, no free cell within ${maxRadius} voxels, skipping`); - return accumulator; + return { accumulator, gridBounds }; } const dist = Math.max(Math.abs(found.ix - seedIx), Math.abs(found.iy - seedIy), Math.abs(found.iz - seedIz)); const worldX = (gridBounds.min.x + (found.ix + 0.5) * voxelResolution).toFixed(2); @@ -568,91 +578,55 @@ const simplifyForCapsule = ( t0 = performance.now(); bitA.fill(0); // reuse as visited bitfield - const QUEUE_BITS = 25; - const QUEUE_CAP = 1 << QUEUE_BITS; - const QUEUE_MASK = QUEUE_CAP - 1; - const bfsQueue = new Uint32Array(QUEUE_CAP); + const MAX_QUEUE = 1 << 25; + const queueCap = Math.min(totalVoxels, MAX_QUEUE); + const queueMask = queueCap - 1; + const bfsQueue = new Uint32Array(queueCap); let qHead = 0; let qTail = 0; + let queueSize = 0; let reachableCount = 0; + let overflowed = false; + + const enqueue = (nIdx: number) => { + const w = nIdx >>> 5; + const m = 1 << (nIdx & 31); + if (!((bitB[w] | bitA[w]) & m)) { + if (queueSize >= queueCap) { + if (!overflowed) { + logger.warn(`nav simplify: BFS queue overflow (cap ${queueCap}), results may be incomplete`); + overflowed = true; + } + return; + } + bitA[w] |= m; + bfsQueue[qTail] = nIdx; + qTail = (qTail + 1) & queueMask; + queueSize++; + } + }; - // Mark seed as visited bitA[seedIdx >>> 5] |= (1 << (seedIdx & 31)); bfsQueue[qTail] = seedIdx; - qTail = (qTail + 1) & QUEUE_MASK; + qTail = (qTail + 1) & queueMask; + queueSize++; while (qHead !== qTail) { const idx = bfsQueue[qHead]; - qHead = (qHead + 1) & QUEUE_MASK; + qHead = (qHead + 1) & queueMask; + queueSize--; reachableCount++; const ix = idx % nx; const iy = Math.floor((idx % stride) / nx); const iz = Math.floor(idx / stride); - // Check 6-connected neighbors: free = not blocked AND not visited. - // OR the blocked and visited words, then test a single bit. - let nIdx: number, w: number, m: number; - if (ix > 0) { - nIdx = idx - 1; - w = nIdx >>> 5; - m = 1 << (nIdx & 31); - if (!((bitB[w] | bitA[w]) & m)) { - bitA[w] |= m; - bfsQueue[qTail] = nIdx; - qTail = (qTail + 1) & QUEUE_MASK; - } - } - if (ix < nx - 1) { - nIdx = idx + 1; - w = nIdx >>> 5; - m = 1 << (nIdx & 31); - if (!((bitB[w] | bitA[w]) & m)) { - bitA[w] |= m; - bfsQueue[qTail] = nIdx; - qTail = (qTail + 1) & QUEUE_MASK; - } - } - if (iy > 0) { - nIdx = idx - nx; - w = nIdx >>> 5; - m = 1 << (nIdx & 31); - if (!((bitB[w] | bitA[w]) & m)) { - bitA[w] |= m; - bfsQueue[qTail] = nIdx; - qTail = (qTail + 1) & QUEUE_MASK; - } - } - if (iy < ny - 1) { - nIdx = idx + nx; - w = nIdx >>> 5; - m = 1 << (nIdx & 31); - if (!((bitB[w] | bitA[w]) & m)) { - bitA[w] |= m; - bfsQueue[qTail] = nIdx; - qTail = (qTail + 1) & QUEUE_MASK; - } - } - if (iz > 0) { - nIdx = idx - stride; - w = nIdx >>> 5; - m = 1 << (nIdx & 31); - if (!((bitB[w] | bitA[w]) & m)) { - bitA[w] |= m; - bfsQueue[qTail] = nIdx; - qTail = (qTail + 1) & QUEUE_MASK; - } - } - if (iz < nz - 1) { - nIdx = idx + stride; - w = nIdx >>> 5; - m = 1 << (nIdx & 31); - if (!((bitB[w] | bitA[w]) & m)) { - bitA[w] |= m; - bfsQueue[qTail] = nIdx; - qTail = (qTail + 1) & QUEUE_MASK; - } - } + if (ix > 0) enqueue(idx - 1); + if (ix < nx - 1) enqueue(idx + 1); + if (iy > 0) enqueue(idx - nx); + if (iy < ny - 1) enqueue(idx + nx); + if (iz > 0) enqueue(idx - stride); + if (iz < nz - 1) enqueue(idx + stride); } logger.debug(`nav simplify: phase 3 (flood fill) ${(performance.now() - t0).toFixed(0)}ms, ${reachableCount} reachable cells (${(reachableCount / totalVoxels * 100).toFixed(1)}%)`); @@ -725,27 +699,33 @@ const simplifyForCapsule = ( const cropMaxBz = Math.min(nbz, (maxIz >> 2) + 1 + MARGIN); const blockSize = 4 * voxelResolution; - gridBounds.min = new Vec3( + const croppedMin = new Vec3( gridBounds.min.x + cropMinBx * blockSize, gridBounds.min.y + cropMinBy * blockSize, gridBounds.min.z + cropMinBz * blockSize ); - gridBounds.max = new Vec3( - gridBounds.min.x + (cropMaxBx - cropMinBx) * blockSize, - gridBounds.min.y + (cropMaxBy - cropMinBy) * blockSize, - gridBounds.min.z + (cropMaxBz - cropMinBz) * blockSize - ); + const croppedBounds: Bounds = { + min: croppedMin, + max: new Vec3( + croppedMin.x + (cropMaxBx - cropMinBx) * blockSize, + croppedMin.y + (cropMaxBy - cropMinBy) * blockSize, + croppedMin.z + (cropMaxBz - cropMinBz) * blockSize + ) + }; const croppedBlocks = (cropMaxBx - cropMinBx) * (cropMaxBy - cropMinBy) * (cropMaxBz - cropMinBz); const totalBlocks = nbx * nby * nbz; logger.log(`nav simplify: phase 6 (crop) ${(performance.now() - t0).toFixed(0)}ms, ${cropMaxBx - cropMinBx}x${cropMaxBy - cropMinBy}x${cropMaxBz - cropMinBz} blocks (${croppedBlocks} of ${totalBlocks})`); - return denseGridToAccumulator( - bitA, nx, ny, nz, - cropMinBx, cropMinBy, cropMinBz, - cropMaxBx, cropMaxBy, cropMaxBz - ); + return { + accumulator: denseGridToAccumulator( + bitA, nx, ny, nz, + cropMinBx, cropMinBy, cropMinBz, + cropMaxBx, cropMaxBy, cropMaxBz + ), + gridBounds: croppedBounds + }; }; export { simplifyForCapsule }; -export type { NavSeed }; +export type { NavSeed, NavSimplifyResult }; diff --git a/src/lib/write.ts b/src/lib/write.ts index 07e645d..7010daf 100644 --- a/src/lib/write.ts +++ b/src/lib/write.ts @@ -167,7 +167,7 @@ const writeFile = async (writeOptions: WriteOptions, fs: FileSystem) => { }, fs); break; case 'voxel': { - const enableNav = options.navSimplify || !!(options.navCapsule && options.navSeed); + const enableNav = !!options.navSimplify; const navCapsule = enableNav ? (options.navCapsule ?? { height: 1.6, radius: 0.2 }) : undefined; const navSeed = enableNav ? (options.navSeed ?? { x: 0, y: 0, z: 0 }) : undefined; await writeVoxel({ diff --git a/src/lib/writers/write-voxel.ts b/src/lib/writers/write-voxel.ts index 69d6635..a5a635b 100644 --- a/src/lib/writers/write-voxel.ts +++ b/src/lib/writers/write-voxel.ts @@ -208,7 +208,7 @@ const writeVoxel = async (options: WriteVoxelOptions, fs: FileSystem): Promise 0, 'Should produce solid voxels around the navigable space'); @@ -127,7 +127,7 @@ describe('simplifyForCapsule', function () { const result = simplifyForCapsule(acc, gridBounds, voxelResolution, capsuleHeight, capsuleRadius, seed); - const resultCount = countSolidVoxels(result); + const resultCount = countSolidVoxels(result.accumulator); const nx = Math.round((gridBounds.max.x - gridBounds.min.x) / voxelResolution); const totalCells = nx * nx * nx; @@ -143,7 +143,7 @@ describe('simplifyForCapsule', function () { const seed = { x: -100, y: -100, z: -100 }; const result = simplifyForCapsule(acc, gridBounds, voxelResolution, capsuleHeight, capsuleRadius, seed); - assert.strictEqual(countSolidVoxels(result), countSolidVoxels(acc), + assert.strictEqual(countSolidVoxels(result.accumulator), countSolidVoxels(acc), 'Should return original when seed is outside grid'); }); @@ -158,7 +158,7 @@ describe('simplifyForCapsule', function () { }; const result = simplifyForCapsule(acc, gridBounds, voxelResolution, capsuleHeight, capsuleRadius, seed); - assert.strictEqual(countSolidVoxels(result), countSolidVoxels(acc), + assert.strictEqual(countSolidVoxels(result.accumulator), countSolidVoxels(acc), 'Should return original when seed is in blocked region'); }); }); @@ -170,7 +170,7 @@ describe('simplifyForCapsule', function () { const seed = { x: 0.5, y: 0.5, z: 0.5 }; const result = simplifyForCapsule(acc, gridBounds, voxelResolution, capsuleHeight, capsuleRadius, seed); - const resultCount = countSolidVoxels(result); + const resultCount = countSolidVoxels(result.accumulator); const nx = Math.round((gridBounds.max.x - gridBounds.min.x) / voxelResolution); const ny = Math.round((gridBounds.max.y - gridBounds.min.y) / voxelResolution); const nz = Math.round((gridBounds.max.z - gridBounds.min.z) / voxelResolution); @@ -192,7 +192,7 @@ describe('simplifyForCapsule', function () { const result = simplifyForCapsule(acc, gridBounds, voxelResolution, capsuleHeight, capsuleRadius, seed); - const resultCount = countSolidVoxels(result); + const resultCount = countSolidVoxels(result.accumulator); assert.ok(resultCount > 0, 'Should retain solid voxels near the reachable space'); }); @@ -223,7 +223,7 @@ describe('simplifyForCapsule', function () { const seed = { x: centerWorld, y: centerWorld, z: centerWorld }; const result = simplifyForCapsule(acc, gridBounds, voxelResolution, capsuleHeight, capsuleRadius, seed); - const resultCount = countSolidVoxels(result); + const resultCount = countSolidVoxels(result.accumulator); assert.ok(resultCount > 0, 'Should preserve solid walls around the navigable space'); From 53faac7ce323a51e31551e6519d6ff631457c281 Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Mon, 23 Mar 2026 15:56:43 +0000 Subject: [PATCH 08/18] latest --- src/cli/index.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index abd8187..c44af1f 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -87,7 +87,7 @@ const parseArguments = async () => { 'opacity-cutoff': { type: 'string', short: 'A', default: '0.1' }, 'collision-mesh': { type: 'boolean', short: 'K', default: false }, 'mesh-simplify': { type: 'string', short: 'T', default: '0.25' }, - 'nav-simplify': { type: 'boolean', short: 'n', default: true }, + 'no-nav-simplify': { type: 'boolean', short: 'n', default: false }, 'nav-capsule': { type: 'string', default: '' }, 'nav-seed': { type: 'string', default: '' }, @@ -173,7 +173,7 @@ const parseArguments = async () => { // Parse nav simplification options const navCapsuleStr = v['nav-capsule']; const navSeedStr = v['nav-seed']; - const navSimplify = v['nav-simplify']; + const navSimplify = !v['no-nav-simplify']; let navCapsule: { height: number; radius: number } | undefined; let navSeed: { x: number; y: number; z: number } | undefined; @@ -437,8 +437,7 @@ GLOBAL OPTIONS -A, --opacity-cutoff Opacity threshold for solid voxels. Default: 0.1 -K, --collision-mesh Generate collision mesh (.collision.glb) with voxel output -T, --mesh-simplify Ratio of triangles to keep for collision mesh (0-1). Default: 0.25 - -n, --nav-simplify Capsule navigation simplification for voxel output (default: on) - --no-nav-simplify Disable navigation simplification + -n, --no-nav-simplify Disable capsule navigation simplification for voxel output --nav-capsule Capsule dimensions for nav simplification. Default: 1.6,0.2 --nav-seed Seed position for nav simplification. Default: 0,0,0 From 2d8c58640d2634cf36edee3e66c1ab904e963443 Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Mon, 23 Mar 2026 16:00:34 +0000 Subject: [PATCH 09/18] latest --- src/lib/voxel/nav-simplify.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/voxel/nav-simplify.ts b/src/lib/voxel/nav-simplify.ts index c09efde..1e27343 100644 --- a/src/lib/voxel/nav-simplify.ts +++ b/src/lib/voxel/nav-simplify.ts @@ -579,7 +579,7 @@ const simplifyForCapsule = ( bitA.fill(0); // reuse as visited bitfield const MAX_QUEUE = 1 << 25; - const queueCap = Math.min(totalVoxels, MAX_QUEUE); + const queueCap = Math.min(1 << (32 - Math.clz32(totalVoxels - 1)), MAX_QUEUE); const queueMask = queueCap - 1; const bfsQueue = new Uint32Array(queueCap); let qHead = 0; From a30498c5fbca061866cd8d3d419dc26286551b98 Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Mon, 23 Mar 2026 17:39:33 +0000 Subject: [PATCH 10/18] latest --- src/cli/index.ts | 17 ++++++++------ src/lib/voxel/nav-simplify.ts | 42 ++++++++--------------------------- 2 files changed, 19 insertions(+), 40 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index c44af1f..c87bb83 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -465,16 +465,19 @@ const main = async () => { let start: Timing | null = null; + const err = console.error.bind(console); + const warn = console.warn.bind(console); + // inject Node.js-specific logger - logs go to stderr, data output goes to stdout logger.setLogger({ - log: (...args) => console.error(...args), - warn: (...args) => console.warn(...args), - error: (...args) => console.error(...args), - debug: (...args) => console.error(...args), - output: text => console.log(text), + log: err, + warn: warn, + error: err, + debug: err, + output: console.log.bind(console), onProgress: (node) => { if (node.stepName) { - console.error(`[${node.step}/${node.totalSteps}] ${node.stepName}`); + err(`[${node.step}/${node.totalSteps}] ${node.stepName}`); } else if (node.step === 0) { start = hrtime(); } else { @@ -483,7 +486,7 @@ const main = async () => { const prev = Math.round(displaySteps * (node.step - 1) / node.totalSteps); if (curr > prev) process.stderr.write('#'.repeat(curr - prev)); if (node.step === node.totalSteps) { - process.stderr.write(` took ${hrtimeDelta(start, hrtime()).toFixed(3)}s\n`); + process.stderr.write(` (${hrtimeDelta(start, hrtime()).toFixed(3)}s)\n`); } } } diff --git a/src/lib/voxel/nav-simplify.ts b/src/lib/voxel/nav-simplify.ts index 1e27343..efbf7cf 100644 --- a/src/lib/voxel/nav-simplify.ts +++ b/src/lib/voxel/nav-simplify.ts @@ -3,7 +3,6 @@ import { Vec3 } from 'playcanvas'; import { BlockAccumulator, mortonToXYZ, - popcount, xyzToMorton, type Bounds } from './sparse-octree'; @@ -519,23 +518,15 @@ const simplifyForCapsule = ( const kernelR = Math.ceil(capsuleRadius / voxelResolution); const yHalfExtent = Math.ceil(capsuleHeight / (2 * voxelResolution)); - const memoryMB = Math.round(wordCount * 4 * 2 / (1024 * 1024)); - logger.debug(`nav simplify: grid ${nx}x${ny}x${nz} (${totalVoxels} voxels, ~${memoryMB} MB bitfield), clearance r=${kernelR}, y half=${yHalfExtent}`); + logger.progress.begin(6); // Phase 1: build dense bitfield grid from accumulator - let t0 = performance.now(); const bitA = new Uint32Array(wordCount); fillDenseSolidGrid(accumulator, bitA, nx, ny, nz); - - let solidCount = 0; - for (let w = 0; w < wordCount; w++) { - solidCount += popcount(bitA[w]); - } - logger.debug(`nav simplify: phase 1 (dense grid) ${(performance.now() - t0).toFixed(0)}ms, ${solidCount} solid voxels`); + logger.progress.step(); // Phase 2: capsule clearance grid (Minkowski dilation of solid by capsule) // Three separable 1D sliding window passes (X, Z, Y). - t0 = performance.now(); const bitB = new Uint32Array(wordCount); dilateX(bitA, bitB, nx, ny, nz, kernelR); @@ -543,7 +534,7 @@ const simplifyForCapsule = ( dilateZ(bitB, bitA, nx, ny, nz, kernelR); bitB.fill(0); dilateY(bitA, bitB, nx, ny, nz, yHalfExtent); - logger.debug(`nav simplify: phase 2 (dilation) ${(performance.now() - t0).toFixed(0)}ms`); + logger.progress.step(); // Phase 3: BFS flood fill from seed through free (non-blocked) cells. // Uses bitB as blocked mask and bitA as visited mask. @@ -553,6 +544,7 @@ const simplifyForCapsule = ( if (seedIx < 0 || seedIx >= nx || seedIy < 0 || seedIy >= ny || seedIz < 0 || seedIz >= nz) { logger.warn(`nav simplify: seed (${seed.x}, ${seed.y}, ${seed.z}) outside grid, skipping`); + logger.progress.cancel(); return { accumulator, gridBounds }; } @@ -562,20 +554,15 @@ const simplifyForCapsule = ( const found = findNearestFreeCell(bitB, seedIx, seedIy, seedIz, nx, ny, nz, stride, maxRadius); if (!found) { logger.warn(`nav simplify: seed (${seed.x}, ${seed.y}, ${seed.z}) blocked after dilation, no free cell within ${maxRadius} voxels, skipping`); + logger.progress.cancel(); return { accumulator, gridBounds }; } - const dist = Math.max(Math.abs(found.ix - seedIx), Math.abs(found.iy - seedIy), Math.abs(found.iz - seedIz)); - const worldX = (gridBounds.min.x + (found.ix + 0.5) * voxelResolution).toFixed(2); - const worldY = (gridBounds.min.y + (found.iy + 0.5) * voxelResolution).toFixed(2); - const worldZ = (gridBounds.min.z + (found.iz + 0.5) * voxelResolution).toFixed(2); - logger.log(`nav simplify: seed (${seed.x}, ${seed.y}, ${seed.z}) blocked after dilation, adjusted to (${worldX}, ${worldY}, ${worldZ}) (distance: ${dist} voxels)`); seedIx = found.ix; seedIy = found.iy; seedIz = found.iz; seedIdx = seedIx + seedIy * nx + seedIz * stride; } - t0 = performance.now(); bitA.fill(0); // reuse as visited bitfield const MAX_QUEUE = 1 << 25; @@ -585,7 +572,6 @@ const simplifyForCapsule = ( let qHead = 0; let qTail = 0; let queueSize = 0; - let reachableCount = 0; let overflowed = false; const enqueue = (nIdx: number) => { @@ -615,7 +601,6 @@ const simplifyForCapsule = ( const idx = bfsQueue[qHead]; qHead = (qHead + 1) & queueMask; queueSize--; - reachableCount++; const ix = idx % nx; const iy = Math.floor((idx % stride) / nx); @@ -629,12 +614,11 @@ const simplifyForCapsule = ( if (iz < nz - 1) enqueue(idx + stride); } - logger.debug(`nav simplify: phase 3 (flood fill) ${(performance.now() - t0).toFixed(0)}ms, ${reachableCount} reachable cells (${(reachableCount / totalVoxels * 100).toFixed(1)}%)`); + logger.progress.step(); // Phase 4: invert reachable to solid (bitwise operation). // Reachable = visited AND NOT blocked = bitA AND NOT bitB. // Solid = NOT reachable = NOT bitA OR bitB = ~bitA | bitB. - t0 = performance.now(); for (let w = 0; w < wordCount; w++) { bitB[w] |= ~bitA[w]; } @@ -645,14 +629,9 @@ const simplifyForCapsule = ( bitB[wordCount - 1] &= (1 << tailBits) - 1; } - let outputCount = 0; - for (let w = 0; w < wordCount; w++) { - outputCount += popcount(bitB[w]); - } - logger.debug(`nav simplify: phase 4 (invert) ${(performance.now() - t0).toFixed(0)}ms, ${outputCount} solid voxels`); + logger.progress.step(); // Phase 5: erode solid by capsule shape (Minkowski subtraction) - t0 = performance.now(); bitA.fill(0); erodeX(bitB, bitA, nx, ny, nz, kernelR); @@ -661,10 +640,9 @@ const simplifyForCapsule = ( bitA.fill(0); erodeY(bitB, bitA, nx, ny, nz, yHalfExtent); - logger.debug(`nav simplify: phase 5 (erosion) ${(performance.now() - t0).toFixed(0)}ms`); + logger.progress.step(); // Phase 6: crop to bounding box of empty (navigable) cells - t0 = performance.now(); let minIx = nx, minIy = ny, minIz = nz; let maxIx = 0, maxIy = 0, maxIz = 0; @@ -713,9 +691,7 @@ const simplifyForCapsule = ( ) }; - const croppedBlocks = (cropMaxBx - cropMinBx) * (cropMaxBy - cropMinBy) * (cropMaxBz - cropMinBz); - const totalBlocks = nbx * nby * nbz; - logger.log(`nav simplify: phase 6 (crop) ${(performance.now() - t0).toFixed(0)}ms, ${cropMaxBx - cropMinBx}x${cropMaxBy - cropMinBy}x${cropMaxBz - cropMinBz} blocks (${croppedBlocks} of ${totalBlocks})`); + logger.progress.step(); return { accumulator: denseGridToAccumulator( From 06524ca9786b08ccc121c1f3c708538f231cd535 Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Mon, 23 Mar 2026 18:42:47 +0000 Subject: [PATCH 11/18] latest --- src/lib/voxel/nav-simplify.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/voxel/nav-simplify.ts b/src/lib/voxel/nav-simplify.ts index efbf7cf..0165dbf 100644 --- a/src/lib/voxel/nav-simplify.ts +++ b/src/lib/voxel/nav-simplify.ts @@ -566,7 +566,7 @@ const simplifyForCapsule = ( bitA.fill(0); // reuse as visited bitfield const MAX_QUEUE = 1 << 25; - const queueCap = Math.min(1 << (32 - Math.clz32(totalVoxels - 1)), MAX_QUEUE); + const queueCap = MAX_QUEUE; const queueMask = queueCap - 1; const bfsQueue = new Uint32Array(queueCap); let qHead = 0; From ab93a4eb5d99119c195287e065e712d1f4ad7161 Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Mon, 23 Mar 2026 18:53:57 +0000 Subject: [PATCH 12/18] latest --- src/lib/voxel/nav-simplify.ts | 8 ++++++-- test/nav-simplify.test.mjs | 7 +++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/lib/voxel/nav-simplify.ts b/src/lib/voxel/nav-simplify.ts index 0165dbf..1cc8ee1 100644 --- a/src/lib/voxel/nav-simplify.ts +++ b/src/lib/voxel/nav-simplify.ts @@ -509,6 +509,11 @@ const simplifyForCapsule = ( const nx = Math.round((gridBounds.max.x - gridBounds.min.x) / voxelResolution); const ny = Math.round((gridBounds.max.y - gridBounds.min.y) / voxelResolution); const nz = Math.round((gridBounds.max.z - gridBounds.min.z) / voxelResolution); + + if (nx % 4 !== 0 || ny % 4 !== 0 || nz % 4 !== 0) { + throw new Error(`Grid dimensions must be multiples of 4, got ${nx}x${ny}x${nz}`); + } + const totalVoxels = nx * ny * nz; const stride = nx * ny; const wordCount = (totalVoxels + 31) >>> 5; @@ -565,8 +570,7 @@ const simplifyForCapsule = ( bitA.fill(0); // reuse as visited bitfield - const MAX_QUEUE = 1 << 25; - const queueCap = MAX_QUEUE; + const queueCap = 1 << Math.min(25, Math.ceil(Math.log2(totalVoxels + 1))); const queueMask = queueCap - 1; const bfsQueue = new Uint32Array(queueCap); let qHead = 0; diff --git a/test/nav-simplify.test.mjs b/test/nav-simplify.test.mjs index 1ea2562..232bdba 100644 --- a/test/nav-simplify.test.mjs +++ b/test/nav-simplify.test.mjs @@ -148,9 +148,12 @@ describe('simplifyForCapsule', function () { }); it('should return original accumulator if seed is in solid region', function () { - const { acc, gridBounds } = buildHollowBox(4, voxelResolution); + // 3-block box: walls at blocks 0 and 2, interior is only block 1 + // (4 voxels per axis). After dilation by yHalfExtent=3 in Y the + // interior is fully blocked, so no free cell exists within search + // radius and the function returns the original accumulator. + const { acc, gridBounds } = buildHollowBox(3, voxelResolution); - // Seed at (0,0,0) which is inside a wall block const seed = { x: gridBounds.min.x + voxelResolution, y: gridBounds.min.y + voxelResolution, From 5f6eb541f26d171f7fcf97ab37d5ace2a810ead7 Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Tue, 24 Mar 2026 09:27:36 +0000 Subject: [PATCH 13/18] latest --- src/cli/index.ts | 33 ++++++++++++++++++++++++++++++--- src/lib/voxel/nav-simplify.ts | 20 ++++++++++++-------- src/lib/write.ts | 2 +- src/lib/writers/write-voxel.ts | 3 +++ test/nav-simplify.test.mjs | 2 -- 5 files changed, 46 insertions(+), 14 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index c87bb83..549192a 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -442,13 +442,40 @@ GLOBAL OPTIONS --nav-seed Seed position for nav simplification. Default: 0,0,0 EXAMPLES - # Scale and translate + # Scale then translate splat-transform bunny.ply -s 0.5 -t 0,0,10 bunny-scaled.ply - # Merge two files with per-file transforms + # Merge two files with transforms and compress to SOG format splat-transform -w cloudA.ply -r 0,90,0 cloudB.ply -s 2 merged.sog - # Print summary without writing a file + # Generate unbundled HTML viewer with separate CSS, JS and SOG files + splat-transform -U bunny.ply bunny-viewer.html + + # Generate synthetic splats using a generator script + splat-transform gen-grid.mjs -p width=500,height=500,scale=0.1 grid.ply + + # Generate LOD with custom chunk size and node split size + splat-transform -O 0,1,2 -C 1024 -X 32 input.lcc output/lod-meta.json + + # Generate voxel data + splat-transform input.ply output.voxel.json + + # Generate voxel data with collision mesh + splat-transform -K input.ply output.voxel.json + + # Generate voxel data with custom resolution and opacity threshold + splat-transform -R 0.1 -A 0.3 input.ply output.voxel.json + + # Generate voxel data with nav simplification disabled + splat-transform -n input.ply output.voxel.json + + # Convert voxel data back to PLY for visualization + splat-transform scene.voxel.json scene-voxels.ply + + # Print statistical summary, then write output + splat-transform bunny.ply --summary output.ply + + # Print summary without writing a file (discard output) splat-transform bunny.ply -m null `; diff --git a/src/lib/voxel/nav-simplify.ts b/src/lib/voxel/nav-simplify.ts index 1cc8ee1..41e1dd9 100644 --- a/src/lib/voxel/nav-simplify.ts +++ b/src/lib/voxel/nav-simplify.ts @@ -570,24 +570,28 @@ const simplifyForCapsule = ( bitA.fill(0); // reuse as visited bitfield - const queueCap = 1 << Math.min(25, Math.ceil(Math.log2(totalVoxels + 1))); - const queueMask = queueCap - 1; - const bfsQueue = new Uint32Array(queueCap); + let queueCap = 1 << Math.min(25, Math.ceil(Math.log2(totalVoxels + 1))); + let queueMask = queueCap - 1; + let bfsQueue = new Uint32Array(queueCap); let qHead = 0; let qTail = 0; let queueSize = 0; - let overflowed = false; const enqueue = (nIdx: number) => { const w = nIdx >>> 5; const m = 1 << (nIdx & 31); if (!((bitB[w] | bitA[w]) & m)) { if (queueSize >= queueCap) { - if (!overflowed) { - logger.warn(`nav simplify: BFS queue overflow (cap ${queueCap}), results may be incomplete`); - overflowed = true; + const newCap = queueCap << 1; + const newQueue = new Uint32Array(newCap); + for (let i = 0; i < queueSize; i++) { + newQueue[i] = bfsQueue[(qHead + i) & queueMask]; } - return; + bfsQueue = newQueue; + queueCap = newCap; + queueMask = newCap - 1; + qHead = 0; + qTail = queueSize; } bitA[w] |= m; bfsQueue[qTail] = nIdx; diff --git a/src/lib/write.ts b/src/lib/write.ts index 7010daf..65092da 100644 --- a/src/lib/write.ts +++ b/src/lib/write.ts @@ -167,7 +167,7 @@ const writeFile = async (writeOptions: WriteOptions, fs: FileSystem) => { }, fs); break; case 'voxel': { - const enableNav = !!options.navSimplify; + const enableNav = options.navSimplify !== false; const navCapsule = enableNav ? (options.navCapsule ?? { height: 1.6, radius: 0.2 }) : undefined; const navSeed = enableNav ? (options.navSeed ?? { x: 0, y: 0, z: 0 }) : undefined; await writeVoxel({ diff --git a/src/lib/writers/write-voxel.ts b/src/lib/writers/write-voxel.ts index a5a635b..f71ad77 100644 --- a/src/lib/writers/write-voxel.ts +++ b/src/lib/writers/write-voxel.ts @@ -187,6 +187,9 @@ const writeVoxel = async (options: WriteVoxelOptions, fs: FileSystem): Promise Date: Tue, 24 Mar 2026 09:37:52 +0000 Subject: [PATCH 14/18] latest --- src/cli/index.ts | 6 +++++- src/lib/voxel/nav-simplify.ts | 10 ++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index 549192a..429f262 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -183,7 +183,11 @@ const parseArguments = async () => { if (parts.length !== 2) { throw new Error(`Invalid nav-capsule value: ${navCapsuleStr}. Expected height,radius`); } - navCapsule = { height: parts[0], radius: parts[1] }; + const [height, radius] = parts; + if (!Number.isFinite(height) || !Number.isFinite(radius) || height <= 0 || radius < 0) { + throw new Error(`Invalid nav-capsule value: ${navCapsuleStr}. Height must be > 0 and radius must be >= 0`); + } + navCapsule = { height, radius }; } else { navCapsule = { height: 1.6, radius: 0.2 }; } diff --git a/src/lib/voxel/nav-simplify.ts b/src/lib/voxel/nav-simplify.ts index 41e1dd9..b3ece58 100644 --- a/src/lib/voxel/nav-simplify.ts +++ b/src/lib/voxel/nav-simplify.ts @@ -518,6 +518,16 @@ const simplifyForCapsule = ( const stride = nx * ny; const wordCount = (totalVoxels + 31) >>> 5; + if (!Number.isFinite(voxelResolution) || voxelResolution <= 0) { + throw new Error(`nav simplify: voxelResolution must be finite and > 0, got ${voxelResolution}`); + } + if (!Number.isFinite(capsuleHeight) || capsuleHeight <= 0) { + throw new Error(`nav simplify: capsuleHeight must be finite and > 0, got ${capsuleHeight}`); + } + if (!Number.isFinite(capsuleRadius) || capsuleRadius < 0) { + throw new Error(`nav simplify: capsuleRadius must be finite and >= 0, got ${capsuleRadius}`); + } + // Capsule approximated as an axis-aligned box (square XZ cross-section). // Conservative: may reject narrow diagonal passages a true capsule could fit. const kernelR = Math.ceil(capsuleRadius / voxelResolution); From 43e2f2f1afaf8d2410dcfcef5c19a280e35e5dbe Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Tue, 24 Mar 2026 09:49:52 +0000 Subject: [PATCH 15/18] latest --- src/cli/index.ts | 6 +++++- src/lib/voxel/nav-simplify.ts | 25 +++++++++++++------------ 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index 429f262..d2c22f7 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -196,7 +196,11 @@ const parseArguments = async () => { if (parts.length !== 3) { throw new Error(`Invalid nav-seed value: ${navSeedStr}. Expected x,y,z`); } - navSeed = { x: parts[0], y: parts[1], z: parts[2] }; + const [x, y, z] = parts; + if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) { + throw new Error(`Invalid nav-seed value: ${navSeedStr}. x, y, and z must be finite numbers`); + } + navSeed = { x, y, z }; } else { navSeed = { x: 0, y: 0, z: 0 }; } diff --git a/src/lib/voxel/nav-simplify.ts b/src/lib/voxel/nav-simplify.ts index b3ece58..4da6bfd 100644 --- a/src/lib/voxel/nav-simplify.ts +++ b/src/lib/voxel/nav-simplify.ts @@ -487,8 +487,9 @@ const findNearestFreeCell = ( * step 2 so the runtime capsule query produces correct collisions. * 6. Crop to bounding box of navigable cells. * - * Grid boundaries are treated as solid, so the fill is always bounded even - * in unsealed scenes. + * The flood fill is bounded by the finite grid extents: out-of-bounds cells + * are never visited, but grid boundaries are not explicitly modeled as solid. + * This means unsealed scenes may allow navigation up to the edge of the grid. * * @param accumulator - BlockAccumulator with filtered voxelization results. * @param gridBounds - Grid bounds aligned to block boundaries (not mutated). @@ -506,6 +507,16 @@ const simplifyForCapsule = ( capsuleRadius: number, seed: NavSeed ): NavSimplifyResult => { + if (!Number.isFinite(voxelResolution) || voxelResolution <= 0) { + throw new Error(`nav simplify: voxelResolution must be finite and > 0, got ${voxelResolution}`); + } + if (!Number.isFinite(capsuleHeight) || capsuleHeight <= 0) { + throw new Error(`nav simplify: capsuleHeight must be finite and > 0, got ${capsuleHeight}`); + } + if (!Number.isFinite(capsuleRadius) || capsuleRadius < 0) { + throw new Error(`nav simplify: capsuleRadius must be finite and >= 0, got ${capsuleRadius}`); + } + const nx = Math.round((gridBounds.max.x - gridBounds.min.x) / voxelResolution); const ny = Math.round((gridBounds.max.y - gridBounds.min.y) / voxelResolution); const nz = Math.round((gridBounds.max.z - gridBounds.min.z) / voxelResolution); @@ -518,16 +529,6 @@ const simplifyForCapsule = ( const stride = nx * ny; const wordCount = (totalVoxels + 31) >>> 5; - if (!Number.isFinite(voxelResolution) || voxelResolution <= 0) { - throw new Error(`nav simplify: voxelResolution must be finite and > 0, got ${voxelResolution}`); - } - if (!Number.isFinite(capsuleHeight) || capsuleHeight <= 0) { - throw new Error(`nav simplify: capsuleHeight must be finite and > 0, got ${capsuleHeight}`); - } - if (!Number.isFinite(capsuleRadius) || capsuleRadius < 0) { - throw new Error(`nav simplify: capsuleRadius must be finite and >= 0, got ${capsuleRadius}`); - } - // Capsule approximated as an axis-aligned box (square XZ cross-section). // Conservative: may reject narrow diagonal passages a true capsule could fit. const kernelR = Math.ceil(capsuleRadius / voxelResolution); From 245ed52818ca6c9d93d08385ba6ccd84d6294ff3 Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Tue, 24 Mar 2026 10:49:41 +0000 Subject: [PATCH 16/18] latest --- src/lib/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/types.ts b/src/lib/types.ts index d881ae1..b71a412 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -32,7 +32,7 @@ type Options = { /** Ratio of triangles to keep when simplifying the collision mesh (0-1). Default: 0.25 */ meshSimplify?: number; - /** Enable navigation simplification with default capsule (height 1.6, radius 0.2) and seed (0,0,0). */ + /** Enable navigation simplification for voxel output. Default: true; set to false to disable. */ navSimplify?: boolean; /** Capsule dimensions (height, radius) for navigation simplification. Default: { height: 1.6, radius: 0.2 } */ From 212c2e536c92b6be4ca43a489915ef1e0029f7f2 Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Tue, 24 Mar 2026 11:07:21 +0000 Subject: [PATCH 17/18] latest --- src/lib/types.ts | 2 +- src/lib/voxel/nav-simplify.ts | 344 ++++++++++++++++++---------------- test/nav-simplify.test.mjs | 38 ---- 3 files changed, 179 insertions(+), 205 deletions(-) diff --git a/src/lib/types.ts b/src/lib/types.ts index b71a412..183e035 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -32,7 +32,7 @@ type Options = { /** Ratio of triangles to keep when simplifying the collision mesh (0-1). Default: 0.25 */ meshSimplify?: number; - /** Enable navigation simplification for voxel output. Default: true; set to false to disable. */ + /** Enable navigation simplification with default capsule (height 1.6, radius 0.2) and seed (0,0,0). Default: true (set to false to disable). */ navSimplify?: boolean; /** Capsule dimensions (height, radius) for navigation simplification. Default: { height: 1.6, radius: 0.2 } */ diff --git a/src/lib/voxel/nav-simplify.ts b/src/lib/voxel/nav-simplify.ts index 4da6bfd..bdb4c61 100644 --- a/src/lib/voxel/nav-simplify.ts +++ b/src/lib/voxel/nav-simplify.ts @@ -525,6 +525,10 @@ const simplifyForCapsule = ( throw new Error(`Grid dimensions must be multiples of 4, got ${nx}x${ny}x${nz}`); } + if (accumulator.count === 0) { + return { accumulator, gridBounds }; + } + const totalVoxels = nx * ny * nz; const stride = nx * ny; const wordCount = (totalVoxels + 31) >>> 5; @@ -535,191 +539,199 @@ const simplifyForCapsule = ( const yHalfExtent = Math.ceil(capsuleHeight / (2 * voxelResolution)); logger.progress.begin(6); - - // Phase 1: build dense bitfield grid from accumulator - const bitA = new Uint32Array(wordCount); - fillDenseSolidGrid(accumulator, bitA, nx, ny, nz); - logger.progress.step(); - - // Phase 2: capsule clearance grid (Minkowski dilation of solid by capsule) - // Three separable 1D sliding window passes (X, Z, Y). - const bitB = new Uint32Array(wordCount); - - dilateX(bitA, bitB, nx, ny, nz, kernelR); - bitA.fill(0); - dilateZ(bitB, bitA, nx, ny, nz, kernelR); - bitB.fill(0); - dilateY(bitA, bitB, nx, ny, nz, yHalfExtent); - logger.progress.step(); - - // Phase 3: BFS flood fill from seed through free (non-blocked) cells. - // Uses bitB as blocked mask and bitA as visited mask. - let seedIx = Math.floor((seed.x - gridBounds.min.x) / voxelResolution); - let seedIy = Math.floor((seed.y - gridBounds.min.y) / voxelResolution); - let seedIz = Math.floor((seed.z - gridBounds.min.z) / voxelResolution); - - if (seedIx < 0 || seedIx >= nx || seedIy < 0 || seedIy >= ny || seedIz < 0 || seedIz >= nz) { - logger.warn(`nav simplify: seed (${seed.x}, ${seed.y}, ${seed.z}) outside grid, skipping`); - logger.progress.cancel(); - return { accumulator, gridBounds }; - } - - let seedIdx = seedIx + seedIy * nx + seedIz * stride; - if ((bitB[seedIdx >>> 5] >>> (seedIdx & 31)) & 1) { - const maxRadius = Math.max(kernelR, yHalfExtent) * 2; - const found = findNearestFreeCell(bitB, seedIx, seedIy, seedIz, nx, ny, nz, stride, maxRadius); - if (!found) { - logger.warn(`nav simplify: seed (${seed.x}, ${seed.y}, ${seed.z}) blocked after dilation, no free cell within ${maxRadius} voxels, skipping`); - logger.progress.cancel(); + let progressComplete = false; + + try { + + // Phase 1: build dense bitfield grid from accumulator + const bitA = new Uint32Array(wordCount); + fillDenseSolidGrid(accumulator, bitA, nx, ny, nz); + logger.progress.step(); + + // Phase 2: capsule clearance grid (Minkowski dilation of solid by capsule) + // Three separable 1D sliding window passes (X, Z, Y). + const bitB = new Uint32Array(wordCount); + + dilateX(bitA, bitB, nx, ny, nz, kernelR); + bitA.fill(0); + dilateZ(bitB, bitA, nx, ny, nz, kernelR); + bitB.fill(0); + dilateY(bitA, bitB, nx, ny, nz, yHalfExtent); + logger.progress.step(); + + // Phase 3: BFS flood fill from seed through free (non-blocked) cells. + // Uses bitB as blocked mask and bitA as visited mask. + let seedIx = Math.floor((seed.x - gridBounds.min.x) / voxelResolution); + let seedIy = Math.floor((seed.y - gridBounds.min.y) / voxelResolution); + let seedIz = Math.floor((seed.z - gridBounds.min.z) / voxelResolution); + + if (seedIx < 0 || seedIx >= nx || seedIy < 0 || seedIy >= ny || seedIz < 0 || seedIz >= nz) { + logger.warn(`nav simplify: seed (${seed.x}, ${seed.y}, ${seed.z}) outside grid, skipping`); return { accumulator, gridBounds }; } - seedIx = found.ix; - seedIy = found.iy; - seedIz = found.iz; - seedIdx = seedIx + seedIy * nx + seedIz * stride; - } - bitA.fill(0); // reuse as visited bitfield - - let queueCap = 1 << Math.min(25, Math.ceil(Math.log2(totalVoxels + 1))); - let queueMask = queueCap - 1; - let bfsQueue = new Uint32Array(queueCap); - let qHead = 0; - let qTail = 0; - let queueSize = 0; - - const enqueue = (nIdx: number) => { - const w = nIdx >>> 5; - const m = 1 << (nIdx & 31); - if (!((bitB[w] | bitA[w]) & m)) { - if (queueSize >= queueCap) { - const newCap = queueCap << 1; - const newQueue = new Uint32Array(newCap); - for (let i = 0; i < queueSize; i++) { - newQueue[i] = bfsQueue[(qHead + i) & queueMask]; + let seedIdx = seedIx + seedIy * nx + seedIz * stride; + if ((bitB[seedIdx >>> 5] >>> (seedIdx & 31)) & 1) { + const maxRadius = Math.max(kernelR, yHalfExtent) * 2; + const found = findNearestFreeCell(bitB, seedIx, seedIy, seedIz, nx, ny, nz, stride, maxRadius); + if (!found) { + logger.warn(`nav simplify: seed (${seed.x}, ${seed.y}, ${seed.z}) blocked after dilation, no free cell within ${maxRadius} voxels, skipping`); + return { accumulator, gridBounds }; + } + seedIx = found.ix; + seedIy = found.iy; + seedIz = found.iz; + seedIdx = seedIx + seedIy * nx + seedIz * stride; + } + + bitA.fill(0); // reuse as visited bitfield + + let queueCap = 1 << Math.min(25, Math.ceil(Math.log2(totalVoxels + 1))); + let queueMask = queueCap - 1; + let bfsQueue = new Uint32Array(queueCap); + let qHead = 0; + let qTail = 0; + let queueSize = 0; + + const enqueue = (nIdx: number) => { + const w = nIdx >>> 5; + const m = 1 << (nIdx & 31); + if (!((bitB[w] | bitA[w]) & m)) { + if (queueSize >= queueCap) { + const newCap = queueCap << 1; + const newQueue = new Uint32Array(newCap); + for (let i = 0; i < queueSize; i++) { + newQueue[i] = bfsQueue[(qHead + i) & queueMask]; + } + bfsQueue = newQueue; + queueCap = newCap; + queueMask = newCap - 1; + qHead = 0; + qTail = queueSize; } - bfsQueue = newQueue; - queueCap = newCap; - queueMask = newCap - 1; - qHead = 0; - qTail = queueSize; + bitA[w] |= m; + bfsQueue[qTail] = nIdx; + qTail = (qTail + 1) & queueMask; + queueSize++; } - bitA[w] |= m; - bfsQueue[qTail] = nIdx; - qTail = (qTail + 1) & queueMask; - queueSize++; + }; + + bitA[seedIdx >>> 5] |= (1 << (seedIdx & 31)); + bfsQueue[qTail] = seedIdx; + qTail = (qTail + 1) & queueMask; + queueSize++; + + while (qHead !== qTail) { + const idx = bfsQueue[qHead]; + qHead = (qHead + 1) & queueMask; + queueSize--; + + const ix = idx % nx; + const iy = Math.floor((idx % stride) / nx); + const iz = Math.floor(idx / stride); + + if (ix > 0) enqueue(idx - 1); + if (ix < nx - 1) enqueue(idx + 1); + if (iy > 0) enqueue(idx - nx); + if (iy < ny - 1) enqueue(idx + nx); + if (iz > 0) enqueue(idx - stride); + if (iz < nz - 1) enqueue(idx + stride); } - }; - - bitA[seedIdx >>> 5] |= (1 << (seedIdx & 31)); - bfsQueue[qTail] = seedIdx; - qTail = (qTail + 1) & queueMask; - queueSize++; - - while (qHead !== qTail) { - const idx = bfsQueue[qHead]; - qHead = (qHead + 1) & queueMask; - queueSize--; - - const ix = idx % nx; - const iy = Math.floor((idx % stride) / nx); - const iz = Math.floor(idx / stride); - - if (ix > 0) enqueue(idx - 1); - if (ix < nx - 1) enqueue(idx + 1); - if (iy > 0) enqueue(idx - nx); - if (iy < ny - 1) enqueue(idx + nx); - if (iz > 0) enqueue(idx - stride); - if (iz < nz - 1) enqueue(idx + stride); - } - logger.progress.step(); + logger.progress.step(); - // Phase 4: invert reachable to solid (bitwise operation). - // Reachable = visited AND NOT blocked = bitA AND NOT bitB. - // Solid = NOT reachable = NOT bitA OR bitB = ~bitA | bitB. - for (let w = 0; w < wordCount; w++) { - bitB[w] |= ~bitA[w]; - } + // Phase 4: invert reachable to solid (bitwise operation). + // Reachable = visited AND NOT blocked = bitA AND NOT bitB. + // Solid = NOT reachable = NOT bitA OR bitB = ~bitA | bitB. + for (let w = 0; w < wordCount; w++) { + bitB[w] |= ~bitA[w]; + } - // Clear padding bits in the last word to avoid phantom solids - const tailBits = totalVoxels & 31; - if (tailBits) { - bitB[wordCount - 1] &= (1 << tailBits) - 1; - } + // Clear padding bits in the last word to avoid phantom solids + const tailBits = totalVoxels & 31; + if (tailBits) { + bitB[wordCount - 1] &= (1 << tailBits) - 1; + } - logger.progress.step(); + logger.progress.step(); - // Phase 5: erode solid by capsule shape (Minkowski subtraction) - bitA.fill(0); - erodeX(bitB, bitA, nx, ny, nz, kernelR); + // Phase 5: erode solid by capsule shape (Minkowski subtraction) + bitA.fill(0); + erodeX(bitB, bitA, nx, ny, nz, kernelR); - bitB.fill(0); - erodeZ(bitA, bitB, nx, ny, nz, kernelR); + bitB.fill(0); + erodeZ(bitA, bitB, nx, ny, nz, kernelR); - bitA.fill(0); - erodeY(bitB, bitA, nx, ny, nz, yHalfExtent); - logger.progress.step(); + bitA.fill(0); + erodeY(bitB, bitA, nx, ny, nz, yHalfExtent); + logger.progress.step(); - // Phase 6: crop to bounding box of empty (navigable) cells - let minIx = nx, minIy = ny, minIz = nz; - let maxIx = 0, maxIy = 0, maxIz = 0; + // Phase 6: crop to bounding box of empty (navigable) cells + let minIx = nx, minIy = ny, minIz = nz; + let maxIx = 0, maxIy = 0, maxIz = 0; - for (let iz = 0; iz < nz; iz++) { - const zOff = iz * stride; - for (let iy = 0; iy < ny; iy++) { - const rowOff = zOff + iy * nx; - for (let ix = 0; ix < nx; ix++) { - const idx = rowOff + ix; - if (!((bitA[idx >>> 5] >>> (idx & 31)) & 1)) { - if (ix < minIx) minIx = ix; - if (ix > maxIx) maxIx = ix; - if (iy < minIy) minIy = iy; - if (iy > maxIy) maxIy = iy; - if (iz < minIz) minIz = iz; - if (iz > maxIz) maxIz = iz; + for (let iz = 0; iz < nz; iz++) { + const zOff = iz * stride; + for (let iy = 0; iy < ny; iy++) { + const rowOff = zOff + iy * nx; + for (let ix = 0; ix < nx; ix++) { + const idx = rowOff + ix; + if (!((bitA[idx >>> 5] >>> (idx & 31)) & 1)) { + if (ix < minIx) minIx = ix; + if (ix > maxIx) maxIx = ix; + if (iy < minIy) minIy = iy; + if (iy > maxIy) maxIy = iy; + if (iz < minIz) minIz = iz; + if (iz > maxIz) maxIz = iz; + } } } } - } - const nbx = nx >> 2; - const nby = ny >> 2; - const nbz = nz >> 2; - - const MARGIN = 1; - const cropMinBx = Math.max(0, (minIx >> 2) - MARGIN); - const cropMinBy = Math.max(0, (minIy >> 2) - MARGIN); - const cropMinBz = Math.max(0, (minIz >> 2) - MARGIN); - const cropMaxBx = Math.min(nbx, (maxIx >> 2) + 1 + MARGIN); - const cropMaxBy = Math.min(nby, (maxIy >> 2) + 1 + MARGIN); - const cropMaxBz = Math.min(nbz, (maxIz >> 2) + 1 + MARGIN); - - const blockSize = 4 * voxelResolution; - const croppedMin = new Vec3( - gridBounds.min.x + cropMinBx * blockSize, - gridBounds.min.y + cropMinBy * blockSize, - gridBounds.min.z + cropMinBz * blockSize - ); - const croppedBounds: Bounds = { - min: croppedMin, - max: new Vec3( - croppedMin.x + (cropMaxBx - cropMinBx) * blockSize, - croppedMin.y + (cropMaxBy - cropMinBy) * blockSize, - croppedMin.z + (cropMaxBz - cropMinBz) * blockSize - ) - }; - - logger.progress.step(); - - return { - accumulator: denseGridToAccumulator( - bitA, nx, ny, nz, - cropMinBx, cropMinBy, cropMinBz, - cropMaxBx, cropMaxBy, cropMaxBz - ), - gridBounds: croppedBounds - }; + const nbx = nx >> 2; + const nby = ny >> 2; + const nbz = nz >> 2; + + const MARGIN = 1; + const cropMinBx = Math.max(0, (minIx >> 2) - MARGIN); + const cropMinBy = Math.max(0, (minIy >> 2) - MARGIN); + const cropMinBz = Math.max(0, (minIz >> 2) - MARGIN); + const cropMaxBx = Math.min(nbx, (maxIx >> 2) + 1 + MARGIN); + const cropMaxBy = Math.min(nby, (maxIy >> 2) + 1 + MARGIN); + const cropMaxBz = Math.min(nbz, (maxIz >> 2) + 1 + MARGIN); + + const blockSize = 4 * voxelResolution; + const croppedMin = new Vec3( + gridBounds.min.x + cropMinBx * blockSize, + gridBounds.min.y + cropMinBy * blockSize, + gridBounds.min.z + cropMinBz * blockSize + ); + const croppedBounds: Bounds = { + min: croppedMin, + max: new Vec3( + croppedMin.x + (cropMaxBx - cropMinBx) * blockSize, + croppedMin.y + (cropMaxBy - cropMinBy) * blockSize, + croppedMin.z + (cropMaxBz - cropMinBz) * blockSize + ) + }; + + logger.progress.step(); + progressComplete = true; + + return { + accumulator: denseGridToAccumulator( + bitA, nx, ny, nz, + cropMinBx, cropMinBy, cropMinBz, + cropMaxBx, cropMaxBy, cropMaxBz + ), + gridBounds: croppedBounds + }; + + } finally { + if (!progressComplete) { + logger.progress.cancel(); + } + } }; export { simplifyForCapsule }; diff --git a/test/nav-simplify.test.mjs b/test/nav-simplify.test.mjs index ed6b890..dc2854c 100644 --- a/test/nav-simplify.test.mjs +++ b/test/nav-simplify.test.mjs @@ -11,7 +11,6 @@ import assert from 'node:assert'; import { BlockAccumulator, xyzToMorton, - mortonToXYZ, alignGridBounds, popcount } from '../src/lib/voxel/sparse-octree.js'; @@ -20,43 +19,6 @@ import { simplifyForCapsule } from '../src/lib/voxel/nav-simplify.js'; const SOLID_LO = 0xFFFFFFFF >>> 0; const SOLID_HI = 0xFFFFFFFF >>> 0; -/** - * Build a Set of all solid voxel indices (ix, iy, iz) from a BlockAccumulator. - */ -function extractSolidVoxels(acc) { - const set = new Set(); - const solid = acc.getSolidBlocks(); - for (let i = 0; i < solid.length; i++) { - const [bx, by, bz] = mortonToXYZ(solid[i]); - for (let lz = 0; lz < 4; lz++) { - for (let ly = 0; ly < 4; ly++) { - for (let lx = 0; lx < 4; lx++) { - set.add(`${(bx << 2) + lx},${(by << 2) + ly},${(bz << 2) + lz}`); - } - } - } - } - const mixed = acc.getMixedBlocks(); - for (let i = 0; i < mixed.morton.length; i++) { - const [bx, by, bz] = mortonToXYZ(mixed.morton[i]); - const lo = mixed.masks[i * 2]; - const hi = mixed.masks[i * 2 + 1]; - for (let lz = 0; lz < 4; lz++) { - for (let ly = 0; ly < 4; ly++) { - for (let lx = 0; lx < 4; lx++) { - const bitIdx = lx + ly * 4 + lz * 16; - const word = bitIdx < 32 ? lo : hi; - const bit = bitIdx < 32 ? bitIdx : bitIdx - 32; - if ((word >>> bit) & 1) { - set.add(`${(bx << 2) + lx},${(by << 2) + ly},${(bz << 2) + lz}`); - } - } - } - } - } - return set; -} - /** * Count total solid voxels in a BlockAccumulator. */ From fc7a4ae6831c662e85a6220e14624e4f728c56f7 Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Tue, 24 Mar 2026 11:24:24 +0000 Subject: [PATCH 18/18] latest --- src/lib/voxel/nav-simplify.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/voxel/nav-simplify.ts b/src/lib/voxel/nav-simplify.ts index bdb4c61..e88976e 100644 --- a/src/lib/voxel/nav-simplify.ts +++ b/src/lib/voxel/nav-simplify.ts @@ -621,7 +621,7 @@ const simplifyForCapsule = ( qTail = (qTail + 1) & queueMask; queueSize++; - while (qHead !== qTail) { + while (queueSize > 0) { const idx = bfsQueue[qHead]; qHead = (qHead + 1) & queueMask; queueSize--;