@playcanvas/splat-transform 0.4.0 → 0.4.2
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/LICENSE +19 -19
- package/README.md +125 -125
- package/bin/cli.mjs +5 -5
- package/dist/index.mjs +437 -420
- package/dist/index.mjs.map +1 -1
- package/package.json +56 -56
package/dist/index.mjs
CHANGED
|
@@ -1881,7 +1881,7 @@ class Quat {
|
|
|
1881
1881
|
}
|
|
1882
1882
|
}
|
|
1883
1883
|
|
|
1884
|
-
var version = "0.
|
|
1884
|
+
var version = "0.4.2";
|
|
1885
1885
|
|
|
1886
1886
|
class Column {
|
|
1887
1887
|
name;
|
|
@@ -2307,6 +2307,309 @@ const process = (dataTable, processActions) => {
|
|
|
2307
2307
|
return result;
|
|
2308
2308
|
};
|
|
2309
2309
|
|
|
2310
|
+
// Half-precision floating point decoder
|
|
2311
|
+
function decodeFloat16(encoded) {
|
|
2312
|
+
const signBit = (encoded >> 15) & 1;
|
|
2313
|
+
const exponent = (encoded >> 10) & 0x1f;
|
|
2314
|
+
const mantissa = encoded & 0x3ff;
|
|
2315
|
+
if (exponent === 0) {
|
|
2316
|
+
if (mantissa === 0) {
|
|
2317
|
+
return signBit ? -0 : 0.0;
|
|
2318
|
+
}
|
|
2319
|
+
// Denormalized number
|
|
2320
|
+
let m = mantissa;
|
|
2321
|
+
let exp = -14;
|
|
2322
|
+
while (!(m & 0x400)) {
|
|
2323
|
+
m <<= 1;
|
|
2324
|
+
exp--;
|
|
2325
|
+
}
|
|
2326
|
+
m &= 0x3ff;
|
|
2327
|
+
const finalExp = exp + 127;
|
|
2328
|
+
const finalMantissa = m << 13;
|
|
2329
|
+
const bits = (signBit << 31) | (finalExp << 23) | finalMantissa;
|
|
2330
|
+
return new Float32Array(new Uint32Array([bits]).buffer)[0];
|
|
2331
|
+
}
|
|
2332
|
+
if (exponent === 0x1f) {
|
|
2333
|
+
return mantissa === 0 ? (signBit ? -Infinity : Infinity) : NaN;
|
|
2334
|
+
}
|
|
2335
|
+
const finalExp = exponent - 15 + 127;
|
|
2336
|
+
const finalMantissa = mantissa << 13;
|
|
2337
|
+
const bits = (signBit << 31) | (finalExp << 23) | finalMantissa;
|
|
2338
|
+
return new Float32Array(new Uint32Array([bits]).buffer)[0];
|
|
2339
|
+
}
|
|
2340
|
+
const COMPRESSION_MODES = [
|
|
2341
|
+
{
|
|
2342
|
+
centerBytes: 12,
|
|
2343
|
+
scaleBytes: 12,
|
|
2344
|
+
rotationBytes: 16,
|
|
2345
|
+
colorBytes: 4,
|
|
2346
|
+
harmonicsBytes: 4,
|
|
2347
|
+
scaleStartByte: 12,
|
|
2348
|
+
rotationStartByte: 24,
|
|
2349
|
+
colorStartByte: 40,
|
|
2350
|
+
harmonicsStartByte: 44,
|
|
2351
|
+
scaleQuantRange: 1
|
|
2352
|
+
},
|
|
2353
|
+
{
|
|
2354
|
+
centerBytes: 6,
|
|
2355
|
+
scaleBytes: 6,
|
|
2356
|
+
rotationBytes: 8,
|
|
2357
|
+
colorBytes: 4,
|
|
2358
|
+
harmonicsBytes: 2,
|
|
2359
|
+
scaleStartByte: 6,
|
|
2360
|
+
rotationStartByte: 12,
|
|
2361
|
+
colorStartByte: 20,
|
|
2362
|
+
harmonicsStartByte: 24,
|
|
2363
|
+
scaleQuantRange: 32767
|
|
2364
|
+
},
|
|
2365
|
+
{
|
|
2366
|
+
centerBytes: 6,
|
|
2367
|
+
scaleBytes: 6,
|
|
2368
|
+
rotationBytes: 8,
|
|
2369
|
+
colorBytes: 4,
|
|
2370
|
+
harmonicsBytes: 1,
|
|
2371
|
+
scaleStartByte: 6,
|
|
2372
|
+
rotationStartByte: 12,
|
|
2373
|
+
colorStartByte: 20,
|
|
2374
|
+
harmonicsStartByte: 24,
|
|
2375
|
+
scaleQuantRange: 32767
|
|
2376
|
+
}
|
|
2377
|
+
];
|
|
2378
|
+
const HARMONICS_COMPONENT_COUNT = [0, 9, 24, 45];
|
|
2379
|
+
const readKsplat = async (fileHandle) => {
|
|
2380
|
+
const stats = await fileHandle.stat();
|
|
2381
|
+
const totalSize = stats.size;
|
|
2382
|
+
// Load complete file
|
|
2383
|
+
const fileBuffer = Buffer$1.alloc(totalSize);
|
|
2384
|
+
await fileHandle.read(fileBuffer, 0, totalSize, 0);
|
|
2385
|
+
const MAIN_HEADER_SIZE = 4096;
|
|
2386
|
+
const SECTION_HEADER_SIZE = 1024;
|
|
2387
|
+
if (totalSize < MAIN_HEADER_SIZE) {
|
|
2388
|
+
throw new Error('File too small to be valid .ksplat format');
|
|
2389
|
+
}
|
|
2390
|
+
// Parse main header
|
|
2391
|
+
const mainHeader = new DataView(fileBuffer.buffer, fileBuffer.byteOffset, MAIN_HEADER_SIZE);
|
|
2392
|
+
const majorVersion = mainHeader.getUint8(0);
|
|
2393
|
+
const minorVersion = mainHeader.getUint8(1);
|
|
2394
|
+
if (majorVersion !== 0 || minorVersion < 1) {
|
|
2395
|
+
throw new Error(`Unsupported version ${majorVersion}.${minorVersion}`);
|
|
2396
|
+
}
|
|
2397
|
+
const maxSections = mainHeader.getUint32(4, true);
|
|
2398
|
+
const numSplats = mainHeader.getUint32(16, true);
|
|
2399
|
+
const compressionMode = mainHeader.getUint16(20, true);
|
|
2400
|
+
if (compressionMode > 2) {
|
|
2401
|
+
throw new Error(`Invalid compression mode: ${compressionMode}`);
|
|
2402
|
+
}
|
|
2403
|
+
const minHarmonicsValue = mainHeader.getFloat32(36, true) || -1.5;
|
|
2404
|
+
const maxHarmonicsValue = mainHeader.getFloat32(40, true) || 1.5;
|
|
2405
|
+
if (numSplats === 0) {
|
|
2406
|
+
throw new Error('Invalid .ksplat file: file is empty');
|
|
2407
|
+
}
|
|
2408
|
+
// First pass: scan all sections to find maximum harmonics degree
|
|
2409
|
+
let maxHarmonicsDegree = 0;
|
|
2410
|
+
for (let sectionIdx = 0; sectionIdx < maxSections; sectionIdx++) {
|
|
2411
|
+
const sectionHeaderOffset = MAIN_HEADER_SIZE + sectionIdx * SECTION_HEADER_SIZE;
|
|
2412
|
+
const sectionHeader = new DataView(fileBuffer.buffer, fileBuffer.byteOffset + sectionHeaderOffset, SECTION_HEADER_SIZE);
|
|
2413
|
+
const sectionSplatCount = sectionHeader.getUint32(0, true);
|
|
2414
|
+
if (sectionSplatCount === 0)
|
|
2415
|
+
continue; // Skip empty sections
|
|
2416
|
+
const harmonicsDegree = sectionHeader.getUint16(40, true);
|
|
2417
|
+
maxHarmonicsDegree = Math.max(maxHarmonicsDegree, harmonicsDegree);
|
|
2418
|
+
}
|
|
2419
|
+
// Initialize data storage with base columns
|
|
2420
|
+
const columns = [
|
|
2421
|
+
new Column('x', new Float32Array(numSplats)),
|
|
2422
|
+
new Column('y', new Float32Array(numSplats)),
|
|
2423
|
+
new Column('z', new Float32Array(numSplats)),
|
|
2424
|
+
new Column('scale_0', new Float32Array(numSplats)),
|
|
2425
|
+
new Column('scale_1', new Float32Array(numSplats)),
|
|
2426
|
+
new Column('scale_2', new Float32Array(numSplats)),
|
|
2427
|
+
new Column('f_dc_0', new Float32Array(numSplats)),
|
|
2428
|
+
new Column('f_dc_1', new Float32Array(numSplats)),
|
|
2429
|
+
new Column('f_dc_2', new Float32Array(numSplats)),
|
|
2430
|
+
new Column('opacity', new Float32Array(numSplats)),
|
|
2431
|
+
new Column('rot_0', new Float32Array(numSplats)),
|
|
2432
|
+
new Column('rot_1', new Float32Array(numSplats)),
|
|
2433
|
+
new Column('rot_2', new Float32Array(numSplats)),
|
|
2434
|
+
new Column('rot_3', new Float32Array(numSplats))
|
|
2435
|
+
];
|
|
2436
|
+
// Add spherical harmonics columns based on maximum degree found
|
|
2437
|
+
const maxHarmonicsComponentCount = HARMONICS_COMPONENT_COUNT[maxHarmonicsDegree];
|
|
2438
|
+
for (let i = 0; i < maxHarmonicsComponentCount; i++) {
|
|
2439
|
+
columns.push(new Column(`f_rest_${i}`, new Float32Array(numSplats)));
|
|
2440
|
+
}
|
|
2441
|
+
const { centerBytes, scaleBytes, rotationBytes, colorBytes, harmonicsBytes, scaleStartByte, rotationStartByte, colorStartByte, harmonicsStartByte, scaleQuantRange } = COMPRESSION_MODES[compressionMode];
|
|
2442
|
+
let currentSectionDataOffset = MAIN_HEADER_SIZE + maxSections * SECTION_HEADER_SIZE;
|
|
2443
|
+
let splatIndex = 0;
|
|
2444
|
+
// Process each section
|
|
2445
|
+
for (let sectionIdx = 0; sectionIdx < maxSections; sectionIdx++) {
|
|
2446
|
+
const sectionHeaderOffset = MAIN_HEADER_SIZE + sectionIdx * SECTION_HEADER_SIZE;
|
|
2447
|
+
const sectionHeader = new DataView(fileBuffer.buffer, fileBuffer.byteOffset + sectionHeaderOffset, SECTION_HEADER_SIZE);
|
|
2448
|
+
const sectionSplatCount = sectionHeader.getUint32(0, true);
|
|
2449
|
+
const maxSectionSplats = sectionHeader.getUint32(4, true);
|
|
2450
|
+
const bucketCapacity = sectionHeader.getUint32(8, true);
|
|
2451
|
+
const bucketCount = sectionHeader.getUint32(12, true);
|
|
2452
|
+
const spatialBlockSize = sectionHeader.getFloat32(16, true);
|
|
2453
|
+
const bucketStorageSize = sectionHeader.getUint16(20, true);
|
|
2454
|
+
const quantizationRange = sectionHeader.getUint32(24, true) || scaleQuantRange;
|
|
2455
|
+
const fullBuckets = sectionHeader.getUint32(32, true);
|
|
2456
|
+
const partialBuckets = sectionHeader.getUint32(36, true);
|
|
2457
|
+
const harmonicsDegree = sectionHeader.getUint16(40, true);
|
|
2458
|
+
// Calculate layout
|
|
2459
|
+
const fullBucketSplats = fullBuckets * bucketCapacity;
|
|
2460
|
+
const partialBucketMetaSize = partialBuckets * 4;
|
|
2461
|
+
const totalBucketStorageSize = bucketStorageSize * bucketCount + partialBucketMetaSize;
|
|
2462
|
+
const harmonicsComponentCount = HARMONICS_COMPONENT_COUNT[harmonicsDegree];
|
|
2463
|
+
const bytesPerSplat = centerBytes + scaleBytes + rotationBytes +
|
|
2464
|
+
colorBytes + harmonicsComponentCount * harmonicsBytes;
|
|
2465
|
+
const sectionDataSize = bytesPerSplat * maxSectionSplats;
|
|
2466
|
+
// Calculate decompression parameters
|
|
2467
|
+
const positionScale = spatialBlockSize / 2.0 / quantizationRange;
|
|
2468
|
+
// Get bucket centers
|
|
2469
|
+
const bucketCentersOffset = currentSectionDataOffset + partialBucketMetaSize;
|
|
2470
|
+
const bucketCenters = new Float32Array(fileBuffer.buffer, fileBuffer.byteOffset + bucketCentersOffset, bucketCount * 3);
|
|
2471
|
+
// Get partial bucket sizes
|
|
2472
|
+
const partialBucketSizes = new Uint32Array(fileBuffer.buffer, fileBuffer.byteOffset + currentSectionDataOffset, partialBuckets);
|
|
2473
|
+
// Get splat data
|
|
2474
|
+
const splatDataOffset = currentSectionDataOffset + totalBucketStorageSize;
|
|
2475
|
+
const splatData = new DataView(fileBuffer.buffer, fileBuffer.byteOffset + splatDataOffset, sectionDataSize);
|
|
2476
|
+
// Harmonic value decoder
|
|
2477
|
+
const decodeHarmonics = (offset, component) => {
|
|
2478
|
+
switch (compressionMode) {
|
|
2479
|
+
case 0:
|
|
2480
|
+
return splatData.getFloat32(offset + harmonicsStartByte + component * 4, true);
|
|
2481
|
+
case 1:
|
|
2482
|
+
return decodeFloat16(splatData.getUint16(offset + harmonicsStartByte + component * 2, true));
|
|
2483
|
+
case 2: {
|
|
2484
|
+
const normalized = splatData.getUint8(offset + harmonicsStartByte + component) / 255;
|
|
2485
|
+
return minHarmonicsValue + normalized * (maxHarmonicsValue - minHarmonicsValue);
|
|
2486
|
+
}
|
|
2487
|
+
default:
|
|
2488
|
+
return 0;
|
|
2489
|
+
}
|
|
2490
|
+
};
|
|
2491
|
+
// Track partial bucket processing
|
|
2492
|
+
let currentPartialBucket = fullBuckets;
|
|
2493
|
+
let currentPartialBase = fullBucketSplats;
|
|
2494
|
+
// Process splats in this section
|
|
2495
|
+
for (let splatIdx = 0; splatIdx < sectionSplatCount; splatIdx++) {
|
|
2496
|
+
const splatByteOffset = splatIdx * bytesPerSplat;
|
|
2497
|
+
// Determine which bucket this splat belongs to
|
|
2498
|
+
let bucketIdx;
|
|
2499
|
+
if (splatIdx < fullBucketSplats) {
|
|
2500
|
+
bucketIdx = Math.floor(splatIdx / bucketCapacity);
|
|
2501
|
+
}
|
|
2502
|
+
else {
|
|
2503
|
+
const currentBucketSize = partialBucketSizes[currentPartialBucket - fullBuckets];
|
|
2504
|
+
if (splatIdx >= currentPartialBase + currentBucketSize) {
|
|
2505
|
+
currentPartialBucket++;
|
|
2506
|
+
currentPartialBase += currentBucketSize;
|
|
2507
|
+
}
|
|
2508
|
+
bucketIdx = currentPartialBucket;
|
|
2509
|
+
}
|
|
2510
|
+
// Decode position
|
|
2511
|
+
let x, y, z;
|
|
2512
|
+
if (compressionMode === 0) {
|
|
2513
|
+
x = splatData.getFloat32(splatByteOffset, true);
|
|
2514
|
+
y = splatData.getFloat32(splatByteOffset + 4, true);
|
|
2515
|
+
z = splatData.getFloat32(splatByteOffset + 8, true);
|
|
2516
|
+
}
|
|
2517
|
+
else {
|
|
2518
|
+
x = (splatData.getUint16(splatByteOffset, true) - quantizationRange) * positionScale + bucketCenters[bucketIdx * 3];
|
|
2519
|
+
y = (splatData.getUint16(splatByteOffset + 2, true) - quantizationRange) * positionScale + bucketCenters[bucketIdx * 3 + 1];
|
|
2520
|
+
z = (splatData.getUint16(splatByteOffset + 4, true) - quantizationRange) * positionScale + bucketCenters[bucketIdx * 3 + 2];
|
|
2521
|
+
}
|
|
2522
|
+
// Decode scales
|
|
2523
|
+
let scaleX, scaleY, scaleZ;
|
|
2524
|
+
if (compressionMode === 0) {
|
|
2525
|
+
scaleX = splatData.getFloat32(splatByteOffset + scaleStartByte, true);
|
|
2526
|
+
scaleY = splatData.getFloat32(splatByteOffset + scaleStartByte + 4, true);
|
|
2527
|
+
scaleZ = splatData.getFloat32(splatByteOffset + scaleStartByte + 8, true);
|
|
2528
|
+
}
|
|
2529
|
+
else {
|
|
2530
|
+
scaleX = decodeFloat16(splatData.getUint16(splatByteOffset + scaleStartByte, true));
|
|
2531
|
+
scaleY = decodeFloat16(splatData.getUint16(splatByteOffset + scaleStartByte + 2, true));
|
|
2532
|
+
scaleZ = decodeFloat16(splatData.getUint16(splatByteOffset + scaleStartByte + 4, true));
|
|
2533
|
+
}
|
|
2534
|
+
// Decode rotation quaternion
|
|
2535
|
+
let rot0, rot1, rot2, rot3;
|
|
2536
|
+
if (compressionMode === 0) {
|
|
2537
|
+
rot0 = splatData.getFloat32(splatByteOffset + rotationStartByte, true);
|
|
2538
|
+
rot1 = splatData.getFloat32(splatByteOffset + rotationStartByte + 4, true);
|
|
2539
|
+
rot2 = splatData.getFloat32(splatByteOffset + rotationStartByte + 8, true);
|
|
2540
|
+
rot3 = splatData.getFloat32(splatByteOffset + rotationStartByte + 12, true);
|
|
2541
|
+
}
|
|
2542
|
+
else {
|
|
2543
|
+
rot0 = decodeFloat16(splatData.getUint16(splatByteOffset + rotationStartByte, true));
|
|
2544
|
+
rot1 = decodeFloat16(splatData.getUint16(splatByteOffset + rotationStartByte + 2, true));
|
|
2545
|
+
rot2 = decodeFloat16(splatData.getUint16(splatByteOffset + rotationStartByte + 4, true));
|
|
2546
|
+
rot3 = decodeFloat16(splatData.getUint16(splatByteOffset + rotationStartByte + 6, true));
|
|
2547
|
+
}
|
|
2548
|
+
// Decode color and opacity
|
|
2549
|
+
const red = splatData.getUint8(splatByteOffset + colorStartByte);
|
|
2550
|
+
const green = splatData.getUint8(splatByteOffset + colorStartByte + 1);
|
|
2551
|
+
const blue = splatData.getUint8(splatByteOffset + colorStartByte + 2);
|
|
2552
|
+
const opacity = splatData.getUint8(splatByteOffset + colorStartByte + 3);
|
|
2553
|
+
// Store position
|
|
2554
|
+
columns[0].data[splatIndex] = x;
|
|
2555
|
+
columns[1].data[splatIndex] = y;
|
|
2556
|
+
columns[2].data[splatIndex] = z;
|
|
2557
|
+
// Store scale (convert from linear in .ksplat to log scale for internal use)
|
|
2558
|
+
columns[3].data[splatIndex] = scaleX > 0 ? Math.log(scaleX) : -10;
|
|
2559
|
+
columns[4].data[splatIndex] = scaleY > 0 ? Math.log(scaleY) : -10;
|
|
2560
|
+
columns[5].data[splatIndex] = scaleZ > 0 ? Math.log(scaleZ) : -10;
|
|
2561
|
+
// Store color (convert from uint8 back to spherical harmonics)
|
|
2562
|
+
const SH_C0 = 0.28209479177387814;
|
|
2563
|
+
columns[6].data[splatIndex] = (red / 255.0 - 0.5) / SH_C0;
|
|
2564
|
+
columns[7].data[splatIndex] = (green / 255.0 - 0.5) / SH_C0;
|
|
2565
|
+
columns[8].data[splatIndex] = (blue / 255.0 - 0.5) / SH_C0;
|
|
2566
|
+
// Store opacity (convert from uint8 to float and apply inverse sigmoid)
|
|
2567
|
+
const epsilon = 1e-6;
|
|
2568
|
+
const normalizedOpacity = Math.max(epsilon, Math.min(1.0 - epsilon, opacity / 255.0));
|
|
2569
|
+
columns[9].data[splatIndex] = Math.log(normalizedOpacity / (1.0 - normalizedOpacity));
|
|
2570
|
+
// Store quaternion
|
|
2571
|
+
columns[10].data[splatIndex] = rot0;
|
|
2572
|
+
columns[11].data[splatIndex] = rot1;
|
|
2573
|
+
columns[12].data[splatIndex] = rot2;
|
|
2574
|
+
columns[13].data[splatIndex] = rot3;
|
|
2575
|
+
// Store spherical harmonics
|
|
2576
|
+
for (let i = 0; i < harmonicsComponentCount; i++) {
|
|
2577
|
+
let channel;
|
|
2578
|
+
let coeff;
|
|
2579
|
+
// band 0 is packed together, then band 1, then band 2.
|
|
2580
|
+
if (i < 9) {
|
|
2581
|
+
channel = Math.floor(i / 3);
|
|
2582
|
+
coeff = i % 3;
|
|
2583
|
+
}
|
|
2584
|
+
else if (i < 24) {
|
|
2585
|
+
channel = Math.floor((i - 9) / 5);
|
|
2586
|
+
coeff = (i - 9) % 5 + 3;
|
|
2587
|
+
}
|
|
2588
|
+
else {
|
|
2589
|
+
// don't think 3 bands are supported, but here just in case
|
|
2590
|
+
channel = Math.floor((i - 24) / 7);
|
|
2591
|
+
coeff = (i - 24) % 7 + 8;
|
|
2592
|
+
}
|
|
2593
|
+
const col = channel * (harmonicsComponentCount / 3) + coeff;
|
|
2594
|
+
columns[14 + col].data[splatIndex] = decodeHarmonics(splatByteOffset, i);
|
|
2595
|
+
}
|
|
2596
|
+
splatIndex++;
|
|
2597
|
+
}
|
|
2598
|
+
currentSectionDataOffset += sectionDataSize + totalBucketStorageSize;
|
|
2599
|
+
}
|
|
2600
|
+
if (splatIndex !== numSplats) {
|
|
2601
|
+
throw new Error(`Splat count mismatch: expected ${numSplats}, processed ${splatIndex}`);
|
|
2602
|
+
}
|
|
2603
|
+
const resultTable = new DataTable(columns);
|
|
2604
|
+
return {
|
|
2605
|
+
comments: [],
|
|
2606
|
+
elements: [{
|
|
2607
|
+
name: 'vertex',
|
|
2608
|
+
dataTable: resultTable
|
|
2609
|
+
}]
|
|
2610
|
+
};
|
|
2611
|
+
};
|
|
2612
|
+
|
|
2310
2613
|
const getDataType = (type) => {
|
|
2311
2614
|
switch (type) {
|
|
2312
2615
|
case 'char': return Int8Array;
|
|
@@ -2421,393 +2724,102 @@ const readPly = async (fileHandle) => {
|
|
|
2421
2724
|
const chunkData = Buffer$1.alloc(chunkSize * rowSize);
|
|
2422
2725
|
for (let c = 0; c < numChunks; ++c) {
|
|
2423
2726
|
const numRows = Math.min(chunkSize, element.count - c * chunkSize);
|
|
2424
|
-
await fileHandle.read(chunkData, 0, rowSize * numRows);
|
|
2425
|
-
let offset = 0;
|
|
2426
|
-
// read data row at a time
|
|
2427
|
-
for (let r = 0; r < numRows; ++r) {
|
|
2428
|
-
const rowOffset = c * chunkSize + r;
|
|
2429
|
-
// copy into column data
|
|
2430
|
-
for (let p = 0; p < columns.length; ++p) {
|
|
2431
|
-
const s = sizes[p];
|
|
2432
|
-
chunkData.copy(buffers[p], rowOffset * s, offset, offset + s);
|
|
2433
|
-
offset += s;
|
|
2434
|
-
}
|
|
2435
|
-
}
|
|
2436
|
-
}
|
|
2437
|
-
elements.push({
|
|
2438
|
-
name: element.name,
|
|
2439
|
-
dataTable: new DataTable(columns)
|
|
2440
|
-
});
|
|
2441
|
-
}
|
|
2442
|
-
return {
|
|
2443
|
-
comments: header.comments,
|
|
2444
|
-
elements
|
|
2445
|
-
};
|
|
2446
|
-
};
|
|
2447
|
-
|
|
2448
|
-
const readSplat = async (fileHandle) => {
|
|
2449
|
-
// Get file size to determine number of splats
|
|
2450
|
-
const fileStats = await fileHandle.stat();
|
|
2451
|
-
const fileSize = fileStats.size;
|
|
2452
|
-
// Each splat is 32 bytes
|
|
2453
|
-
const BYTES_PER_SPLAT = 32;
|
|
2454
|
-
if (fileSize % BYTES_PER_SPLAT !== 0) {
|
|
2455
|
-
throw new Error('Invalid .splat file: file size is not a multiple of 32 bytes');
|
|
2456
|
-
}
|
|
2457
|
-
const numSplats = fileSize / BYTES_PER_SPLAT;
|
|
2458
|
-
if (numSplats === 0) {
|
|
2459
|
-
throw new Error('Invalid .splat file: file is empty');
|
|
2460
|
-
}
|
|
2461
|
-
// Create columns for the standard Gaussian splat data
|
|
2462
|
-
const columns = [
|
|
2463
|
-
// Position
|
|
2464
|
-
new Column('x', new Float32Array(numSplats)),
|
|
2465
|
-
new Column('y', new Float32Array(numSplats)),
|
|
2466
|
-
new Column('z', new Float32Array(numSplats)),
|
|
2467
|
-
// Scale (stored as linear in .splat, convert to log for internal use)
|
|
2468
|
-
new Column('scale_0', new Float32Array(numSplats)),
|
|
2469
|
-
new Column('scale_1', new Float32Array(numSplats)),
|
|
2470
|
-
new Column('scale_2', new Float32Array(numSplats)),
|
|
2471
|
-
// Color/opacity
|
|
2472
|
-
new Column('f_dc_0', new Float32Array(numSplats)), // Red
|
|
2473
|
-
new Column('f_dc_1', new Float32Array(numSplats)), // Green
|
|
2474
|
-
new Column('f_dc_2', new Float32Array(numSplats)), // Blue
|
|
2475
|
-
new Column('opacity', new Float32Array(numSplats)),
|
|
2476
|
-
// Rotation quaternion
|
|
2477
|
-
new Column('rot_0', new Float32Array(numSplats)),
|
|
2478
|
-
new Column('rot_1', new Float32Array(numSplats)),
|
|
2479
|
-
new Column('rot_2', new Float32Array(numSplats)),
|
|
2480
|
-
new Column('rot_3', new Float32Array(numSplats))
|
|
2481
|
-
];
|
|
2482
|
-
// Read data in chunks
|
|
2483
|
-
const chunkSize = 1024;
|
|
2484
|
-
const numChunks = Math.ceil(numSplats / chunkSize);
|
|
2485
|
-
const chunkData = Buffer$1.alloc(chunkSize * BYTES_PER_SPLAT);
|
|
2486
|
-
for (let c = 0; c < numChunks; ++c) {
|
|
2487
|
-
const numRows = Math.min(chunkSize, numSplats - c * chunkSize);
|
|
2488
|
-
const bytesToRead = numRows * BYTES_PER_SPLAT;
|
|
2489
|
-
const { bytesRead } = await fileHandle.read(chunkData, 0, bytesToRead);
|
|
2490
|
-
if (bytesRead !== bytesToRead) {
|
|
2491
|
-
throw new Error('Failed to read expected amount of data from .splat file');
|
|
2492
|
-
}
|
|
2493
|
-
// Parse each splat in the chunk
|
|
2494
|
-
for (let r = 0; r < numRows; ++r) {
|
|
2495
|
-
const splatIndex = c * chunkSize + r;
|
|
2496
|
-
const offset = r * BYTES_PER_SPLAT;
|
|
2497
|
-
// Read position (3 × float32)
|
|
2498
|
-
const x = chunkData.readFloatLE(offset + 0);
|
|
2499
|
-
const y = chunkData.readFloatLE(offset + 4);
|
|
2500
|
-
const z = chunkData.readFloatLE(offset + 8);
|
|
2501
|
-
// Read scale (3 × float32)
|
|
2502
|
-
const scaleX = chunkData.readFloatLE(offset + 12);
|
|
2503
|
-
const scaleY = chunkData.readFloatLE(offset + 16);
|
|
2504
|
-
const scaleZ = chunkData.readFloatLE(offset + 20);
|
|
2505
|
-
// Read color and opacity (4 × uint8)
|
|
2506
|
-
const red = chunkData.readUInt8(offset + 24);
|
|
2507
|
-
const green = chunkData.readUInt8(offset + 25);
|
|
2508
|
-
const blue = chunkData.readUInt8(offset + 26);
|
|
2509
|
-
const opacity = chunkData.readUInt8(offset + 27);
|
|
2510
|
-
// Read rotation quaternion (4 × uint8)
|
|
2511
|
-
const rot0 = chunkData.readUInt8(offset + 28);
|
|
2512
|
-
const rot1 = chunkData.readUInt8(offset + 29);
|
|
2513
|
-
const rot2 = chunkData.readUInt8(offset + 30);
|
|
2514
|
-
const rot3 = chunkData.readUInt8(offset + 31);
|
|
2515
|
-
// Store position
|
|
2516
|
-
columns[0].data[splatIndex] = x;
|
|
2517
|
-
columns[1].data[splatIndex] = y;
|
|
2518
|
-
columns[2].data[splatIndex] = z;
|
|
2519
|
-
// Store scale (convert from linear in .splat to log scale for internal use)
|
|
2520
|
-
columns[3].data[splatIndex] = Math.log(scaleX);
|
|
2521
|
-
columns[4].data[splatIndex] = Math.log(scaleY);
|
|
2522
|
-
columns[5].data[splatIndex] = Math.log(scaleZ);
|
|
2523
|
-
// Store color (convert from uint8 back to spherical harmonics)
|
|
2524
|
-
const SH_C0 = 0.28209479177387814;
|
|
2525
|
-
columns[6].data[splatIndex] = (red / 255.0 - 0.5) / SH_C0;
|
|
2526
|
-
columns[7].data[splatIndex] = (green / 255.0 - 0.5) / SH_C0;
|
|
2527
|
-
columns[8].data[splatIndex] = (blue / 255.0 - 0.5) / SH_C0;
|
|
2528
|
-
// Store opacity (convert from uint8 to float and apply inverse sigmoid)
|
|
2529
|
-
const epsilon = 1e-6;
|
|
2530
|
-
const normalizedOpacity = Math.max(epsilon, Math.min(1.0 - epsilon, opacity / 255.0));
|
|
2531
|
-
columns[9].data[splatIndex] = Math.log(normalizedOpacity / (1.0 - normalizedOpacity));
|
|
2532
|
-
// Store rotation quaternion (convert from uint8 [0,255] to float [-1,1] and normalize)
|
|
2533
|
-
const rot0Norm = (rot0 / 255.0) * 2.0 - 1.0;
|
|
2534
|
-
const rot1Norm = (rot1 / 255.0) * 2.0 - 1.0;
|
|
2535
|
-
const rot2Norm = (rot2 / 255.0) * 2.0 - 1.0;
|
|
2536
|
-
const rot3Norm = (rot3 / 255.0) * 2.0 - 1.0;
|
|
2537
|
-
// Normalize quaternion
|
|
2538
|
-
const length = Math.sqrt(rot0Norm * rot0Norm + rot1Norm * rot1Norm + rot2Norm * rot2Norm + rot3Norm * rot3Norm);
|
|
2539
|
-
if (length > 0) {
|
|
2540
|
-
columns[10].data[splatIndex] = rot0Norm / length;
|
|
2541
|
-
columns[11].data[splatIndex] = rot1Norm / length;
|
|
2542
|
-
columns[12].data[splatIndex] = rot2Norm / length;
|
|
2543
|
-
columns[13].data[splatIndex] = rot3Norm / length;
|
|
2544
|
-
}
|
|
2545
|
-
else {
|
|
2546
|
-
// Default to identity quaternion if invalid
|
|
2547
|
-
columns[10].data[splatIndex] = 0.0;
|
|
2548
|
-
columns[11].data[splatIndex] = 0.0;
|
|
2549
|
-
columns[12].data[splatIndex] = 0.0;
|
|
2550
|
-
columns[13].data[splatIndex] = 1.0;
|
|
2551
|
-
}
|
|
2552
|
-
}
|
|
2553
|
-
}
|
|
2554
|
-
return {
|
|
2555
|
-
comments: [],
|
|
2556
|
-
elements: [{
|
|
2557
|
-
name: 'vertex',
|
|
2558
|
-
dataTable: new DataTable(columns)
|
|
2559
|
-
}]
|
|
2560
|
-
};
|
|
2561
|
-
};
|
|
2562
|
-
|
|
2563
|
-
// Half-precision floating point decoder
|
|
2564
|
-
function decodeFloat16(encoded) {
|
|
2565
|
-
const signBit = (encoded >> 15) & 1;
|
|
2566
|
-
const exponent = (encoded >> 10) & 0x1f;
|
|
2567
|
-
const mantissa = encoded & 0x3ff;
|
|
2568
|
-
if (exponent === 0) {
|
|
2569
|
-
if (mantissa === 0) {
|
|
2570
|
-
return signBit ? -0 : 0.0;
|
|
2571
|
-
}
|
|
2572
|
-
// Denormalized number
|
|
2573
|
-
let m = mantissa;
|
|
2574
|
-
let exp = -14;
|
|
2575
|
-
while (!(m & 0x400)) {
|
|
2576
|
-
m <<= 1;
|
|
2577
|
-
exp--;
|
|
2578
|
-
}
|
|
2579
|
-
m &= 0x3ff;
|
|
2580
|
-
const finalExp = exp + 127;
|
|
2581
|
-
const finalMantissa = m << 13;
|
|
2582
|
-
const bits = (signBit << 31) | (finalExp << 23) | finalMantissa;
|
|
2583
|
-
return new Float32Array(new Uint32Array([bits]).buffer)[0];
|
|
2584
|
-
}
|
|
2585
|
-
if (exponent === 0x1f) {
|
|
2586
|
-
return mantissa === 0 ? (signBit ? -Infinity : Infinity) : NaN;
|
|
2587
|
-
}
|
|
2588
|
-
const finalExp = exponent - 15 + 127;
|
|
2589
|
-
const finalMantissa = mantissa << 13;
|
|
2590
|
-
const bits = (signBit << 31) | (finalExp << 23) | finalMantissa;
|
|
2591
|
-
return new Float32Array(new Uint32Array([bits]).buffer)[0];
|
|
2592
|
-
}
|
|
2593
|
-
const COMPRESSION_MODES = [
|
|
2594
|
-
{
|
|
2595
|
-
centerBytes: 12,
|
|
2596
|
-
scaleBytes: 12,
|
|
2597
|
-
rotationBytes: 16,
|
|
2598
|
-
colorBytes: 4,
|
|
2599
|
-
harmonicsBytes: 4,
|
|
2600
|
-
scaleStartByte: 12,
|
|
2601
|
-
rotationStartByte: 24,
|
|
2602
|
-
colorStartByte: 40,
|
|
2603
|
-
harmonicsStartByte: 44,
|
|
2604
|
-
scaleQuantRange: 1
|
|
2605
|
-
},
|
|
2606
|
-
{
|
|
2607
|
-
centerBytes: 6,
|
|
2608
|
-
scaleBytes: 6,
|
|
2609
|
-
rotationBytes: 8,
|
|
2610
|
-
colorBytes: 4,
|
|
2611
|
-
harmonicsBytes: 2,
|
|
2612
|
-
scaleStartByte: 6,
|
|
2613
|
-
rotationStartByte: 12,
|
|
2614
|
-
colorStartByte: 20,
|
|
2615
|
-
harmonicsStartByte: 24,
|
|
2616
|
-
scaleQuantRange: 32767
|
|
2617
|
-
},
|
|
2618
|
-
{
|
|
2619
|
-
centerBytes: 6,
|
|
2620
|
-
scaleBytes: 6,
|
|
2621
|
-
rotationBytes: 8,
|
|
2622
|
-
colorBytes: 4,
|
|
2623
|
-
harmonicsBytes: 1,
|
|
2624
|
-
scaleStartByte: 6,
|
|
2625
|
-
rotationStartByte: 12,
|
|
2626
|
-
colorStartByte: 20,
|
|
2627
|
-
harmonicsStartByte: 24,
|
|
2628
|
-
scaleQuantRange: 32767
|
|
2629
|
-
}
|
|
2630
|
-
];
|
|
2631
|
-
const HARMONICS_COMPONENT_COUNT = [0, 9, 24, 45];
|
|
2632
|
-
const readKsplat = async (fileHandle) => {
|
|
2633
|
-
const stats = await fileHandle.stat();
|
|
2634
|
-
const totalSize = stats.size;
|
|
2635
|
-
// Load complete file
|
|
2636
|
-
const fileBuffer = Buffer$1.alloc(totalSize);
|
|
2637
|
-
await fileHandle.read(fileBuffer, 0, totalSize, 0);
|
|
2638
|
-
const MAIN_HEADER_SIZE = 4096;
|
|
2639
|
-
const SECTION_HEADER_SIZE = 1024;
|
|
2640
|
-
if (totalSize < MAIN_HEADER_SIZE) {
|
|
2641
|
-
throw new Error('File too small to be valid .ksplat format');
|
|
2642
|
-
}
|
|
2643
|
-
// Parse main header
|
|
2644
|
-
const mainHeader = new DataView(fileBuffer.buffer, fileBuffer.byteOffset, MAIN_HEADER_SIZE);
|
|
2645
|
-
const majorVersion = mainHeader.getUint8(0);
|
|
2646
|
-
const minorVersion = mainHeader.getUint8(1);
|
|
2647
|
-
if (majorVersion !== 0 || minorVersion < 1) {
|
|
2648
|
-
throw new Error(`Unsupported version ${majorVersion}.${minorVersion}`);
|
|
2727
|
+
await fileHandle.read(chunkData, 0, rowSize * numRows);
|
|
2728
|
+
let offset = 0;
|
|
2729
|
+
// read data row at a time
|
|
2730
|
+
for (let r = 0; r < numRows; ++r) {
|
|
2731
|
+
const rowOffset = c * chunkSize + r;
|
|
2732
|
+
// copy into column data
|
|
2733
|
+
for (let p = 0; p < columns.length; ++p) {
|
|
2734
|
+
const s = sizes[p];
|
|
2735
|
+
chunkData.copy(buffers[p], rowOffset * s, offset, offset + s);
|
|
2736
|
+
offset += s;
|
|
2737
|
+
}
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2740
|
+
elements.push({
|
|
2741
|
+
name: element.name,
|
|
2742
|
+
dataTable: new DataTable(columns)
|
|
2743
|
+
});
|
|
2649
2744
|
}
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2745
|
+
return {
|
|
2746
|
+
comments: header.comments,
|
|
2747
|
+
elements
|
|
2748
|
+
};
|
|
2749
|
+
};
|
|
2750
|
+
|
|
2751
|
+
const readSplat = async (fileHandle) => {
|
|
2752
|
+
// Get file size to determine number of splats
|
|
2753
|
+
const fileStats = await fileHandle.stat();
|
|
2754
|
+
const fileSize = fileStats.size;
|
|
2755
|
+
// Each splat is 32 bytes
|
|
2756
|
+
const BYTES_PER_SPLAT = 32;
|
|
2757
|
+
if (fileSize % BYTES_PER_SPLAT !== 0) {
|
|
2758
|
+
throw new Error('Invalid .splat file: file size is not a multiple of 32 bytes');
|
|
2655
2759
|
}
|
|
2656
|
-
const
|
|
2657
|
-
const maxHarmonicsValue = mainHeader.getFloat32(40, true) ?? 1.5;
|
|
2760
|
+
const numSplats = fileSize / BYTES_PER_SPLAT;
|
|
2658
2761
|
if (numSplats === 0) {
|
|
2659
|
-
throw new Error('Invalid .
|
|
2660
|
-
}
|
|
2661
|
-
// First pass: scan all sections to find maximum harmonics degree
|
|
2662
|
-
let maxHarmonicsDegree = 0;
|
|
2663
|
-
for (let sectionIdx = 0; sectionIdx < maxSections; sectionIdx++) {
|
|
2664
|
-
const sectionHeaderOffset = MAIN_HEADER_SIZE + sectionIdx * SECTION_HEADER_SIZE;
|
|
2665
|
-
const sectionHeader = new DataView(fileBuffer.buffer, fileBuffer.byteOffset + sectionHeaderOffset, SECTION_HEADER_SIZE);
|
|
2666
|
-
const sectionSplatCount = sectionHeader.getUint32(0, true);
|
|
2667
|
-
if (sectionSplatCount === 0)
|
|
2668
|
-
continue; // Skip empty sections
|
|
2669
|
-
const harmonicsDegree = sectionHeader.getUint16(40, true);
|
|
2670
|
-
maxHarmonicsDegree = Math.max(maxHarmonicsDegree, harmonicsDegree);
|
|
2762
|
+
throw new Error('Invalid .splat file: file is empty');
|
|
2671
2763
|
}
|
|
2672
|
-
//
|
|
2764
|
+
// Create columns for the standard Gaussian splat data
|
|
2673
2765
|
const columns = [
|
|
2766
|
+
// Position
|
|
2674
2767
|
new Column('x', new Float32Array(numSplats)),
|
|
2675
2768
|
new Column('y', new Float32Array(numSplats)),
|
|
2676
2769
|
new Column('z', new Float32Array(numSplats)),
|
|
2770
|
+
// Scale (stored as linear in .splat, convert to log for internal use)
|
|
2677
2771
|
new Column('scale_0', new Float32Array(numSplats)),
|
|
2678
2772
|
new Column('scale_1', new Float32Array(numSplats)),
|
|
2679
2773
|
new Column('scale_2', new Float32Array(numSplats)),
|
|
2680
|
-
|
|
2681
|
-
new Column('
|
|
2682
|
-
new Column('
|
|
2774
|
+
// Color/opacity
|
|
2775
|
+
new Column('f_dc_0', new Float32Array(numSplats)), // Red
|
|
2776
|
+
new Column('f_dc_1', new Float32Array(numSplats)), // Green
|
|
2777
|
+
new Column('f_dc_2', new Float32Array(numSplats)), // Blue
|
|
2683
2778
|
new Column('opacity', new Float32Array(numSplats)),
|
|
2779
|
+
// Rotation quaternion
|
|
2684
2780
|
new Column('rot_0', new Float32Array(numSplats)),
|
|
2685
2781
|
new Column('rot_1', new Float32Array(numSplats)),
|
|
2686
2782
|
new Column('rot_2', new Float32Array(numSplats)),
|
|
2687
2783
|
new Column('rot_3', new Float32Array(numSplats))
|
|
2688
2784
|
];
|
|
2689
|
-
//
|
|
2690
|
-
const
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
const bucketCentersOffset = currentSectionDataOffset + partialBucketMetaSize;
|
|
2723
|
-
const bucketCenters = new Float32Array(fileBuffer.buffer, fileBuffer.byteOffset + bucketCentersOffset, bucketCount * 3);
|
|
2724
|
-
// Get partial bucket sizes
|
|
2725
|
-
const partialBucketSizes = new Uint32Array(fileBuffer.buffer, fileBuffer.byteOffset + currentSectionDataOffset, partialBuckets);
|
|
2726
|
-
// Get splat data
|
|
2727
|
-
const splatDataOffset = currentSectionDataOffset + totalBucketStorageSize;
|
|
2728
|
-
const splatData = new DataView(fileBuffer.buffer, fileBuffer.byteOffset + splatDataOffset, sectionDataSize);
|
|
2729
|
-
// Harmonic value decoder
|
|
2730
|
-
const decodeHarmonics = (offset, component) => {
|
|
2731
|
-
switch (compressionMode) {
|
|
2732
|
-
case 0:
|
|
2733
|
-
return splatData.getFloat32(offset + harmonicsStartByte + component * 4, true);
|
|
2734
|
-
case 1:
|
|
2735
|
-
return decodeFloat16(splatData.getUint16(offset + harmonicsStartByte + component * 2, true));
|
|
2736
|
-
case 2: {
|
|
2737
|
-
const normalized = splatData.getUint8(offset + harmonicsStartByte + component) / 255;
|
|
2738
|
-
return minHarmonicsValue + normalized * (maxHarmonicsValue - minHarmonicsValue);
|
|
2739
|
-
}
|
|
2740
|
-
default:
|
|
2741
|
-
return 0;
|
|
2742
|
-
}
|
|
2743
|
-
};
|
|
2744
|
-
// Track partial bucket processing
|
|
2745
|
-
let currentPartialBucket = fullBuckets;
|
|
2746
|
-
let currentPartialBase = fullBucketSplats;
|
|
2747
|
-
// Process splats in this section
|
|
2748
|
-
for (let splatIdx = 0; splatIdx < sectionSplatCount; splatIdx++) {
|
|
2749
|
-
const splatByteOffset = splatIdx * bytesPerSplat;
|
|
2750
|
-
// Determine which bucket this splat belongs to
|
|
2751
|
-
let bucketIdx;
|
|
2752
|
-
if (splatIdx < fullBucketSplats) {
|
|
2753
|
-
bucketIdx = Math.floor(splatIdx / bucketCapacity);
|
|
2754
|
-
}
|
|
2755
|
-
else {
|
|
2756
|
-
const currentBucketSize = partialBucketSizes[currentPartialBucket - fullBuckets];
|
|
2757
|
-
if (splatIdx >= currentPartialBase + currentBucketSize) {
|
|
2758
|
-
currentPartialBucket++;
|
|
2759
|
-
currentPartialBase += currentBucketSize;
|
|
2760
|
-
}
|
|
2761
|
-
bucketIdx = currentPartialBucket;
|
|
2762
|
-
}
|
|
2763
|
-
// Decode position
|
|
2764
|
-
let x, y, z;
|
|
2765
|
-
if (compressionMode === 0) {
|
|
2766
|
-
x = splatData.getFloat32(splatByteOffset, true);
|
|
2767
|
-
y = splatData.getFloat32(splatByteOffset + 4, true);
|
|
2768
|
-
z = splatData.getFloat32(splatByteOffset + 8, true);
|
|
2769
|
-
}
|
|
2770
|
-
else {
|
|
2771
|
-
x = (splatData.getUint16(splatByteOffset, true) - quantizationRange) * positionScale + bucketCenters[bucketIdx * 3];
|
|
2772
|
-
y = (splatData.getUint16(splatByteOffset + 2, true) - quantizationRange) * positionScale + bucketCenters[bucketIdx * 3 + 1];
|
|
2773
|
-
z = (splatData.getUint16(splatByteOffset + 4, true) - quantizationRange) * positionScale + bucketCenters[bucketIdx * 3 + 2];
|
|
2774
|
-
}
|
|
2775
|
-
// Decode scales
|
|
2776
|
-
let scaleX, scaleY, scaleZ;
|
|
2777
|
-
if (compressionMode === 0) {
|
|
2778
|
-
scaleX = splatData.getFloat32(splatByteOffset + scaleStartByte, true);
|
|
2779
|
-
scaleY = splatData.getFloat32(splatByteOffset + scaleStartByte + 4, true);
|
|
2780
|
-
scaleZ = splatData.getFloat32(splatByteOffset + scaleStartByte + 8, true);
|
|
2781
|
-
}
|
|
2782
|
-
else {
|
|
2783
|
-
scaleX = decodeFloat16(splatData.getUint16(splatByteOffset + scaleStartByte, true));
|
|
2784
|
-
scaleY = decodeFloat16(splatData.getUint16(splatByteOffset + scaleStartByte + 2, true));
|
|
2785
|
-
scaleZ = decodeFloat16(splatData.getUint16(splatByteOffset + scaleStartByte + 4, true));
|
|
2786
|
-
}
|
|
2787
|
-
// Decode rotation quaternion
|
|
2788
|
-
let rot0, rot1, rot2, rot3;
|
|
2789
|
-
if (compressionMode === 0) {
|
|
2790
|
-
rot0 = splatData.getFloat32(splatByteOffset + rotationStartByte, true);
|
|
2791
|
-
rot1 = splatData.getFloat32(splatByteOffset + rotationStartByte + 4, true);
|
|
2792
|
-
rot2 = splatData.getFloat32(splatByteOffset + rotationStartByte + 8, true);
|
|
2793
|
-
rot3 = splatData.getFloat32(splatByteOffset + rotationStartByte + 12, true);
|
|
2794
|
-
}
|
|
2795
|
-
else {
|
|
2796
|
-
rot0 = decodeFloat16(splatData.getUint16(splatByteOffset + rotationStartByte, true));
|
|
2797
|
-
rot1 = decodeFloat16(splatData.getUint16(splatByteOffset + rotationStartByte + 2, true));
|
|
2798
|
-
rot2 = decodeFloat16(splatData.getUint16(splatByteOffset + rotationStartByte + 4, true));
|
|
2799
|
-
rot3 = decodeFloat16(splatData.getUint16(splatByteOffset + rotationStartByte + 6, true));
|
|
2800
|
-
}
|
|
2801
|
-
// Decode color and opacity
|
|
2802
|
-
const red = splatData.getUint8(splatByteOffset + colorStartByte);
|
|
2803
|
-
const green = splatData.getUint8(splatByteOffset + colorStartByte + 1);
|
|
2804
|
-
const blue = splatData.getUint8(splatByteOffset + colorStartByte + 2);
|
|
2805
|
-
const opacity = splatData.getUint8(splatByteOffset + colorStartByte + 3);
|
|
2785
|
+
// Read data in chunks
|
|
2786
|
+
const chunkSize = 1024;
|
|
2787
|
+
const numChunks = Math.ceil(numSplats / chunkSize);
|
|
2788
|
+
const chunkData = Buffer$1.alloc(chunkSize * BYTES_PER_SPLAT);
|
|
2789
|
+
for (let c = 0; c < numChunks; ++c) {
|
|
2790
|
+
const numRows = Math.min(chunkSize, numSplats - c * chunkSize);
|
|
2791
|
+
const bytesToRead = numRows * BYTES_PER_SPLAT;
|
|
2792
|
+
const { bytesRead } = await fileHandle.read(chunkData, 0, bytesToRead);
|
|
2793
|
+
if (bytesRead !== bytesToRead) {
|
|
2794
|
+
throw new Error('Failed to read expected amount of data from .splat file');
|
|
2795
|
+
}
|
|
2796
|
+
// Parse each splat in the chunk
|
|
2797
|
+
for (let r = 0; r < numRows; ++r) {
|
|
2798
|
+
const splatIndex = c * chunkSize + r;
|
|
2799
|
+
const offset = r * BYTES_PER_SPLAT;
|
|
2800
|
+
// Read position (3 × float32)
|
|
2801
|
+
const x = chunkData.readFloatLE(offset + 0);
|
|
2802
|
+
const y = chunkData.readFloatLE(offset + 4);
|
|
2803
|
+
const z = chunkData.readFloatLE(offset + 8);
|
|
2804
|
+
// Read scale (3 × float32)
|
|
2805
|
+
const scaleX = chunkData.readFloatLE(offset + 12);
|
|
2806
|
+
const scaleY = chunkData.readFloatLE(offset + 16);
|
|
2807
|
+
const scaleZ = chunkData.readFloatLE(offset + 20);
|
|
2808
|
+
// Read color and opacity (4 × uint8)
|
|
2809
|
+
const red = chunkData.readUInt8(offset + 24);
|
|
2810
|
+
const green = chunkData.readUInt8(offset + 25);
|
|
2811
|
+
const blue = chunkData.readUInt8(offset + 26);
|
|
2812
|
+
const opacity = chunkData.readUInt8(offset + 27);
|
|
2813
|
+
// Read rotation quaternion (4 × uint8)
|
|
2814
|
+
const rot0 = chunkData.readUInt8(offset + 28);
|
|
2815
|
+
const rot1 = chunkData.readUInt8(offset + 29);
|
|
2816
|
+
const rot2 = chunkData.readUInt8(offset + 30);
|
|
2817
|
+
const rot3 = chunkData.readUInt8(offset + 31);
|
|
2806
2818
|
// Store position
|
|
2807
2819
|
columns[0].data[splatIndex] = x;
|
|
2808
2820
|
columns[1].data[splatIndex] = y;
|
|
2809
2821
|
columns[2].data[splatIndex] = z;
|
|
2810
|
-
// Store scale (convert from linear in .
|
|
2822
|
+
// Store scale (convert from linear in .splat to log scale for internal use)
|
|
2811
2823
|
columns[3].data[splatIndex] = Math.log(scaleX);
|
|
2812
2824
|
columns[4].data[splatIndex] = Math.log(scaleY);
|
|
2813
2825
|
columns[5].data[splatIndex] = Math.log(scaleZ);
|
|
@@ -2820,28 +2832,33 @@ const readKsplat = async (fileHandle) => {
|
|
|
2820
2832
|
const epsilon = 1e-6;
|
|
2821
2833
|
const normalizedOpacity = Math.max(epsilon, Math.min(1.0 - epsilon, opacity / 255.0));
|
|
2822
2834
|
columns[9].data[splatIndex] = Math.log(normalizedOpacity / (1.0 - normalizedOpacity));
|
|
2823
|
-
// Store quaternion
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
//
|
|
2829
|
-
|
|
2830
|
-
|
|
2835
|
+
// Store rotation quaternion (convert from uint8 [0,255] to float [-1,1] and normalize)
|
|
2836
|
+
const rot0Norm = (rot0 / 255.0) * 2.0 - 1.0;
|
|
2837
|
+
const rot1Norm = (rot1 / 255.0) * 2.0 - 1.0;
|
|
2838
|
+
const rot2Norm = (rot2 / 255.0) * 2.0 - 1.0;
|
|
2839
|
+
const rot3Norm = (rot3 / 255.0) * 2.0 - 1.0;
|
|
2840
|
+
// Normalize quaternion
|
|
2841
|
+
const length = Math.sqrt(rot0Norm * rot0Norm + rot1Norm * rot1Norm + rot2Norm * rot2Norm + rot3Norm * rot3Norm);
|
|
2842
|
+
if (length > 0) {
|
|
2843
|
+
columns[10].data[splatIndex] = rot0Norm / length;
|
|
2844
|
+
columns[11].data[splatIndex] = rot1Norm / length;
|
|
2845
|
+
columns[12].data[splatIndex] = rot2Norm / length;
|
|
2846
|
+
columns[13].data[splatIndex] = rot3Norm / length;
|
|
2847
|
+
}
|
|
2848
|
+
else {
|
|
2849
|
+
// Default to identity quaternion if invalid
|
|
2850
|
+
columns[10].data[splatIndex] = 0.0;
|
|
2851
|
+
columns[11].data[splatIndex] = 0.0;
|
|
2852
|
+
columns[12].data[splatIndex] = 0.0;
|
|
2853
|
+
columns[13].data[splatIndex] = 1.0;
|
|
2831
2854
|
}
|
|
2832
|
-
splatIndex++;
|
|
2833
2855
|
}
|
|
2834
|
-
currentSectionDataOffset += sectionDataSize + totalBucketStorageSize;
|
|
2835
|
-
}
|
|
2836
|
-
if (splatIndex !== numSplats) {
|
|
2837
|
-
throw new Error(`Splat count mismatch: expected ${numSplats}, processed ${splatIndex}`);
|
|
2838
2856
|
}
|
|
2839
|
-
const resultTable = new DataTable(columns);
|
|
2840
2857
|
return {
|
|
2841
2858
|
comments: [],
|
|
2842
2859
|
elements: [{
|
|
2843
2860
|
name: 'vertex',
|
|
2844
|
-
dataTable:
|
|
2861
|
+
dataTable: new DataTable(columns)
|
|
2845
2862
|
}]
|
|
2846
2863
|
};
|
|
2847
2864
|
};
|
|
@@ -3663,44 +3680,44 @@ const parseArguments = () => {
|
|
|
3663
3680
|
}
|
|
3664
3681
|
return { files, options };
|
|
3665
3682
|
};
|
|
3666
|
-
const usage = `
|
|
3667
|
-
Apply geometric transforms & filters to Gaussian-splat point clouds
|
|
3668
|
-
===================================================================
|
|
3669
|
-
|
|
3670
|
-
USAGE
|
|
3671
|
-
splat-transform [GLOBAL] <input.{ply|splat|ksplat}> [ACTIONS] ... <output.{ply|compressed.ply|meta.json|csv}> [ACTIONS]
|
|
3672
|
-
|
|
3673
|
-
• Every time an input file appears, it becomes the current working set; the following
|
|
3674
|
-
ACTIONS are applied in the order listed.
|
|
3675
|
-
• The last file on the command line is treated as the output; anything after it is
|
|
3676
|
-
interpreted as actions that modify the final result.
|
|
3677
|
-
|
|
3678
|
-
SUPPORTED INPUTS
|
|
3679
|
-
.ply .splat .ksplat
|
|
3680
|
-
|
|
3681
|
-
SUPPORTED OUTPUTS
|
|
3682
|
-
.ply .compressed.ply meta.json (SOGS) .csv
|
|
3683
|
-
|
|
3684
|
-
ACTIONS (can be repeated, in any order)
|
|
3685
|
-
-t, --translate x,y,z Translate splats by (x, y, z)
|
|
3686
|
-
-r, --rotate x,y,z Rotate splats by Euler angles (deg)
|
|
3687
|
-
-s, --scale x Uniformly scale splats by factor x
|
|
3688
|
-
-n, --filterNaN Remove any Gaussian containing NaN/Inf
|
|
3689
|
-
-c, --filterByValue name,cmp,value Keep splats where <name> <cmp> <value>
|
|
3690
|
-
cmp ∈ {lt,lte,gt,gte,eq,neq}
|
|
3691
|
-
-
|
|
3692
|
-
|
|
3693
|
-
GLOBAL OPTIONS
|
|
3694
|
-
-w, --overwrite Overwrite output file if it already exists
|
|
3695
|
-
-h, --help Show this help and exit
|
|
3696
|
-
-v, --version Show version and exit
|
|
3697
|
-
|
|
3698
|
-
EXAMPLES
|
|
3699
|
-
# Simple scale-then-translate
|
|
3700
|
-
splat-transform bunny.ply -s 0.5 -t 0,0,10 bunny_scaled.ply
|
|
3701
|
-
|
|
3702
|
-
# Chain two inputs and write compressed output, overwriting if necessary
|
|
3703
|
-
splat-transform -w cloudA.ply -r 0,90,0 cloudB.ply -s 2 merged.compressed.ply
|
|
3683
|
+
const usage = `
|
|
3684
|
+
Apply geometric transforms & filters to Gaussian-splat point clouds
|
|
3685
|
+
===================================================================
|
|
3686
|
+
|
|
3687
|
+
USAGE
|
|
3688
|
+
splat-transform [GLOBAL] <input.{ply|splat|ksplat}> [ACTIONS] ... <output.{ply|compressed.ply|meta.json|csv}> [ACTIONS]
|
|
3689
|
+
|
|
3690
|
+
• Every time an input file appears, it becomes the current working set; the following
|
|
3691
|
+
ACTIONS are applied in the order listed.
|
|
3692
|
+
• The last file on the command line is treated as the output; anything after it is
|
|
3693
|
+
interpreted as actions that modify the final result.
|
|
3694
|
+
|
|
3695
|
+
SUPPORTED INPUTS
|
|
3696
|
+
.ply .splat .ksplat
|
|
3697
|
+
|
|
3698
|
+
SUPPORTED OUTPUTS
|
|
3699
|
+
.ply .compressed.ply meta.json (SOGS) .csv
|
|
3700
|
+
|
|
3701
|
+
ACTIONS (can be repeated, in any order)
|
|
3702
|
+
-t, --translate x,y,z Translate splats by (x, y, z)
|
|
3703
|
+
-r, --rotate x,y,z Rotate splats by Euler angles (deg)
|
|
3704
|
+
-s, --scale x Uniformly scale splats by factor x
|
|
3705
|
+
-n, --filterNaN Remove any Gaussian containing NaN/Inf
|
|
3706
|
+
-c, --filterByValue name,cmp,value Keep splats where <name> <cmp> <value>
|
|
3707
|
+
cmp ∈ {lt,lte,gt,gte,eq,neq}
|
|
3708
|
+
-b, --filterBands {0|1|2|3} Strip spherical-harmonic bands > N
|
|
3709
|
+
|
|
3710
|
+
GLOBAL OPTIONS
|
|
3711
|
+
-w, --overwrite Overwrite output file if it already exists
|
|
3712
|
+
-h, --help Show this help and exit
|
|
3713
|
+
-v, --version Show version and exit
|
|
3714
|
+
|
|
3715
|
+
EXAMPLES
|
|
3716
|
+
# Simple scale-then-translate
|
|
3717
|
+
splat-transform bunny.ply -s 0.5 -t 0,0,10 bunny_scaled.ply
|
|
3718
|
+
|
|
3719
|
+
# Chain two inputs and write compressed output, overwriting if necessary
|
|
3720
|
+
splat-transform -w cloudA.ply -r 0,90,0 cloudB.ply -s 2 merged.compressed.ply
|
|
3704
3721
|
`;
|
|
3705
3722
|
const main = async () => {
|
|
3706
3723
|
console.log(`splat-transform v${version}`);
|