diff --git a/src/cli/index.ts b/src/cli/index.ts index ed1fe58..d2c22f7 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' }, + 'no-nav-simplify': { type: 'boolean', short: 'n', default: false }, + 'nav-capsule': { type: 'string', default: '' }, + 'nav-seed': { type: 'string', default: '' }, // per-file options translate: { type: 'string', short: 't', multiple: true }, @@ -167,6 +170,42 @@ const parseArguments = async () => { const viewerSettingsPath = v['viewer-settings']; + // Parse nav simplification options + const navCapsuleStr = v['nav-capsule']; + const navSeedStr = v['nav-seed']; + const navSimplify = !v['no-nav-simplify']; + let navCapsule: { height: number; radius: number } | undefined; + let navSeed: { x: number; y: number; z: number } | undefined; + + 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`); + } + 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 }; + } + if (navSeedStr) { + const parts = navSeedStr.split(',').map(parseNumber); + if (parts.length !== 3) { + throw new Error(`Invalid nav-seed value: ${navSeedStr}. Expected x,y,z`); + } + 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 }; + } + } + const options: CliOptions = { overwrite: v.overwrite, help: v.help, @@ -183,7 +222,10 @@ 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']), + navSimplify, + navCapsule, + navSeed }; if (!Number.isFinite(options.meshSimplify) || options.meshSimplify < 0 || options.meshSimplify > 1) { @@ -403,6 +445,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 + -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 EXAMPLES # Scale then translate @@ -429,6 +474,9 @@ EXAMPLES # 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 @@ -452,16 +500,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 { @@ -470,7 +521,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(` (${hrtimeDelta(start, hrtime()).toFixed(3)}s)\n`); } } } diff --git a/src/lib/index.ts b/src/lib/index.ts index ff40601..bb9df98 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, NavSimplifyResult } 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..183e035 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -31,6 +31,15 @@ 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). Default: true (set to false to disable). */ + 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. Default: { x: 0, y: 0, z: 0 } */ + 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..e88976e --- /dev/null +++ b/src/lib/voxel/nav-simplify.ts @@ -0,0 +1,738 @@ +import { Vec3 } from 'playcanvas'; + +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; +}; + +/** + * 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). + * + * @param accumulator - Source block data. + * @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: Uint32Array, + 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) { + const idx = rowOff + ix; + grid[idx >>> 5] |= (1 << (idx & 31)); + } + } + } + } + } + + 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) { + const idx = rowOff + ix; + grid[idx >>> 5] |= (1 << (idx & 31)); + } + } + } + } + } + } +}; + +/** + * 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 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 dilateX = ( + src: Uint32Array, dst: Uint32Array, + nx: number, ny: number, nz: number, + halfExtent: number +): void => { + const stride = nx * ny; + for (let iz = 0; iz < nz; iz++) { + for (let iy = 0; iy < ny; iy++) { + 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++) { + 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++; + } + } + } + } +}; + +/** + * 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 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: 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++) { + const idx = zOff + iy * nx + ix; + if ((src[idx >>> 5] >>> (idx & 31)) & 1) count++; + } + for (let iy = 0; iy < ny; iy++) { + const idx = zOff + iy * nx + ix; + if (count > 0) dst[idx >>> 5] |= (1 << (idx & 31)); + const exitY = iy - halfExtent; + 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) { + const ni = zOff + enterY * nx + ix; + if ((src[ni >>> 5] >>> (ni & 31)) & 1) count++; + } + } + } + } +}; + +/** + * 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 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 dilateZ = ( + 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 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++) { + for (let iy = 0; iy < ny; iy++) { + 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++) { + 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++; + } + } + } + } +}; + +/** + * 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 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: 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++) { + const idx = zOff + iy * nx + ix; + if (!((src[idx >>> 5] >>> (idx & 31)) & 1)) zeroCount++; + } + for (let iy = 0; iy < ny; iy++) { + const idx = zOff + iy * nx + ix; + if (zeroCount === 0) dst[idx >>> 5] |= (1 << (idx & 31)); + const exitY = iy - halfExtent; + 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) { + const ni = zOff + enterY * nx + ix; + if (!((src[ni >>> 5] >>> (ni & 31)) & 1)) zeroCount++; + } + } + } + } +}; + +/** + * 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 - 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: 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 stride = nx * ny; + + 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; + 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++) { + 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); + } else { + hi |= (1 << (bitIdx - 32)); + } + } + } + } + } + + if (lo !== 0 || hi !== 0) { + acc.addBlock( + xyzToMorton(bx - cropMinBx, by - cropMinBy, bz - cropMinBz), + lo, hi + ); + } + } + } + } + + 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. + * + * 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 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 (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. + * + * 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). + * @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 Simplified accumulator and cropped grid bounds. + */ +const simplifyForCapsule = ( + accumulator: BlockAccumulator, + gridBounds: Bounds, + voxelResolution: number, + capsuleHeight: number, + 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); + + 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}`); + } + + if (accumulator.count === 0) { + return { accumulator, gridBounds }; + } + + const totalVoxels = nx * ny * nz; + const stride = nx * ny; + const wordCount = (totalVoxels + 31) >>> 5; + + // 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)); + + logger.progress.begin(6); + 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 }; + } + + 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; + } + 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 (queueSize > 0) { + 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(); + + // 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; + } + + logger.progress.step(); + + // 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); + + 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; + + 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(); + 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 }; +export type { NavSeed, NavSimplifyResult }; diff --git a/src/lib/write.ts b/src/lib/write.ts index 226d291..65092da 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 !== 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({ filename, dataTable, @@ -174,9 +177,12 @@ const writeFile = async (writeOptions: WriteOptions, fs: FileSystem) => { opacityCutoff: options.opacityCutoff, collisionMesh: options.collisionMesh, meshSimplify: options.meshSimplify, + navCapsule, + navSeed, createDevice }, fs); break; + } } }; diff --git a/src/lib/writers/write-voxel.ts b/src/lib/writers/write-voxel.ts index 12c9b71..f71ad77 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,23 @@ const writeVoxel = async (options: WriteVoxelOptions, fs: FileSystem): Promise>> 0; +const SOLID_HI = 0xFFFFFFFF >>> 0; + +/** + * 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 around the navigable 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.accumulator); + + assert.ok(resultCount > 0, + 'Should produce solid voxels around the navigable space'); + }); + + 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.accumulator); + 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.accumulator), countSolidVoxels(acc), + 'Should return original when seed is outside grid'); + }); + + it('should return original accumulator if seed is in solid region', function () { + // 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); + + 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.accumulator), 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.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); + 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 retain solid voxels 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.accumulator); + assert.ok(resultCount > 0, + 'Should retain solid voxels near the reachable space'); + }); + }); + + describe('unreachable regions', function () { + it('should crop exterior and preserve walls around navigable space', 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 result = simplifyForCapsule(acc, gridBounds, voxelResolution, capsuleHeight, capsuleRadius, seed); + const resultCount = countSolidVoxels(result.accumulator); + + 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); + const nz = Math.round((gridBounds.max.z - gridBounds.min.z) / voxelResolution); + const totalCells = nx * ny * nz; + assert.ok(resultCount < totalCells, + `Result (${resultCount}) should leave reachable interior empty (total: ${totalCells})`); + }); + }); +});