@playcanvas/splat-transform 0.2.1 → 0.4.0

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/dist/index.mjs CHANGED
@@ -1881,7 +1881,7 @@ class Quat {
1881
1881
  }
1882
1882
  }
1883
1883
 
1884
- var version = "0.2.1";
1884
+ var version = "0.3.0";
1885
1885
 
1886
1886
  class Column {
1887
1887
  name;
@@ -2445,6 +2445,407 @@ const readPly = async (fileHandle) => {
2445
2445
  };
2446
2446
  };
2447
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}`);
2649
+ }
2650
+ const maxSections = mainHeader.getUint32(4, true);
2651
+ const numSplats = mainHeader.getUint32(16, true);
2652
+ const compressionMode = mainHeader.getUint16(20, true);
2653
+ if (compressionMode > 2) {
2654
+ throw new Error(`Invalid compression mode: ${compressionMode}`);
2655
+ }
2656
+ const minHarmonicsValue = mainHeader.getFloat32(36, true) ?? -1.5;
2657
+ const maxHarmonicsValue = mainHeader.getFloat32(40, true) ?? 1.5;
2658
+ if (numSplats === 0) {
2659
+ throw new Error('Invalid .ksplat file: file is empty');
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);
2671
+ }
2672
+ // Initialize data storage with base columns
2673
+ const columns = [
2674
+ new Column('x', new Float32Array(numSplats)),
2675
+ new Column('y', new Float32Array(numSplats)),
2676
+ new Column('z', new Float32Array(numSplats)),
2677
+ new Column('scale_0', new Float32Array(numSplats)),
2678
+ new Column('scale_1', new Float32Array(numSplats)),
2679
+ new Column('scale_2', new Float32Array(numSplats)),
2680
+ new Column('f_dc_0', new Float32Array(numSplats)),
2681
+ new Column('f_dc_1', new Float32Array(numSplats)),
2682
+ new Column('f_dc_2', new Float32Array(numSplats)),
2683
+ new Column('opacity', new Float32Array(numSplats)),
2684
+ new Column('rot_0', new Float32Array(numSplats)),
2685
+ new Column('rot_1', new Float32Array(numSplats)),
2686
+ new Column('rot_2', new Float32Array(numSplats)),
2687
+ new Column('rot_3', new Float32Array(numSplats))
2688
+ ];
2689
+ // Add spherical harmonics columns based on maximum degree found
2690
+ const maxHarmonicsComponentCount = HARMONICS_COMPONENT_COUNT[maxHarmonicsDegree];
2691
+ for (let i = 0; i < maxHarmonicsComponentCount; i++) {
2692
+ columns.push(new Column(`f_rest_${i}`, new Float32Array(numSplats)));
2693
+ }
2694
+ const { centerBytes, scaleBytes, rotationBytes, colorBytes, harmonicsBytes, scaleStartByte, rotationStartByte, colorStartByte, harmonicsStartByte, scaleQuantRange } = COMPRESSION_MODES[compressionMode];
2695
+ let currentSectionDataOffset = MAIN_HEADER_SIZE + maxSections * SECTION_HEADER_SIZE;
2696
+ let splatIndex = 0;
2697
+ // Process each section
2698
+ for (let sectionIdx = 0; sectionIdx < maxSections; sectionIdx++) {
2699
+ const sectionHeaderOffset = MAIN_HEADER_SIZE + sectionIdx * SECTION_HEADER_SIZE;
2700
+ const sectionHeader = new DataView(fileBuffer.buffer, fileBuffer.byteOffset + sectionHeaderOffset, SECTION_HEADER_SIZE);
2701
+ const sectionSplatCount = sectionHeader.getUint32(0, true);
2702
+ const maxSectionSplats = sectionHeader.getUint32(4, true);
2703
+ const bucketCapacity = sectionHeader.getUint32(8, true);
2704
+ const bucketCount = sectionHeader.getUint32(12, true);
2705
+ const spatialBlockSize = sectionHeader.getFloat32(16, true);
2706
+ const bucketStorageSize = sectionHeader.getUint16(20, true);
2707
+ const quantizationRange = sectionHeader.getUint32(24, true) || scaleQuantRange;
2708
+ const fullBuckets = sectionHeader.getUint32(32, true);
2709
+ const partialBuckets = sectionHeader.getUint32(36, true);
2710
+ const harmonicsDegree = sectionHeader.getUint16(40, true);
2711
+ // Calculate layout
2712
+ const fullBucketSplats = fullBuckets * bucketCapacity;
2713
+ const partialBucketMetaSize = partialBuckets * 4;
2714
+ const totalBucketStorageSize = bucketStorageSize * bucketCount + partialBucketMetaSize;
2715
+ const harmonicsComponentCount = HARMONICS_COMPONENT_COUNT[harmonicsDegree];
2716
+ const bytesPerSplat = centerBytes + scaleBytes + rotationBytes +
2717
+ colorBytes + harmonicsComponentCount * harmonicsBytes;
2718
+ const sectionDataSize = bytesPerSplat * maxSectionSplats;
2719
+ // Calculate decompression parameters
2720
+ const positionScale = spatialBlockSize / 2.0 / quantizationRange;
2721
+ // Get bucket centers
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);
2806
+ // Store position
2807
+ columns[0].data[splatIndex] = x;
2808
+ columns[1].data[splatIndex] = y;
2809
+ columns[2].data[splatIndex] = z;
2810
+ // Store scale (convert from linear in .ksplat to log scale for internal use)
2811
+ columns[3].data[splatIndex] = Math.log(scaleX);
2812
+ columns[4].data[splatIndex] = Math.log(scaleY);
2813
+ columns[5].data[splatIndex] = Math.log(scaleZ);
2814
+ // Store color (convert from uint8 back to spherical harmonics)
2815
+ const SH_C0 = 0.28209479177387814;
2816
+ columns[6].data[splatIndex] = (red / 255.0 - 0.5) / SH_C0;
2817
+ columns[7].data[splatIndex] = (green / 255.0 - 0.5) / SH_C0;
2818
+ columns[8].data[splatIndex] = (blue / 255.0 - 0.5) / SH_C0;
2819
+ // Store opacity (convert from uint8 to float and apply inverse sigmoid)
2820
+ const epsilon = 1e-6;
2821
+ const normalizedOpacity = Math.max(epsilon, Math.min(1.0 - epsilon, opacity / 255.0));
2822
+ columns[9].data[splatIndex] = Math.log(normalizedOpacity / (1.0 - normalizedOpacity));
2823
+ // Store quaternion
2824
+ columns[10].data[splatIndex] = rot0;
2825
+ columns[11].data[splatIndex] = rot1;
2826
+ columns[12].data[splatIndex] = rot2;
2827
+ columns[13].data[splatIndex] = rot3;
2828
+ // Store spherical harmonics
2829
+ for (let i = 0; i < harmonicsComponentCount; i++) {
2830
+ columns[14 + i].data[splatIndex] = decodeHarmonics(splatByteOffset, i);
2831
+ }
2832
+ splatIndex++;
2833
+ }
2834
+ currentSectionDataOffset += sectionDataSize + totalBucketStorageSize;
2835
+ }
2836
+ if (splatIndex !== numSplats) {
2837
+ throw new Error(`Splat count mismatch: expected ${numSplats}, processed ${splatIndex}`);
2838
+ }
2839
+ const resultTable = new DataTable(columns);
2840
+ return {
2841
+ comments: [],
2842
+ elements: [{
2843
+ name: 'vertex',
2844
+ dataTable: resultTable
2845
+ }]
2846
+ };
2847
+ };
2848
+
2448
2849
  const sigmoid = (v) => 1 / (1 + Math.exp(-v));
2449
2850
 
2450
2851
  const q = new Quat();
@@ -2770,6 +3171,23 @@ const writeCompressedPly = async (fileHandle, dataTable) => {
2770
3171
  await fileHandle.write(shData);
2771
3172
  };
2772
3173
 
3174
+ const writeCsv = async (fileHandle, dataTable) => {
3175
+ const len = dataTable.numRows;
3176
+ // write header
3177
+ await fileHandle.write(`${dataTable.columnNames.join(',')}\n`);
3178
+ const columns = dataTable.columns.map(c => c.data);
3179
+ // write rows
3180
+ for (let i = 0; i < len; ++i) {
3181
+ let row = '';
3182
+ for (let c = 0; c < dataTable.columns.length; ++c) {
3183
+ if (c)
3184
+ row += ',';
3185
+ row += columns[c][i];
3186
+ }
3187
+ await fileHandle.write(`${row}\n`);
3188
+ }
3189
+ };
3190
+
2773
3191
  const columnTypeToPlyType = (type) => {
2774
3192
  switch (type) {
2775
3193
  case 'float32': return 'float';
@@ -2846,18 +3264,11 @@ const calcMinMax = (dataTable, columnNames) => {
2846
3264
  const logTransform = (value) => {
2847
3265
  return Math.sign(value) * Math.log(Math.abs(value) + 1);
2848
3266
  };
2849
- // calculate the output index of the gaussian given its index. chunks of
2850
- // 256 gaussians are packed into 16x16 tiles on the output texture.
2851
- const target = (index, width) => {
2852
- const chunkWidth = width / 16;
2853
- const chunkIndex = Math.floor(index / 256);
2854
- const chunkX = chunkIndex % chunkWidth;
2855
- const chunkY = Math.floor(chunkIndex / chunkWidth);
2856
- const x = chunkX * 16 + (index % 16);
2857
- const y = chunkY * 16 + Math.floor((index % 256) / 16);
2858
- return x + y * width;
3267
+ // no packing
3268
+ const identity = (index, width) => {
3269
+ return index;
2859
3270
  };
2860
- const writeSogs = async (outputFilename, dataTable) => {
3271
+ const writeSogs = async (fileHandle, dataTable, outputFilename) => {
2861
3272
  // generate an optimal ordering
2862
3273
  const sortIndices = generateOrdering(dataTable);
2863
3274
  const numRows = dataTable.numRows;
@@ -2871,6 +3282,8 @@ const writeSogs = async (outputFilename, dataTable) => {
2871
3282
  .webp({ lossless: true })
2872
3283
  .toFile(pathname);
2873
3284
  };
3285
+ // the layout function determines how the data is packed into the output texture.
3286
+ const layout = identity; // rectChunks;
2874
3287
  const row = {};
2875
3288
  // convert position/means
2876
3289
  const meansL = new Uint8Array(width * height * channels);
@@ -2883,7 +3296,7 @@ const writeSogs = async (outputFilename, dataTable) => {
2883
3296
  const x = 65535 * (logTransform(row.x) - meansMinMax[0][0]) / (meansMinMax[0][1] - meansMinMax[0][0]);
2884
3297
  const y = 65535 * (logTransform(row.y) - meansMinMax[1][0]) / (meansMinMax[1][1] - meansMinMax[1][0]);
2885
3298
  const z = 65535 * (logTransform(row.z) - meansMinMax[2][0]) / (meansMinMax[2][1] - meansMinMax[2][0]);
2886
- const ti = target(i, width);
3299
+ const ti = layout(i);
2887
3300
  meansL[ti * 4] = x & 0xff;
2888
3301
  meansL[ti * 4 + 1] = y & 0xff;
2889
3302
  meansL[ti * 4 + 2] = z & 0xff;
@@ -2930,7 +3343,7 @@ const writeSogs = async (outputFilename, dataTable) => {
2930
3343
  [0, 1, 3],
2931
3344
  [0, 1, 2]
2932
3345
  ][maxComp];
2933
- const ti = target(i, width);
3346
+ const ti = layout(i);
2934
3347
  quats[ti * 4] = 255 * (q[idx[0]] * 0.5 + 0.5);
2935
3348
  quats[ti * 4 + 1] = 255 * (q[idx[1]] * 0.5 + 0.5);
2936
3349
  quats[ti * 4 + 2] = 255 * (q[idx[2]] * 0.5 + 0.5);
@@ -2944,7 +3357,7 @@ const writeSogs = async (outputFilename, dataTable) => {
2944
3357
  const scaleMinMax = calcMinMax(dataTable, scaleNames);
2945
3358
  for (let i = 0; i < dataTable.numRows; ++i) {
2946
3359
  dataTable.getRow(sortIndices[i], row, scaleColumns);
2947
- const ti = target(i, width);
3360
+ const ti = layout(i);
2948
3361
  scales[ti * 4] = 255 * (row.scale_0 - scaleMinMax[0][0]) / (scaleMinMax[0][1] - scaleMinMax[0][0]);
2949
3362
  scales[ti * 4 + 1] = 255 * (row.scale_1 - scaleMinMax[1][0]) / (scaleMinMax[1][1] - scaleMinMax[1][0]);
2950
3363
  scales[ti * 4 + 2] = 255 * (row.scale_2 - scaleMinMax[2][0]) / (scaleMinMax[2][1] - scaleMinMax[2][0]);
@@ -2958,7 +3371,7 @@ const writeSogs = async (outputFilename, dataTable) => {
2958
3371
  const sh0MinMax = calcMinMax(dataTable, sh0Names);
2959
3372
  for (let i = 0; i < dataTable.numRows; ++i) {
2960
3373
  dataTable.getRow(sortIndices[i], row, sh0Columns);
2961
- const ti = target(i, width);
3374
+ const ti = layout(i);
2962
3375
  sh0[ti * 4] = 255 * (row.f_dc_0 - sh0MinMax[0][0]) / (sh0MinMax[0][1] - sh0MinMax[0][0]);
2963
3376
  sh0[ti * 4 + 1] = 255 * (row.f_dc_1 - sh0MinMax[1][0]) / (sh0MinMax[1][1] - sh0MinMax[1][0]);
2964
3377
  sh0[ti * 4 + 2] = 255 * (row.f_dc_2 - sh0MinMax[2][0]) / (sh0MinMax[2][1] - sh0MinMax[2][0]);
@@ -2998,40 +3411,85 @@ const writeSogs = async (outputFilename, dataTable) => {
2998
3411
  files: ['sh0.webp']
2999
3412
  }
3000
3413
  };
3001
- const outputFile = await open(outputFilename, 'w');
3002
- await outputFile.write((new TextEncoder()).encode(JSON.stringify(meta, null, 4)));
3003
- await outputFile.close();
3414
+ await fileHandle.write((new TextEncoder()).encode(JSON.stringify(meta, null, 4)));
3004
3415
  };
3005
3416
 
3006
3417
  const readFile = async (filename) => {
3007
3418
  console.log(`reading '${filename}'...`);
3008
3419
  const inputFile = await open(filename, 'r');
3009
- const plyData = await readPly(inputFile);
3420
+ const lowerFilename = filename.toLowerCase();
3421
+ let fileData;
3422
+ if (lowerFilename.endsWith('.ksplat')) {
3423
+ fileData = await readKsplat(inputFile);
3424
+ }
3425
+ else if (lowerFilename.endsWith('.splat')) {
3426
+ fileData = await readSplat(inputFile);
3427
+ }
3428
+ else if (lowerFilename.endsWith('.ply')) {
3429
+ fileData = await readPly(inputFile);
3430
+ }
3431
+ else {
3432
+ await inputFile.close();
3433
+ throw new Error(`Unsupported input file type: ${filename}`);
3434
+ }
3010
3435
  await inputFile.close();
3011
- return plyData;
3436
+ return fileData;
3012
3437
  };
3013
- const writeFile = async (filename, dataTable) => {
3014
- if (filename.endsWith('.json')) {
3015
- await writeSogs(filename, dataTable);
3438
+ const getOutputFormat = (filename) => {
3439
+ const lowerFilename = filename.toLowerCase();
3440
+ if (lowerFilename.endsWith('.csv')) {
3441
+ return 'csv';
3016
3442
  }
3017
- else if (filename.endsWith('.compressed.ply')) {
3018
- console.log(`writing '${filename}'...`);
3019
- const outputFile = await open(filename, 'w');
3020
- await writeCompressedPly(outputFile, dataTable);
3021
- await outputFile.close();
3443
+ else if (lowerFilename.endsWith('.json')) {
3444
+ return 'json';
3022
3445
  }
3023
- else {
3024
- console.log(`writing '${filename}'...`);
3025
- const outputFile = await open(filename, 'w');
3026
- await writePly(outputFile, {
3027
- comments: [],
3028
- elements: [{
3029
- name: 'vertex',
3030
- dataTable: dataTable
3031
- }]
3032
- });
3033
- await outputFile.close();
3446
+ else if (lowerFilename.endsWith('.compressed.ply')) {
3447
+ return 'compressed-ply';
3448
+ }
3449
+ else if (lowerFilename.endsWith('.ply')) {
3450
+ return 'ply';
3451
+ }
3452
+ throw new Error(`Unsupported output file type: ${filename}`);
3453
+ };
3454
+ const writeFile = async (filename, dataTable, options) => {
3455
+ const outputFormat = getOutputFormat(filename);
3456
+ // open the output file
3457
+ let outputFile;
3458
+ try {
3459
+ outputFile = await open(filename, options.overwrite ? 'w' : 'wx');
3460
+ }
3461
+ catch (err) {
3462
+ if (err.code === 'EEXIST') {
3463
+ console.error(`File '${filename}' already exists. Use -w option to overwrite.`);
3464
+ exit(1);
3465
+ }
3466
+ else {
3467
+ throw err;
3468
+ }
3469
+ }
3470
+ console.log(`writing '${filename}'...`);
3471
+ // write the data
3472
+ switch (outputFormat) {
3473
+ case 'csv':
3474
+ await writeCsv(outputFile, dataTable);
3475
+ break;
3476
+ case 'json':
3477
+ await writeSogs(outputFile, dataTable, filename);
3478
+ break;
3479
+ case 'compressed-ply':
3480
+ await writeCompressedPly(outputFile, dataTable);
3481
+ break;
3482
+ case 'ply':
3483
+ await writePly(outputFile, {
3484
+ comments: [],
3485
+ elements: [{
3486
+ name: 'vertex',
3487
+ dataTable: dataTable
3488
+ }]
3489
+ });
3490
+ break;
3034
3491
  }
3492
+ await outputFile.close();
3035
3493
  };
3036
3494
  // combine multiple tables into one
3037
3495
  // columns with matching name and type are combined
@@ -3098,12 +3556,17 @@ const parseArguments = () => {
3098
3556
  strict: true,
3099
3557
  allowPositionals: true,
3100
3558
  options: {
3559
+ // global options
3560
+ overwrite: { type: 'boolean', short: 'w' },
3561
+ help: { type: 'boolean', short: 'h' },
3562
+ version: { type: 'boolean', short: 'v' },
3563
+ // file options
3101
3564
  translate: { type: 'string', short: 't', multiple: true },
3102
3565
  rotate: { type: 'string', short: 'r', multiple: true },
3103
3566
  scale: { type: 'string', short: 's', multiple: true },
3104
3567
  filterNaN: { type: 'boolean', short: 'n', multiple: true },
3105
3568
  filterByValue: { type: 'string', short: 'c', multiple: true },
3106
- filterBands: { type: 'string', short: 'h', multiple: true }
3569
+ filterBands: { type: 'string', short: 'b', multiple: true }
3107
3570
  }
3108
3571
  });
3109
3572
  const parseNumber = (value) => {
@@ -3133,6 +3596,11 @@ const parseArguments = () => {
3133
3596
  }
3134
3597
  };
3135
3598
  const files = [];
3599
+ const options = {
3600
+ overwrite: v.overwrite || false,
3601
+ help: v.help || false,
3602
+ version: v.version || false
3603
+ };
3136
3604
  for (const t of tokens) {
3137
3605
  if (t.kind === 'positional') {
3138
3606
  files.push({
@@ -3193,22 +3661,57 @@ const parseArguments = () => {
3193
3661
  }
3194
3662
  }
3195
3663
  }
3196
- return files;
3664
+ return { files, options };
3197
3665
  };
3198
- const usage = `Usage: splat-transform input.ply [actions] input.ply [actions] ... output.ply [actions]
3199
- actions:
3200
- -translate -t x,y,z Translate splats by (x, y, z)
3201
- -rotate -r x,y,z Rotate splats by euler angles (x, y, z) (in degrees)
3202
- -scale -s x Scale splats by x (uniform scaling)
3203
- -filterNaN -n Remove gaussians containing any NaN or Inf value
3204
- -filterByValue -c name,comparator,value Filter gaussians by a value. Specify the value name, comparator (lt, lte, gt, gte, eq, neq) and value
3205
- -filterBands -h 1 Filter spherical harmonic band data. Value must be 0, 1, 2 or 3.
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
+ -h, --filterBands {0|1|2|3} Strip spherical-harmonic bands > N
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
3206
3704
  `;
3207
3705
  const main = async () => {
3208
3706
  console.log(`splat-transform v${version}`);
3209
3707
  // read args
3210
- const files = parseArguments();
3211
- if (files.length < 2) {
3708
+ const { files, options } = parseArguments();
3709
+ // show version and exit
3710
+ if (options.version) {
3711
+ exit(0);
3712
+ }
3713
+ // invalid args or show help
3714
+ if (files.length < 2 || options.help) {
3212
3715
  console.error(usage);
3213
3716
  exit(1);
3214
3717
  }
@@ -3236,7 +3739,7 @@ const main = async () => {
3236
3739
  // combine inputs into a single output dataTable
3237
3740
  const dataTable = process(combine(inputFiles.map(file => file.elements[0].dataTable)), outputArg.processActions);
3238
3741
  // write file
3239
- await writeFile(resolve(outputArg.filename), dataTable);
3742
+ await writeFile(resolve(outputArg.filename), dataTable, options);
3240
3743
  }
3241
3744
  catch (err) {
3242
3745
  // handle errors