@manycore/aholo-splat-transform 1.2.8 → 1.2.10
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.
- package/CHANGELOG.md +124 -113
- package/README.md +39 -39
- package/THIRD_PARTY_LICENSES.txt +1373 -1373
- package/bin/cli.js +125 -118
- package/dist/SplatData.d.ts +67 -67
- package/dist/SplatData.js +167 -150
- package/dist/constant.d.ts +3 -3
- package/dist/constant.js +13 -13
- package/dist/file/IFile.d.ts +5 -5
- package/dist/file/IFile.js +1 -1
- package/dist/file/esz.d.ts +11 -11
- package/dist/file/esz.js +337 -322
- package/dist/file/index.d.ts +8 -8
- package/dist/file/index.js +7 -7
- package/dist/file/ksplat.d.ts +12 -12
- package/dist/file/ksplat.js +293 -231
- package/dist/file/lcc.d.ts +11 -11
- package/dist/file/lcc.js +161 -158
- package/dist/file/ply.d.ts +13 -13
- package/dist/file/ply.js +439 -390
- package/dist/file/sog.d.ts +80 -80
- package/dist/file/sog.js +525 -494
- package/dist/file/splat.d.ts +6 -6
- package/dist/file/splat.js +119 -99
- package/dist/file/spz.d.ts +11 -11
- package/dist/file/spz.js +597 -583
- package/dist/file/voxel.d.ts +43 -37
- package/dist/file/voxel.js +411 -280
- package/dist/index.d.ts +33 -33
- package/dist/index.js +54 -54
- package/dist/native/index.d.ts +54 -54
- package/dist/native/index.js +122 -129
- package/dist/native/utils.d.ts +1 -0
- package/dist/native/utils.js +54 -0
- package/dist/tasks/AutoChunkLodTask.d.ts +13 -13
- package/dist/tasks/AutoChunkLodTask.js +117 -117
- package/dist/tasks/AutoLodTask.d.ts +10 -10
- package/dist/tasks/AutoLodTask.js +20 -20
- package/dist/tasks/BaseTask.d.ts +15 -15
- package/dist/tasks/BaseTask.js +5 -5
- package/dist/tasks/FlexLodTask.d.ts +12 -12
- package/dist/tasks/FlexLodTask.js +54 -44
- package/dist/tasks/ModifyTask.d.ts +9 -9
- package/dist/tasks/ModifyTask.js +166 -156
- package/dist/tasks/ReadTask.d.ts +9 -9
- package/dist/tasks/ReadTask.js +29 -29
- package/dist/tasks/SkeletonLodTask.d.ts +10 -10
- package/dist/tasks/SkeletonLodTask.js +176 -156
- package/dist/tasks/VoxelTask.d.ts +35 -30
- package/dist/tasks/VoxelTask.js +40 -37
- package/dist/tasks/WriteTask.d.ts +12 -12
- package/dist/tasks/WriteTask.js +70 -70
- package/dist/utils/BufferReader.d.ts +12 -12
- package/dist/utils/BufferReader.js +45 -45
- package/dist/utils/Logger.d.ts +11 -11
- package/dist/utils/Logger.js +40 -40
- package/dist/utils/StreamChunkDecoder.d.ts +16 -16
- package/dist/utils/StreamChunkDecoder.js +31 -31
- package/dist/utils/index.d.ts +27 -27
- package/dist/utils/index.js +101 -101
- package/dist/utils/k-means.d.ts +4 -4
- package/dist/utils/k-means.js +340 -341
- package/dist/utils/math.d.ts +46 -46
- package/dist/utils/math.js +350 -346
- package/dist/utils/quantize-1d.d.ts +4 -4
- package/dist/utils/quantize-1d.js +164 -164
- package/dist/utils/sh-rotate.d.ts +2 -2
- package/dist/utils/sh-rotate.js +236 -175
- package/dist/utils/splat.d.ts +21 -21
- package/dist/utils/splat.js +397 -387
- package/dist/utils/voxel/binary.d.ts +8 -0
- package/dist/utils/voxel/binary.js +176 -0
- package/dist/utils/voxel/common.d.ts +178 -162
- package/dist/utils/voxel/common.js +1752 -1682
- package/dist/utils/voxel/coplanar-merge.d.ts +63 -63
- package/dist/utils/voxel/coplanar-merge.js +818 -819
- package/dist/utils/voxel/filter-cluster.d.ts +20 -0
- package/dist/utils/voxel/filter-cluster.js +628 -0
- package/dist/utils/voxel/gpu-dilation.d.ts +2 -2
- package/dist/utils/voxel/gpu-dilation.js +677 -656
- package/dist/utils/voxel/marching-cubes.d.ts +42 -42
- package/dist/utils/voxel/marching-cubes.js +1645 -1657
- package/dist/utils/voxel/mesh.d.ts +3 -3
- package/dist/utils/voxel/mesh.js +130 -130
- package/dist/utils/voxel/nav.d.ts +29 -29
- package/dist/utils/voxel/nav.js +1068 -1043
- package/dist/utils/voxel/postprocess.d.ts +23 -23
- package/dist/utils/voxel/postprocess.js +408 -375
- package/dist/utils/voxel/voxel-faces.d.ts +18 -18
- package/dist/utils/voxel/voxel-faces.js +662 -663
- package/dist/utils/voxel/voxelize.d.ts +34 -33
- package/dist/utils/voxel/voxelize.js +1208 -1193
- package/dist/utils/webgpu.d.ts +8 -8
- package/dist/utils/webgpu.js +122 -122
- package/package.json +38 -39
- package/dist/native/cpp/bin/linux/binding.node +0 -0
- package/dist/native/cpp/bin/windows/binding.node +0 -0
package/dist/file/voxel.js
CHANGED
|
@@ -1,280 +1,411 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { Buffer } from 'node:buffer';
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
:
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
logger.timeEnd('
|
|
227
|
-
logger.
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
+
};
|