@manycore/aholo-splat-transform 1.2.7 → 1.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/CHANGELOG.md +120 -106
  2. package/COPYRIGHT.md +17 -0
  3. package/README.md +39 -39
  4. package/THIRD_PARTY_LICENSES.txt +1373 -0
  5. package/bin/cli.js +125 -118
  6. package/dist/SplatData.d.ts +67 -67
  7. package/dist/SplatData.js +167 -156
  8. package/dist/constant.d.ts +3 -3
  9. package/dist/constant.js +13 -13
  10. package/dist/file/IFile.d.ts +5 -5
  11. package/dist/file/IFile.js +1 -1
  12. package/dist/file/esz.d.ts +11 -0
  13. package/dist/file/esz.js +337 -0
  14. package/dist/file/index.d.ts +8 -7
  15. package/dist/file/index.js +7 -6
  16. package/dist/file/ksplat.d.ts +12 -12
  17. package/dist/file/ksplat.js +293 -232
  18. package/dist/file/lcc.d.ts +11 -11
  19. package/dist/file/lcc.js +161 -157
  20. package/dist/file/ply.d.ts +13 -13
  21. package/dist/file/ply.js +439 -388
  22. package/dist/file/sog.d.ts +80 -80
  23. package/dist/file/sog.js +525 -504
  24. package/dist/file/splat.d.ts +6 -6
  25. package/dist/file/splat.js +119 -99
  26. package/dist/file/spz.d.ts +11 -8
  27. package/dist/file/spz.js +597 -400
  28. package/dist/file/voxel.d.ts +43 -37
  29. package/dist/file/voxel.js +411 -280
  30. package/dist/index.d.ts +33 -33
  31. package/dist/index.js +54 -54
  32. package/dist/native/index.d.ts +54 -54
  33. package/dist/native/index.js +122 -128
  34. package/dist/native/utils.d.ts +1 -0
  35. package/dist/native/utils.js +54 -0
  36. package/dist/tasks/AutoChunkLodTask.d.ts +13 -13
  37. package/dist/tasks/AutoChunkLodTask.js +117 -117
  38. package/dist/tasks/AutoLodTask.d.ts +10 -10
  39. package/dist/tasks/AutoLodTask.js +20 -20
  40. package/dist/tasks/BaseTask.d.ts +15 -15
  41. package/dist/tasks/BaseTask.js +5 -5
  42. package/dist/tasks/FlexLodTask.d.ts +12 -12
  43. package/dist/tasks/FlexLodTask.js +54 -44
  44. package/dist/tasks/ModifyTask.d.ts +9 -9
  45. package/dist/tasks/ModifyTask.js +166 -156
  46. package/dist/tasks/ReadTask.d.ts +9 -9
  47. package/dist/tasks/ReadTask.js +29 -29
  48. package/dist/tasks/SkeletonLodTask.d.ts +10 -10
  49. package/dist/tasks/SkeletonLodTask.js +176 -156
  50. package/dist/tasks/VoxelTask.d.ts +35 -30
  51. package/dist/tasks/VoxelTask.js +40 -37
  52. package/dist/tasks/WriteTask.d.ts +12 -11
  53. package/dist/tasks/WriteTask.js +70 -70
  54. package/dist/utils/BufferReader.d.ts +12 -12
  55. package/dist/utils/BufferReader.js +45 -47
  56. package/dist/utils/Logger.d.ts +11 -11
  57. package/dist/utils/Logger.js +40 -38
  58. package/dist/utils/StreamChunkDecoder.d.ts +16 -16
  59. package/dist/utils/StreamChunkDecoder.js +31 -36
  60. package/dist/utils/index.d.ts +27 -27
  61. package/dist/utils/index.js +101 -101
  62. package/dist/utils/k-means.d.ts +4 -4
  63. package/dist/utils/k-means.js +340 -350
  64. package/dist/utils/math.d.ts +46 -46
  65. package/dist/utils/math.js +350 -351
  66. package/dist/utils/quantize-1d.d.ts +4 -4
  67. package/dist/utils/quantize-1d.js +164 -164
  68. package/dist/utils/sh-rotate.d.ts +2 -2
  69. package/dist/utils/sh-rotate.js +236 -175
  70. package/dist/utils/splat.d.ts +21 -20
  71. package/dist/utils/splat.js +397 -378
  72. package/dist/utils/voxel/binary.d.ts +8 -0
  73. package/dist/utils/voxel/binary.js +176 -0
  74. package/dist/utils/voxel/common.d.ts +178 -162
  75. package/dist/utils/voxel/common.js +1752 -1700
  76. package/dist/utils/voxel/coplanar-merge.d.ts +63 -63
  77. package/dist/utils/voxel/coplanar-merge.js +818 -819
  78. package/dist/utils/voxel/filter-cluster.d.ts +20 -0
  79. package/dist/utils/voxel/filter-cluster.js +628 -0
  80. package/dist/utils/voxel/gpu-dilation.d.ts +2 -2
  81. package/dist/utils/voxel/gpu-dilation.js +677 -665
  82. package/dist/utils/voxel/marching-cubes.d.ts +42 -42
  83. package/dist/utils/voxel/marching-cubes.js +1645 -1657
  84. package/dist/utils/voxel/mesh.d.ts +3 -3
  85. package/dist/utils/voxel/mesh.js +130 -130
  86. package/dist/utils/voxel/nav.d.ts +29 -29
  87. package/dist/utils/voxel/nav.js +1068 -1043
  88. package/dist/utils/voxel/postprocess.d.ts +23 -23
  89. package/dist/utils/voxel/postprocess.js +408 -375
  90. package/dist/utils/voxel/voxel-faces.d.ts +18 -18
  91. package/dist/utils/voxel/voxel-faces.js +662 -663
  92. package/dist/utils/voxel/voxelize.d.ts +34 -33
  93. package/dist/utils/voxel/voxelize.js +1208 -1193
  94. package/dist/utils/webgpu.d.ts +8 -8
  95. package/dist/utils/webgpu.js +122 -122
  96. package/package.json +37 -30
  97. package/dist/native/cpp/bin/linux/binding.node +0 -0
  98. package/dist/native/cpp/bin/windows/binding.node +0 -0
@@ -1,280 +1,411 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { Buffer } from 'node:buffer';
4
- import { logger, cpuVoxelize, gpuVoxelize } from '../utils/index.js';
5
- import { fillExterior, fillFloor, carve } from '../utils/voxel/nav.js';
6
- import { buildCollisionMesh } from '../utils/voxel/mesh.js';
7
- import { cropToNavigable, cropToOccupied, filterAndFillBlocks } from '../utils/voxel/postprocess.js';
8
- import { alignGridBounds, ALPHA_THRESHOLD, BlockMaskBuffer, buildSparseOctree, decodeMorton3, encodeMorton3, extentsFromQuatScale, getChildOffset, SparseVoxelGrid } from '../utils/voxel/common.js';
9
- /**
10
- * Build a sparse voxel octree from gaussian splat data.
11
- *
12
- * Pipeline (based on https://github.com/playcanvas/splat-transform/blob/8f3b843efdc378f97d4f6a66a3a90a2de6d479a4/src/lib/writers/write-voxel.ts):
13
- * 1) Compute gaussian extents and scene bounds (`extentsFromQuatScale`).
14
- * 2) Align grid bounds.
15
- * 3) Voxelize to `BlockMaskBuffer` via `gpuVoxelize`.
16
- * 4) Post-process occupancy (`filterAndFillBlocks`, optional `fillExterior`, `fillFloor`, `carve`).
17
- * 5) Crop bounds (`cropToNavigable` or `cropToOccupied`), then build octree (`buildSparseOctree`).
18
- * 6) Optionally generate collision mesh (`buildCollisionMesh` `collision.glb` next to `voxel.bin`).
19
- *
20
- * @param data - Gaussian splat source data.
21
- * @param voxelResolution - Voxel size in world units.
22
- * @param opacityCutoff - Opacity threshold used during voxelization.
23
- * @param collisionMesh - Whether to generate collision GLB.
24
- * @param navExteriorRadius - Exterior fill radius; requires `navSeed`.
25
- * @param floorFill - Whether to run floor fill before carve.
26
- * @param floorFillDilation - Horizontal dilation radius for floor fill.
27
- * @param box - Axis-aligned world-space clamp box for voxelization.
28
- * @param navCapsule - Capsule config used by `carve`.
29
- * @param navSeed - Seed position used by exterior/carve flood fills.
30
- */
31
- const writeVoxels = async (data, voxelResolution = 0.05, opacityCutoff = 0.1, backend = 'gpu', collisionMesh = false, navExteriorRadius, floorFill = false, floorFillDilation = 0, cpuWorkerCount = -1, box = { minCorner: [-100, -100, -100], maxCorner: [100, 100, 100] }, navCapsule, navSeed) => {
32
- const hasNav = !!(navCapsule && navSeed && navCapsule.height > 0);
33
- const hasFillExterior = !!(navExteriorRadius && navSeed);
34
- const hasFloorFill = floorFill;
35
- logger.info(`voxel params: resolution=${voxelResolution}, opacityCutoff=${opacityCutoff}, backend=${backend}, ` +
36
- `collisionMesh=${collisionMesh}, navExteriorRadius=${navExteriorRadius ?? 'none'}, ` +
37
- `floorFill=${floorFill}, floorFillDilation=${floorFillDilation}, cpuWorkerCount=${cpuWorkerCount === -1 ? 'auto' : cpuWorkerCount}, ` +
38
- `box=${JSON.stringify(box)}, navCapsule=${navCapsule ? JSON.stringify(navCapsule) : 'none'}, ` +
39
- `navSeed=${navSeed ? JSON.stringify(navSeed) : 'none'}, hasFillExterior=${hasFillExterior}, hasFloorFill=${hasFloorFill}, hasNav=${hasNav}`);
40
- const xCol = data.table[0 /* ColIdx.x */];
41
- const yCol = data.table[1 /* ColIdx.y */];
42
- const zCol = data.table[2 /* ColIdx.z */];
43
- const sxCol = data.table[3 /* ColIdx.sx */];
44
- const syCol = data.table[4 /* ColIdx.sy */];
45
- const szCol = data.table[5 /* ColIdx.sz */];
46
- const qxCol = data.table[6 /* ColIdx.qx */];
47
- const qyCol = data.table[7 /* ColIdx.qy */];
48
- const qzCol = data.table[8 /* ColIdx.qz */];
49
- const qwCol = data.table[9 /* ColIdx.qw */];
50
- const aCol = data.table[13 /* ColIdx.a */];
51
- const sceneBounds = {
52
- min: { x: Infinity, y: Infinity, z: Infinity },
53
- max: { x: -Infinity, y: -Infinity, z: -Infinity }
54
- };
55
- // Compute per-gaussian AABB extents from quaternion+scale and accumulate scene bounds.
56
- logger.time('Voxel bounding/extents');
57
- const extents = new Float32Array(data.counts * 3);
58
- const extentOpacityThreshold = ALPHA_THRESHOLD;
59
- let invalidExtentCount = 0;
60
- for (let i = 0; i < data.counts; i++) {
61
- const e = extentsFromQuatScale(sxCol[i], syCol[i], szCol[i], qxCol[i], qyCol[i], qzCol[i], qwCol[i], aCol[i], extentOpacityThreshold);
62
- if (!Number.isFinite(e.ex) || !Number.isFinite(e.ey) || !Number.isFinite(e.ez)) {
63
- extents[i * 3 + 0] = 0;
64
- extents[i * 3 + 1] = 0;
65
- extents[i * 3 + 2] = 0;
66
- invalidExtentCount++;
67
- }
68
- else {
69
- extents[i * 3 + 0] = e.ex;
70
- extents[i * 3 + 1] = e.ey;
71
- extents[i * 3 + 2] = e.ez;
72
- sceneBounds.min.x = Math.min(sceneBounds.min.x, xCol[i] - e.ex);
73
- sceneBounds.min.y = Math.min(sceneBounds.min.y, yCol[i] - e.ey);
74
- sceneBounds.min.z = Math.min(sceneBounds.min.z, zCol[i] - e.ez);
75
- sceneBounds.max.x = Math.max(sceneBounds.max.x, xCol[i] + e.ex);
76
- sceneBounds.max.y = Math.max(sceneBounds.max.y, yCol[i] + e.ey);
77
- sceneBounds.max.z = Math.max(sceneBounds.max.z, zCol[i] + e.ez);
78
- }
79
- }
80
- if (invalidExtentCount > 0) {
81
- logger.info(`voxel: skipped ${invalidExtentCount} gaussians with invalid extent values`);
82
- }
83
- logger.info(`scene extents: (${sceneBounds.min.x.toFixed(2)},${sceneBounds.min.y.toFixed(2)},${sceneBounds.min.z.toFixed(2)}) - (${sceneBounds.max.x.toFixed(2)},${sceneBounds.max.y.toFixed(2)},${sceneBounds.max.z.toFixed(2)})`);
84
- logger.timeEnd('Voxel bounding/extents');
85
- const exteriorPad = hasFillExterior
86
- ? (Math.ceil(navExteriorRadius / voxelResolution) + 1) * voxelResolution
87
- : 0;
88
- const floorPad = hasFloorFill
89
- ? (Math.ceil(floorFillDilation / voxelResolution) + 1) * voxelResolution
90
- : 0;
91
- const padXZ = Math.max(exteriorPad, floorPad);
92
- const padY = exteriorPad;
93
- const rawVoxelBounds = {
94
- min: {
95
- x: sceneBounds.min.x - padXZ,
96
- y: sceneBounds.min.y - padY,
97
- z: sceneBounds.min.z - padXZ
98
- },
99
- max: {
100
- x: sceneBounds.max.x + padXZ,
101
- y: sceneBounds.max.y + padY,
102
- z: sceneBounds.max.z + padXZ
103
- }
104
- };
105
- const voxelBounds = {
106
- min: {
107
- x: Math.max(rawVoxelBounds.min.x, box.minCorner[0]),
108
- y: Math.max(rawVoxelBounds.min.y, box.minCorner[1]),
109
- z: Math.max(rawVoxelBounds.min.z, box.minCorner[2])
110
- },
111
- max: {
112
- x: Math.min(rawVoxelBounds.max.x, box.maxCorner[0]),
113
- y: Math.min(rawVoxelBounds.max.y, box.maxCorner[1]),
114
- z: Math.min(rawVoxelBounds.max.z, box.maxCorner[2])
115
- }
116
- };
117
- const boxCropApplied = voxelBounds.min.x > rawVoxelBounds.min.x ||
118
- voxelBounds.min.y > rawVoxelBounds.min.y ||
119
- voxelBounds.min.z > rawVoxelBounds.min.z ||
120
- voxelBounds.max.x < rawVoxelBounds.max.x ||
121
- voxelBounds.max.y < rawVoxelBounds.max.y ||
122
- voxelBounds.max.z < rawVoxelBounds.max.z;
123
- if (boxCropApplied) {
124
- logger.info(`voxel box crop applied: ` +
125
- `raw=(${rawVoxelBounds.min.x.toFixed(2)},${rawVoxelBounds.min.y.toFixed(2)},${rawVoxelBounds.min.z.toFixed(2)})-` +
126
- `(${rawVoxelBounds.max.x.toFixed(2)},${rawVoxelBounds.max.y.toFixed(2)},${rawVoxelBounds.max.z.toFixed(2)}), ` +
127
- `cropped=(${voxelBounds.min.x.toFixed(2)},${voxelBounds.min.y.toFixed(2)},${voxelBounds.min.z.toFixed(2)})-` +
128
- `(${voxelBounds.max.x.toFixed(2)},${voxelBounds.max.y.toFixed(2)},${voxelBounds.max.z.toFixed(2)})`);
129
- }
130
- if (voxelBounds.min.x >= voxelBounds.max.x || voxelBounds.min.y >= voxelBounds.max.y || voxelBounds.min.z >= voxelBounds.max.z) {
131
- throw new Error(`voxel box does not overlap scene bounds: box=${JSON.stringify(box)}`);
132
- }
133
- // Align to 4x4x4 block grid.
134
- const gridBounds = alignGridBounds(voxelBounds, voxelResolution);
135
- let blocks = new BlockMaskBuffer();
136
- if (backend === 'gpu') {
137
- const gpuStart = Date.now();
138
- try {
139
- blocks = await gpuVoxelize(xCol, yCol, zCol, sxCol, syCol, szCol, qxCol, qyCol, qzCol, qwCol, aCol, extents, gridBounds, voxelResolution, opacityCutoff);
140
- const gpuElapsed = Date.now() - gpuStart;
141
- logger.info(`Voxelizing (GPU) done: ${(gpuElapsed / 1000).toFixed(3)}s`);
142
- }
143
- catch (e) {
144
- const gpuElapsed = Date.now() - gpuStart;
145
- logger.error('Voxel GPU backend failed, fallback to CPU.');
146
- logger.error(`Voxelizing (GPU) failed after ${(gpuElapsed / 1000).toFixed(3)}s`);
147
- if (e instanceof Error) {
148
- logger.error(`GPU error message: ${e.message}`);
149
- if (e.stack) {
150
- logger.error(`GPU error stack: ${e.stack}`);
151
- }
152
- }
153
- else {
154
- logger.error(`GPU error: ${String(e)}`);
155
- }
156
- }
157
- }
158
- if (backend === 'cpu' || blocks.count === 0) {
159
- logger.time('Voxelizing (CPU)');
160
- blocks = await cpuVoxelize(xCol, yCol, zCol, sxCol, syCol, szCol, qxCol, qyCol, qzCol, qwCol, aCol, extents, gridBounds, voxelResolution, opacityCutoff, { workerCount: cpuWorkerCount });
161
- logger.timeEnd('Voxelizing (CPU)');
162
- }
163
- // Remove isolated noise voxels and fill tiny holes at block-mask level.
164
- logger.time('Filter blocks');
165
- const blockSize = 4 * voxelResolution;
166
- const numBlocksX = Math.round((gridBounds.max.x - gridBounds.min.x) / blockSize);
167
- const numBlocksY = Math.round((gridBounds.max.y - gridBounds.min.y) / blockSize);
168
- const numBlocksZ = Math.round((gridBounds.max.z - gridBounds.min.z) / blockSize);
169
- blocks = filterAndFillBlocks(blocks, numBlocksX, numBlocksY, numBlocksZ);
170
- logger.timeEnd('Filter blocks');
171
- logger.time('Loading grid');
172
- let grid = SparseVoxelGrid.fromBuffer(blocks, numBlocksX << 2, numBlocksY << 2, numBlocksZ << 2);
173
- blocks.clear();
174
- logger.timeEnd('Loading grid');
175
- let navGridBounds = gridBounds;
176
- // Optional navigability passes (aligned with reference order):
177
- // fillExterior -> fillFloor -> carve.
178
- if (hasFillExterior) {
179
- logger.time('Fill exterior');
180
- const fillResult = await fillExterior(grid, navGridBounds, voxelResolution, navExteriorRadius, navSeed, backend);
181
- grid = fillResult.grid;
182
- navGridBounds = fillResult.gridBounds;
183
- logger.timeEnd('Fill exterior');
184
- }
185
- if (hasFloorFill) {
186
- logger.time('Fill floor');
187
- const floorResult = await fillFloor(grid, navGridBounds, voxelResolution, floorFillDilation, backend);
188
- grid = floorResult.grid;
189
- navGridBounds = floorResult.gridBounds;
190
- logger.timeEnd('Fill floor');
191
- }
192
- if (hasNav) {
193
- logger.time('Carve nav');
194
- const navResult = await carve(grid, navGridBounds, voxelResolution, navCapsule.height, navCapsule.radius, navSeed, backend);
195
- grid = navResult.grid;
196
- navGridBounds = navResult.gridBounds;
197
- logger.timeEnd('Carve nav');
198
- }
199
- // Crop padded bounds before octree build.
200
- // If navigability passes ran, keep navigable extent; otherwise crop to occupied extent.
201
- logger.time('Crop voxel bounds');
202
- const finalCrop = (hasFillExterior || hasFloorFill)
203
- ? cropToNavigable(grid, navGridBounds, voxelResolution)
204
- : cropToOccupied(grid, navGridBounds, voxelResolution);
205
- grid = finalCrop.grid;
206
- navGridBounds = finalCrop.gridBounds;
207
- logger.timeEnd('Crop voxel bounds');
208
- const collisionMeshShape = (() => {
209
- if (collisionMesh === false || collisionMesh === undefined) {
210
- return null;
211
- }
212
- if (collisionMesh === true) {
213
- return 'smooth';
214
- }
215
- if (collisionMesh === 'smooth' || collisionMesh === 'faces') {
216
- return collisionMesh;
217
- }
218
- throw new Error(`Invalid collisionMesh value: ${String(collisionMesh)}. Expected true, false, "smooth", or "faces"`);
219
- })();
220
- const collisionGlb = collisionMeshShape
221
- ? buildCollisionMesh(grid, navGridBounds, voxelResolution, collisionMeshShape)
222
- : undefined;
223
- // BuildSparseOctree emits Laine-Karras nodes + mixed leaf masks.
224
- logger.time('Build octree');
225
- const octree = buildSparseOctree(grid, navGridBounds, sceneBounds, voxelResolution, { consumeGrid: true });
226
- logger.timeEnd('Build octree');
227
- logger.info(`octree: depth=${octree.treeDepth}, interior=${octree.numInteriorNodes}, mixed=${octree.numMixedLeaves}`);
228
- const metadata = {
229
- version: '1.1',
230
- gridBounds: {
231
- min: [octree.gridBounds.min.x, octree.gridBounds.min.y, octree.gridBounds.min.z],
232
- max: [octree.gridBounds.max.x, octree.gridBounds.max.y, octree.gridBounds.max.z]
233
- },
234
- sceneBounds: {
235
- min: [octree.sceneBounds.min.x, octree.sceneBounds.min.y, octree.sceneBounds.min.z],
236
- max: [octree.sceneBounds.max.x, octree.sceneBounds.max.y, octree.sceneBounds.max.z]
237
- },
238
- voxelResolution: octree.voxelResolution,
239
- leafSize: octree.leafSize,
240
- treeDepth: octree.treeDepth,
241
- numInteriorNodes: octree.numInteriorNodes,
242
- numMixedLeaves: octree.numMixedLeaves,
243
- nodeCount: octree.nodes.length,
244
- leafDataCount: octree.leafData.length,
245
- files: ['voxel.bin']
246
- };
247
- const binarySize = (octree.nodes.length + octree.leafData.length) * 4;
248
- const binary = new Uint8Array(binarySize);
249
- const view = new Uint32Array(binary.buffer);
250
- view.set(octree.nodes, 0);
251
- view.set(octree.leafData, octree.nodes.length);
252
- return { metadata, binary, collisionGlb };
253
- };
254
- export async function writeVoxelFiles(outputDir, data, options) {
255
- const { metadata, binary, collisionGlb } = await writeVoxels(data, options?.voxelResolution ?? 0.05, options?.opacityCutoff ?? 0.1, options?.backend ?? 'gpu', options?.collisionMesh ?? false, options?.navExteriorRadius, options?.floorFill ?? false, options?.floorFillDilation ?? 0, options?.cpuWorkerCount ?? -1, options?.box ?? { minCorner: [-100, -100, -100], maxCorner: [100, 100, 100] }, options?.navCapsule, options?.navSeed);
256
- fs.mkdirSync(outputDir, { recursive: true });
257
- const metaPath = path.join(outputDir, 'voxel-meta.json');
258
- const binPath = path.join(outputDir, 'voxel.bin');
259
- logger.info(`writing '${metaPath}'...`);
260
- fs.writeFileSync(metaPath, Buffer.from(JSON.stringify(metadata, null, 2), 'utf-8'));
261
- logger.info(`writing '${binPath}'...`);
262
- fs.writeFileSync(binPath, binary);
263
- if (collisionGlb && collisionGlb.length > 0) {
264
- const glbPath = path.join(outputDir, 'collision.glb');
265
- logger.info(`writing '${glbPath}'...`);
266
- fs.writeFileSync(glbPath, collisionGlb);
267
- }
268
- const totalBytes = binary.length;
269
- if (collisionGlb && collisionGlb.length > 0) {
270
- logger.info(`total size: octree ${(totalBytes / 1024).toFixed(1)} KB, collision mesh ${(collisionGlb.length / 1024).toFixed(1)} KB`);
271
- }
272
- else {
273
- logger.info(`total size: ${(totalBytes / 1024).toFixed(1)} KB`);
274
- }
275
- }
276
- export const voxelUtils = {
277
- getChildOffset,
278
- encodeMorton3,
279
- decodeMorton3
280
- };
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { Buffer } from 'node:buffer';
4
+ import { gzipSync } from 'node:zlib';
5
+ /**
6
+ * Portions of this voxel pipeline are adapted from:
7
+ * https://github.com/playcanvas/splat-transform
8
+ * Copyright (c) 2011-2026 PlayCanvas Ltd.
9
+ * Licensed under the MIT License.
10
+ */
11
+ import { ColIdx } from '../SplatData.js';
12
+ import { logger, cpuVoxelize, gpuVoxelize } from '../utils/index.js';
13
+ import { encodeCompactVoxelBinary, encodeRawVoxelBinary } from '../utils/voxel/binary.js';
14
+ import { fillExterior, fillFloor, carve } from '../utils/voxel/nav.js';
15
+ import { buildCollisionMesh } from '../utils/voxel/mesh.js';
16
+ import { cropToNavigable, cropToOccupied, filterAndFillBlocks } from '../utils/voxel/postprocess.js';
17
+ import { filterCluster, } from '../utils/voxel/filter-cluster.js';
18
+ import { alignGridBounds, ALPHA_THRESHOLD, buildSparseOctree, checkVoxelGridCapacity, decodeMorton3, encodeMorton3, extentsFromQuatScale, getChildOffset, MAX_24BIT_OFFSET, MAX_VOXEL_BLOCK_COUNT_INT32, SparseOctree24BitOverflowError, SparseVoxelGrid, } from '../utils/voxel/common.js';
19
+ // Stay under the hard 31-bit block limit and leave room for CPU memory use.
20
+ const GRID_BLOCK_FALLBACK_TARGET = Math.floor(MAX_VOXEL_BLOCK_COUNT_INT32 * 0.55);
21
+ const OCTREE_24BIT_FALLBACK_TARGET = Math.floor((MAX_24BIT_OFFSET + 1) * 0.98);
22
+ const MAX_RESOLUTION_FALLBACK_ATTEMPTS = 4;
23
+ const RESOLUTION_FALLBACK_ALIGNMENT = 0.01;
24
+ function blockCountFromBounds(bounds, voxelResolution) {
25
+ const blockSize = 4 * voxelResolution;
26
+ const count = {
27
+ x: Math.round((bounds.max.x - bounds.min.x) / blockSize),
28
+ y: Math.round((bounds.max.y - bounds.min.y) / blockSize),
29
+ z: Math.round((bounds.max.z - bounds.min.z) / blockSize),
30
+ };
31
+ return { ...count, total: count.x * count.y * count.z };
32
+ }
33
+ function chooseFallbackResolution(currentVoxelResolution, currentCount, targetCount) {
34
+ // Raise resolution based on how far the count is over target.
35
+ // Round up to 0.01 so metadata stays easy to read.
36
+ const step = RESOLUTION_FALLBACK_ALIGNMENT;
37
+ function align(value) {
38
+ return Number((Math.ceil((value - 1e-12) / step) * step).toFixed(6));
39
+ }
40
+ const scaled = currentVoxelResolution * Math.cbrt(currentCount / targetCount);
41
+ const aligned = align(scaled);
42
+ if (aligned > currentVoxelResolution) {
43
+ return aligned;
44
+ }
45
+ return align(currentVoxelResolution + step);
46
+ }
47
+ /**
48
+ * Build a sparse voxel octree from gaussian splat data.
49
+ *
50
+ * Pipeline (based on https://github.com/playcanvas/splat-transform/blob/8f3b843efdc378f97d4f6a66a3a90a2de6d479a4/src/lib/writers/write-voxel.ts):
51
+ * 1) Compute gaussian extents and scene bounds (`extentsFromQuatScale`).
52
+ * 2) Align grid bounds.
53
+ * 3) Voxelize to `BlockMaskBuffer` via `gpuVoxelize`.
54
+ * 4) Post-process occupancy (`filterAndFillBlocks`, optional `fillExterior`, `fillFloor`, `carve`).
55
+ * 5) Crop bounds (`cropToNavigable` or `cropToOccupied`), then build octree (`buildSparseOctree`).
56
+ * 6) Optionally generate collision mesh (`buildCollisionMesh` → `collision.glb` next to `voxel.bin`).
57
+ *
58
+ * @param data - Gaussian splat source data.
59
+ * @param voxelResolution - Voxel size in world units.
60
+ * @param opacityCutoff - Opacity threshold used during voxelization.
61
+ * @param collisionMesh - Whether to generate collision GLB.
62
+ * @param navExteriorRadius - Exterior fill radius; requires `navSeed`.
63
+ * @param floorFill - Whether to run floor fill before carve.
64
+ * @param floorFillDilation - Horizontal dilation radius for floor fill.
65
+ * @param box - Axis-aligned world-space clamp box for voxelization.
66
+ * @param navCapsule - Capsule config used by `carve`.
67
+ * @param navSeed - Seed position used by exterior/carve flood fills.
68
+ */
69
+ async function writeVoxels(data, voxelResolution = 0.05, opacityCutoff = 0.1, backend = 'gpu', collisionMesh = false, navExteriorRadius, floorFill = false, floorFillDilation = 0, cpuWorkerCount = -1, box = { minCorner: [-100, -100, -100], maxCorner: [100, 100, 100] }, navCapsule, navSeed, nodeEncoding = 'raw', requestedVoxelResolution = voxelResolution) {
70
+ const hasNav = !!(navCapsule && navSeed && navCapsule.height > 0);
71
+ const hasFillExterior = !!(navExteriorRadius && navSeed);
72
+ const hasFloorFill = floorFill;
73
+ logger.info(`voxel params: resolution=${voxelResolution}, opacityCutoff=${opacityCutoff}, backend=${backend}, ` +
74
+ `collisionMesh=${collisionMesh}, navExteriorRadius=${navExteriorRadius ?? 'none'}, ` +
75
+ `floorFill=${floorFill}, floorFillDilation=${floorFillDilation}, cpuWorkerCount=${cpuWorkerCount === -1 ? 'auto' : cpuWorkerCount}, ` +
76
+ `box=${JSON.stringify(box)}, navCapsule=${navCapsule ? JSON.stringify(navCapsule) : 'none'}, ` +
77
+ `navSeed=${navSeed ? JSON.stringify(navSeed) : 'none'}, hasFillExterior=${hasFillExterior}, hasFloorFill=${hasFloorFill}, hasNav=${hasNav}`);
78
+ const xCol = data.table[ColIdx.x];
79
+ const yCol = data.table[ColIdx.y];
80
+ const zCol = data.table[ColIdx.z];
81
+ const sxCol = data.table[ColIdx.sx];
82
+ const syCol = data.table[ColIdx.sy];
83
+ const szCol = data.table[ColIdx.sz];
84
+ const qxCol = data.table[ColIdx.qx];
85
+ const qyCol = data.table[ColIdx.qy];
86
+ const qzCol = data.table[ColIdx.qz];
87
+ const qwCol = data.table[ColIdx.qw];
88
+ const aCol = data.table[ColIdx.a];
89
+ const sceneBounds = {
90
+ min: { x: Infinity, y: Infinity, z: Infinity },
91
+ max: { x: -Infinity, y: -Infinity, z: -Infinity },
92
+ };
93
+ // Compute per-gaussian AABB extents from quaternion+scale and accumulate scene bounds.
94
+ logger.time('Voxel bounding/extents');
95
+ const extents = new Float32Array(data.counts * 3);
96
+ const extentOpacityThreshold = ALPHA_THRESHOLD;
97
+ let invalidExtentCount = 0;
98
+ for (let i = 0; i < data.counts; i++) {
99
+ const e = extentsFromQuatScale(sxCol[i], syCol[i], szCol[i], qxCol[i], qyCol[i], qzCol[i], qwCol[i], aCol[i], extentOpacityThreshold);
100
+ if (!Number.isFinite(e.ex) || !Number.isFinite(e.ey) || !Number.isFinite(e.ez)) {
101
+ extents[i * 3 + 0] = 0;
102
+ extents[i * 3 + 1] = 0;
103
+ extents[i * 3 + 2] = 0;
104
+ invalidExtentCount++;
105
+ }
106
+ else {
107
+ extents[i * 3 + 0] = e.ex;
108
+ extents[i * 3 + 1] = e.ey;
109
+ extents[i * 3 + 2] = e.ez;
110
+ sceneBounds.min.x = Math.min(sceneBounds.min.x, xCol[i] - e.ex);
111
+ sceneBounds.min.y = Math.min(sceneBounds.min.y, yCol[i] - e.ey);
112
+ sceneBounds.min.z = Math.min(sceneBounds.min.z, zCol[i] - e.ez);
113
+ sceneBounds.max.x = Math.max(sceneBounds.max.x, xCol[i] + e.ex);
114
+ sceneBounds.max.y = Math.max(sceneBounds.max.y, yCol[i] + e.ey);
115
+ sceneBounds.max.z = Math.max(sceneBounds.max.z, zCol[i] + e.ez);
116
+ }
117
+ }
118
+ if (invalidExtentCount > 0) {
119
+ logger.info(`voxel: skipped ${invalidExtentCount} gaussians with invalid extent values`);
120
+ }
121
+ logger.info(`scene extents: (${sceneBounds.min.x.toFixed(2)},${sceneBounds.min.y.toFixed(2)},${sceneBounds.min.z.toFixed(2)}) - (${sceneBounds.max.x.toFixed(2)},${sceneBounds.max.y.toFixed(2)},${sceneBounds.max.z.toFixed(2)})`);
122
+ logger.timeEnd('Voxel bounding/extents');
123
+ function resolveVoxelBounds(resolution) {
124
+ const exteriorPad = hasFillExterior ? (Math.ceil(navExteriorRadius / resolution) + 1) * resolution : 0;
125
+ const floorPad = hasFloorFill ? (Math.ceil(floorFillDilation / resolution) + 1) * resolution : 0;
126
+ const padXZ = Math.max(exteriorPad, floorPad);
127
+ const padY = exteriorPad;
128
+ const rawVoxelBounds = {
129
+ min: {
130
+ x: sceneBounds.min.x - padXZ,
131
+ y: sceneBounds.min.y - padY,
132
+ z: sceneBounds.min.z - padXZ,
133
+ },
134
+ max: {
135
+ x: sceneBounds.max.x + padXZ,
136
+ y: sceneBounds.max.y + padY,
137
+ z: sceneBounds.max.z + padXZ,
138
+ },
139
+ };
140
+ const voxelBounds = {
141
+ min: {
142
+ x: Math.max(rawVoxelBounds.min.x, box.minCorner[0]),
143
+ y: Math.max(rawVoxelBounds.min.y, box.minCorner[1]),
144
+ z: Math.max(rawVoxelBounds.min.z, box.minCorner[2]),
145
+ },
146
+ max: {
147
+ x: Math.min(rawVoxelBounds.max.x, box.maxCorner[0]),
148
+ y: Math.min(rawVoxelBounds.max.y, box.maxCorner[1]),
149
+ z: Math.min(rawVoxelBounds.max.z, box.maxCorner[2]),
150
+ },
151
+ };
152
+ return { rawVoxelBounds, voxelBounds, gridBounds: alignGridBounds(voxelBounds, resolution) };
153
+ }
154
+ let bounds = resolveVoxelBounds(voxelResolution);
155
+ let blockCount = blockCountFromBounds(bounds.gridBounds, voxelResolution);
156
+ for (let attempt = 0; blockCount.total > GRID_BLOCK_FALLBACK_TARGET; attempt++) {
157
+ if (attempt >= MAX_RESOLUTION_FALLBACK_ATTEMPTS) {
158
+ break;
159
+ }
160
+ const nextVoxelResolution = chooseFallbackResolution(voxelResolution, blockCount.total, GRID_BLOCK_FALLBACK_TARGET);
161
+ const detail = `grid blocks=${blockCount.x}x${blockCount.y}x${blockCount.z}, total=${blockCount.total}, target=${GRID_BLOCK_FALLBACK_TARGET}`;
162
+ logger.info(`voxel resolution fallback: ${detail}; ` + `resolution ${voxelResolution} -> ${nextVoxelResolution}`);
163
+ voxelResolution = nextVoxelResolution;
164
+ bounds = resolveVoxelBounds(voxelResolution);
165
+ blockCount = blockCountFromBounds(bounds.gridBounds, voxelResolution);
166
+ }
167
+ const { rawVoxelBounds, voxelBounds } = bounds;
168
+ const gridBounds = bounds.gridBounds;
169
+ const boxCropApplied = voxelBounds.min.x > rawVoxelBounds.min.x ||
170
+ voxelBounds.min.y > rawVoxelBounds.min.y ||
171
+ voxelBounds.min.z > rawVoxelBounds.min.z ||
172
+ voxelBounds.max.x < rawVoxelBounds.max.x ||
173
+ voxelBounds.max.y < rawVoxelBounds.max.y ||
174
+ voxelBounds.max.z < rawVoxelBounds.max.z;
175
+ if (boxCropApplied) {
176
+ logger.info(`voxel box crop applied: ` +
177
+ `raw=(${rawVoxelBounds.min.x.toFixed(2)},${rawVoxelBounds.min.y.toFixed(2)},${rawVoxelBounds.min.z.toFixed(2)})-` +
178
+ `(${rawVoxelBounds.max.x.toFixed(2)},${rawVoxelBounds.max.y.toFixed(2)},${rawVoxelBounds.max.z.toFixed(2)}), ` +
179
+ `cropped=(${voxelBounds.min.x.toFixed(2)},${voxelBounds.min.y.toFixed(2)},${voxelBounds.min.z.toFixed(2)})-` +
180
+ `(${voxelBounds.max.x.toFixed(2)},${voxelBounds.max.y.toFixed(2)},${voxelBounds.max.z.toFixed(2)})`);
181
+ }
182
+ if (voxelBounds.min.x >= voxelBounds.max.x ||
183
+ voxelBounds.min.y >= voxelBounds.max.y ||
184
+ voxelBounds.min.z >= voxelBounds.max.z) {
185
+ throw new Error(`voxel box does not overlap scene bounds: box=${JSON.stringify(box)}`);
186
+ }
187
+ if (voxelResolution !== requestedVoxelResolution) {
188
+ logger.info(`voxel effective resolution=${voxelResolution} (requested=${requestedVoxelResolution})`);
189
+ }
190
+ checkVoxelGridCapacity(gridBounds, voxelResolution);
191
+ let blocks;
192
+ if (backend === 'gpu') {
193
+ const gpuStart = Date.now();
194
+ try {
195
+ blocks = await gpuVoxelize(xCol, yCol, zCol, sxCol, syCol, szCol, qxCol, qyCol, qzCol, qwCol, aCol, extents, gridBounds, voxelResolution, opacityCutoff);
196
+ const gpuElapsed = Date.now() - gpuStart;
197
+ logger.info(`Voxelizing (GPU) done: ${(gpuElapsed / 1000).toFixed(3)}s`);
198
+ }
199
+ catch (e) {
200
+ const gpuElapsed = Date.now() - gpuStart;
201
+ logger.error(`Voxelizing (GPU) failed after ${(gpuElapsed / 1000).toFixed(3)}s`);
202
+ if (e instanceof Error) {
203
+ logger.error(`GPU error message: ${e.message}`);
204
+ if (e.stack) {
205
+ logger.error(`GPU error stack: ${e.stack}`);
206
+ }
207
+ }
208
+ else {
209
+ logger.error(`GPU error: ${String(e)}`);
210
+ }
211
+ logger.error('Voxel GPU backend failed, fallback to CPU.');
212
+ }
213
+ }
214
+ if (!blocks || blocks.count === 0) {
215
+ logger.time('Voxelizing (CPU)');
216
+ blocks = await cpuVoxelize(xCol, yCol, zCol, sxCol, syCol, szCol, qxCol, qyCol, qzCol, qwCol, aCol, extents, gridBounds, voxelResolution, opacityCutoff, { workerCount: cpuWorkerCount });
217
+ logger.timeEnd('Voxelizing (CPU)');
218
+ }
219
+ // Remove isolated noise voxels and fill tiny holes at block-mask level.
220
+ logger.time('Filter blocks');
221
+ const blockSize = 4 * voxelResolution;
222
+ const numBlocksX = Math.round((gridBounds.max.x - gridBounds.min.x) / blockSize);
223
+ const numBlocksY = Math.round((gridBounds.max.y - gridBounds.min.y) / blockSize);
224
+ const numBlocksZ = Math.round((gridBounds.max.z - gridBounds.min.z) / blockSize);
225
+ blocks = filterAndFillBlocks(blocks, numBlocksX, numBlocksY, numBlocksZ);
226
+ logger.timeEnd('Filter blocks');
227
+ logger.time('Loading grid');
228
+ let grid = SparseVoxelGrid.fromBuffer(blocks, numBlocksX << 2, numBlocksY << 2, numBlocksZ << 2);
229
+ blocks.clear();
230
+ logger.timeEnd('Loading grid');
231
+ let navGridBounds = gridBounds;
232
+ // Optional navigability passes (aligned with reference order):
233
+ // fillExterior -> fillFloor -> carve.
234
+ if (hasFillExterior) {
235
+ logger.time('Fill exterior');
236
+ const fillResult = await fillExterior(grid, navGridBounds, voxelResolution, navExteriorRadius, navSeed, backend);
237
+ grid = fillResult.grid;
238
+ navGridBounds = fillResult.gridBounds;
239
+ logger.timeEnd('Fill exterior');
240
+ }
241
+ if (hasFloorFill) {
242
+ logger.time('Fill floor');
243
+ const floorResult = await fillFloor(grid, navGridBounds, voxelResolution, floorFillDilation, backend);
244
+ grid = floorResult.grid;
245
+ navGridBounds = floorResult.gridBounds;
246
+ logger.timeEnd('Fill floor');
247
+ }
248
+ if (hasNav) {
249
+ logger.time('Carve nav');
250
+ const navResult = await carve(grid, navGridBounds, voxelResolution, navCapsule.height, navCapsule.radius, navSeed, backend);
251
+ grid = navResult.grid;
252
+ navGridBounds = navResult.gridBounds;
253
+ logger.timeEnd('Carve nav');
254
+ }
255
+ // Crop padded bounds before octree build.
256
+ // If navigability passes ran, keep navigable extent; otherwise crop to occupied extent.
257
+ logger.time('Crop voxel bounds');
258
+ const finalCrop = hasFillExterior || hasFloorFill
259
+ ? cropToNavigable(grid, navGridBounds, voxelResolution)
260
+ : cropToOccupied(grid, navGridBounds, voxelResolution);
261
+ grid = finalCrop.grid;
262
+ navGridBounds = finalCrop.gridBounds;
263
+ logger.timeEnd('Crop voxel bounds');
264
+ function resolveCollisionMeshShape() {
265
+ if (collisionMesh === false || collisionMesh === undefined) {
266
+ return null;
267
+ }
268
+ if (collisionMesh === true) {
269
+ return 'smooth';
270
+ }
271
+ if (collisionMesh === 'smooth' || collisionMesh === 'faces') {
272
+ return collisionMesh;
273
+ }
274
+ throw new Error(`Invalid collisionMesh value: ${String(collisionMesh)}. Expected true, false, "smooth", or "faces"`);
275
+ }
276
+ const collisionMeshShape = resolveCollisionMeshShape();
277
+ const collisionGlb = collisionMeshShape
278
+ ? buildCollisionMesh(grid, navGridBounds, voxelResolution, collisionMeshShape)
279
+ : undefined;
280
+ // BuildSparseOctree emits Laine-Karras nodes + mixed leaf masks.
281
+ logger.time('Build octree');
282
+ let octree;
283
+ try {
284
+ octree = buildSparseOctree(grid, navGridBounds, sceneBounds, voxelResolution, { consumeGrid: true });
285
+ logger.timeEnd('Build octree');
286
+ }
287
+ catch (error) {
288
+ logger.timeEnd('Build octree');
289
+ if (error instanceof SparseOctree24BitOverflowError) {
290
+ error.voxelResolution = voxelResolution;
291
+ }
292
+ throw error;
293
+ }
294
+ logger.info(`octree: depth=${octree.treeDepth}, interior=${octree.numInteriorNodes}, mixed=${octree.numMixedLeaves}`);
295
+ const metadata = {
296
+ version: '1.2',
297
+ gridBounds: {
298
+ min: [octree.gridBounds.min.x, octree.gridBounds.min.y, octree.gridBounds.min.z],
299
+ max: [octree.gridBounds.max.x, octree.gridBounds.max.y, octree.gridBounds.max.z],
300
+ },
301
+ sceneBounds: {
302
+ min: [octree.sceneBounds.min.x, octree.sceneBounds.min.y, octree.sceneBounds.min.z],
303
+ max: [octree.sceneBounds.max.x, octree.sceneBounds.max.y, octree.sceneBounds.max.z],
304
+ },
305
+ voxelResolution: octree.voxelResolution,
306
+ leafSize: octree.leafSize,
307
+ treeDepth: octree.treeDepth,
308
+ numInteriorNodes: octree.numInteriorNodes,
309
+ numMixedLeaves: octree.numMixedLeaves,
310
+ nodeCount: octree.nodes.length,
311
+ leafDataCount: octree.leafData.length,
312
+ files: ['voxel.bin'],
313
+ nodeEncoding,
314
+ };
315
+ const binary = nodeEncoding === 'compact'
316
+ ? encodeCompactVoxelBinary(octree.nodes, octree.leafData, octree.numInteriorNodes, octree.numMixedLeaves)
317
+ : encodeRawVoxelBinary(octree.nodes, octree.leafData);
318
+ return { metadata, binary, collisionGlb };
319
+ }
320
+ export async function writeVoxelFiles(outputDir, data, options) {
321
+ const gzip = options?.gzip ?? false;
322
+ const requestedVoxelResolution = options?.voxelResolution ?? 0.05;
323
+ const opacityCutoff = options?.opacityCutoff ?? 0.1;
324
+ const backend = options?.backend ?? 'gpu';
325
+ const collisionMesh = options?.collisionMesh ?? false;
326
+ const floorFill = options?.floorFill ?? false;
327
+ const floorFillDilation = options?.floorFillDilation ?? 0;
328
+ const cpuWorkerCount = options?.cpuWorkerCount ?? -1;
329
+ const box = options?.box ?? { minCorner: [-100, -100, -100], maxCorner: [100, 100, 100] };
330
+ const nodeEncoding = options?.nodeEncoding ?? 'raw';
331
+ const filterClusterOptions = options?.filterCluster ?? true;
332
+ let sourceData = data;
333
+ if (filterClusterOptions) {
334
+ const filterOptions = filterClusterOptions === true ? {} : filterClusterOptions;
335
+ const filterRuntime = {
336
+ backend,
337
+ box,
338
+ };
339
+ if (options?.cpuWorkerCount !== undefined) {
340
+ filterRuntime.cpuWorkerCount = cpuWorkerCount;
341
+ }
342
+ logger.info('voxel filterCluster enabled');
343
+ sourceData = await filterCluster(data, filterOptions, filterRuntime);
344
+ }
345
+ let attemptVoxelResolution = requestedVoxelResolution;
346
+ let result;
347
+ for (let attempt = 0; attempt <= MAX_RESOLUTION_FALLBACK_ATTEMPTS; attempt++) {
348
+ try {
349
+ result = await writeVoxels(sourceData, attemptVoxelResolution, opacityCutoff, backend, collisionMesh, options?.navExteriorRadius, floorFill, floorFillDilation, cpuWorkerCount, box, options?.navCapsule, options?.navSeed, nodeEncoding, requestedVoxelResolution);
350
+ break;
351
+ }
352
+ catch (error) {
353
+ if (!(error instanceof SparseOctree24BitOverflowError) || attempt >= MAX_RESOLUTION_FALLBACK_ATTEMPTS) {
354
+ throw error;
355
+ }
356
+ const failedResolution = error.voxelResolution ?? attemptVoxelResolution;
357
+ const target = Math.min(OCTREE_24BIT_FALLBACK_TARGET, Math.floor(error.limit * 0.98));
358
+ const nextVoxelResolution = chooseFallbackResolution(failedResolution, error.actual, target);
359
+ const detail = `octree 24-bit ${error.kind} count=${error.actual}, target=${target}, limit=${error.limit}`;
360
+ logger.info(`voxel resolution fallback: ${detail}; ` + `resolution ${failedResolution} -> ${nextVoxelResolution}`);
361
+ attemptVoxelResolution = nextVoxelResolution;
362
+ }
363
+ }
364
+ if (!result) {
365
+ throw new Error('voxel resolution fallback exhausted without producing output');
366
+ }
367
+ const { metadata, binary, collisionGlb } = result;
368
+ fs.mkdirSync(outputDir, { recursive: true });
369
+ const metaPath = path.join(outputDir, 'voxel-meta.json');
370
+ const rawBytes = binary.length;
371
+ let binPayload;
372
+ let binPath;
373
+ if (gzip) {
374
+ binPayload = gzipSync(binary);
375
+ binPath = path.join(outputDir, 'voxel.bin.gz');
376
+ metadata.files = ['voxel.bin.gz'];
377
+ }
378
+ else {
379
+ binPayload = binary;
380
+ binPath = path.join(outputDir, 'voxel.bin');
381
+ }
382
+ logger.info(`writing '${metaPath}'...`);
383
+ fs.writeFileSync(metaPath, Buffer.from(JSON.stringify(metadata, null, 2), 'utf-8'));
384
+ logger.info(`writing '${binPath}'...`);
385
+ fs.writeFileSync(binPath, binPayload);
386
+ if (collisionGlb && collisionGlb.length > 0) {
387
+ const glbPath = path.join(outputDir, 'collision.glb');
388
+ logger.info(`writing '${glbPath}'...`);
389
+ fs.writeFileSync(glbPath, collisionGlb);
390
+ }
391
+ function formatSize(bytes) {
392
+ if (bytes >= 1024 * 1024) {
393
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
394
+ }
395
+ return `${(bytes / 1024).toFixed(1)} KB`;
396
+ }
397
+ const octreeSizeMsg = gzip
398
+ ? `octree raw: ${formatSize(rawBytes)}, gzip: ${formatSize(binPayload.length)} (${rawBytes > 0 ? ((binPayload.length / rawBytes) * 100).toFixed(1) : 'n/a'}%)`
399
+ : `octree ${formatSize(rawBytes)}`;
400
+ if (collisionGlb && collisionGlb.length > 0) {
401
+ logger.info(`${octreeSizeMsg}, collision mesh ${formatSize(collisionGlb.length)}`);
402
+ }
403
+ else {
404
+ logger.info(octreeSizeMsg);
405
+ }
406
+ }
407
+ export const voxelUtils = {
408
+ getChildOffset,
409
+ encodeMorton3,
410
+ decodeMorton3,
411
+ };