@playcanvas/splat-transform 0.3.0 → 0.4.1

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/README.md CHANGED
@@ -1,39 +1,125 @@
1
1
  # Splat Transform - 3D Gaussian Splat Converter
2
2
 
3
- Splat Transform is an open source CLI tool for reading gaussian splat PLY files and writing them to PLY, compressed.ply and SOGS format.
3
+ Splat Transform is an open source CLI tool for reading gaussian splat PLY files and writing them to PLY, Compressed PLY, CSV, and SOGS format.
4
4
 
5
5
  Multiple files may be combined and transformed before being written to the output.
6
6
 
7
7
  ## Installation
8
+
8
9
  First install the package globally:
9
- ```
10
+
11
+ ```bash
10
12
  npm install -g @playcanvas/splat-transform
11
13
  ```
12
14
 
13
- Then invoke the CLI from anywhere as follows:
15
+ ## Usage
16
+
14
17
  ```bash
15
- # combine input_a.ply and input_b.ply and write the result to compressed ply format
16
- splat-transform input_a.ply input_b.ply output.compressed.ply
18
+ splat-transform [GLOBAL] <input.{ply|splat|ksplat}> [ACTIONS] ... <output.{ply|compressed.ply|meta.json|csv}> [ACTIONS]
19
+ ```
20
+
21
+ **Key points:**
22
+ - Every time an `*.ply*` appears, it becomes the current working set; the following ACTIONS are applied in the order listed
23
+ - The last file on the command line is treated as the output; anything after it is interpreted as actions that modify the final result
24
+
25
+ ## Supported Formats
26
+
27
+ **Input:**
28
+ - `.ply` - Standard PLY format
29
+ - `.splat` - Binary splat format (antimatter15 format)
30
+ - `.ksplat` - Compressed binary splat format (mkkellogg format)
31
+
32
+ **Output:**
33
+ - `.ply` - Standard PLY format
34
+ - `.compressed.ply` - Compressed PLY format
35
+ - `meta.json` - SOGS format (JSON + WebP images)
36
+ - `.csv` - Comma-separated values
37
+
38
+ ## Actions
17
39
 
18
- # read input_a.ply and input_b.ply and write the result in SOGS format
19
- splat-transform input_a.ply input_b.ply output/meta.json
40
+ Actions can be repeated and applied in any order:
41
+
42
+ ```bash
43
+ -t, --translate x,y,z Translate splats by (x, y, z)
44
+ -r, --rotate x,y,z Rotate splats by Euler angles (deg)
45
+ -s, --scale x Uniformly scale splats by factor x
46
+ -n, --filterNaN Remove any Gaussian containing NaN/Inf
47
+ -c, --filterByValue name,cmp,value Keep splats where <name> <cmp> <value>
48
+ cmp ∈ {lt,lte,gt,gte,eq,neq}
49
+ -b, --filterBands {0|1|2|3} Strip spherical-harmonic bands > N
20
50
  ```
21
51
 
22
- The input and output files can optionally be transformed. For example:
52
+ ## Global Options
53
+
54
+ ```bash
55
+ -w, --overwrite Overwrite output file if it already exists
56
+ -h, --help Show help and exit
57
+ -v, --version Show version and exit
23
58
  ```
24
- # load input.ply and translate it by (1, 0, 0) and write the result to output.ply
25
- splat-transform input.ply -t 1,0,0 output.ply
26
59
 
27
- # remove entries containing NaN and Inf and bands larger than 2
28
- splat-transform input.ply output.ply --filterNaN --filterBands 2
60
+ ## Examples
61
+
62
+ ### Basic Operations
63
+
64
+ ```bash
65
+ # Simple format conversion
66
+ splat-transform input.ply output.csv
67
+
68
+ # Convert from .splat format
69
+ splat-transform input.splat output.ply
70
+
71
+ # Convert from .ksplat format
72
+ splat-transform input.ksplat output.ply
73
+
74
+ # Convert to compressed PLY
75
+ splat-transform input.ply output.compressed.ply
76
+
77
+ # Convert to SOGS format
78
+ splat-transform input.ply output/meta.json
79
+ ```
80
+
81
+ ### Transformations
82
+
83
+ ```bash
84
+ # Scale and translate
85
+ splat-transform bunny.ply -s 0.5 -t 0,0,10 bunny_scaled.ply
86
+
87
+ # Rotate by 90 degrees around Y axis
88
+ splat-transform input.ply -r 0,90,0 output.ply
89
+
90
+ # Chain multiple transformations
91
+ splat-transform input.ply -s 2 -t 1,0,0 -r 0,0,45 output.ply
92
+ ```
93
+
94
+ ### Filtering
95
+
96
+ ```bash
97
+ # Remove entries containing NaN and Inf
98
+ splat-transform input.ply --filterNaN output.ply
99
+
100
+ # Filter by opacity values (keep only splats with opacity > 0.5)
101
+ splat-transform input.ply -c opacity,gt,0.5 output.ply
102
+
103
+ # Strip spherical harmonic bands higher than 2
104
+ splat-transform input.ply --filterBands 2 output.ply
29
105
  ```
30
106
 
31
- The full list of possible actions are as follows:
107
+ ### Advanced Usage
108
+
109
+ ```bash
110
+ # Combine multiple files with different transforms
111
+ splat-transform -w cloudA.ply -r 0,90,0 cloudB.ply -s 2 merged.compressed.ply
112
+
113
+ # Apply final transformations to combined result
114
+ splat-transform input1.ply input2.ply output.ply -t 0,0,10 -s 0.5
32
115
  ```
33
- -translate -t x,y,z Translate splats by (x, y, z)
34
- -rotate -r x,y,z Rotate splats by euler angles (x, y, z) (in degrees)
35
- -scale -s x Scale splats by x (uniform scaling)
36
- -filterNaN -n Remove gaussians containing any NaN or Inf value
37
- -filterByValue -c name,comparator,value Filter gaussians by a value. Specify the value name, comparator (lt, lte, gt, gte, eq, neq) and value
38
- -filterBands -h 1 Filter spherical harmonic band data. Value must be 0, 1, 2 or 3.
116
+
117
+ ## Getting Help
118
+
119
+ ```bash
120
+ # Show version
121
+ splat-transform --version
122
+
123
+ # Show help
124
+ splat-transform --help
39
125
  ```
package/dist/index.mjs CHANGED
@@ -1881,7 +1881,7 @@ class Quat {
1881
1881
  }
1882
1882
  }
1883
1883
 
1884
- var version = "0.3.0";
1884
+ var version = "0.4.0";
1885
1885
 
1886
1886
  class Column {
1887
1887
  name;
@@ -2307,6 +2307,311 @@ 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
+ // const channel = Math.floor(i / (harmonicsComponentCount / 3));
2578
+ // const coeff = i % (harmonicsComponentCount / 3);
2579
+ // const channel = i % 3;
2580
+ // const coeff = Math.floor(i / 3);
2581
+ let channel;
2582
+ let coeff;
2583
+ if (i < 9) {
2584
+ channel = Math.floor(i / 3);
2585
+ coeff = i % 3;
2586
+ }
2587
+ else if (i < 24) {
2588
+ channel = Math.floor((i - 9) / 5);
2589
+ coeff = (i - 9) % 5 + 3;
2590
+ }
2591
+ else {
2592
+ channel = Math.floor((i - 24) / 6);
2593
+ coeff = (i - 24) % 6 + 9;
2594
+ }
2595
+ const col = channel * (harmonicsComponentCount / 3) + coeff;
2596
+ columns[14 + col].data[splatIndex] = decodeHarmonics(splatByteOffset, i);
2597
+ }
2598
+ splatIndex++;
2599
+ }
2600
+ currentSectionDataOffset += sectionDataSize + totalBucketStorageSize;
2601
+ }
2602
+ if (splatIndex !== numSplats) {
2603
+ throw new Error(`Splat count mismatch: expected ${numSplats}, processed ${splatIndex}`);
2604
+ }
2605
+ const resultTable = new DataTable(columns);
2606
+ return {
2607
+ comments: [],
2608
+ elements: [{
2609
+ name: 'vertex',
2610
+ dataTable: resultTable
2611
+ }]
2612
+ };
2613
+ };
2614
+
2310
2615
  const getDataType = (type) => {
2311
2616
  switch (type) {
2312
2617
  case 'char': return Int8Array;
@@ -2445,6 +2750,121 @@ const readPly = async (fileHandle) => {
2445
2750
  };
2446
2751
  };
2447
2752
 
2753
+ const readSplat = async (fileHandle) => {
2754
+ // Get file size to determine number of splats
2755
+ const fileStats = await fileHandle.stat();
2756
+ const fileSize = fileStats.size;
2757
+ // Each splat is 32 bytes
2758
+ const BYTES_PER_SPLAT = 32;
2759
+ if (fileSize % BYTES_PER_SPLAT !== 0) {
2760
+ throw new Error('Invalid .splat file: file size is not a multiple of 32 bytes');
2761
+ }
2762
+ const numSplats = fileSize / BYTES_PER_SPLAT;
2763
+ if (numSplats === 0) {
2764
+ throw new Error('Invalid .splat file: file is empty');
2765
+ }
2766
+ // Create columns for the standard Gaussian splat data
2767
+ const columns = [
2768
+ // Position
2769
+ new Column('x', new Float32Array(numSplats)),
2770
+ new Column('y', new Float32Array(numSplats)),
2771
+ new Column('z', new Float32Array(numSplats)),
2772
+ // Scale (stored as linear in .splat, convert to log for internal use)
2773
+ new Column('scale_0', new Float32Array(numSplats)),
2774
+ new Column('scale_1', new Float32Array(numSplats)),
2775
+ new Column('scale_2', new Float32Array(numSplats)),
2776
+ // Color/opacity
2777
+ new Column('f_dc_0', new Float32Array(numSplats)), // Red
2778
+ new Column('f_dc_1', new Float32Array(numSplats)), // Green
2779
+ new Column('f_dc_2', new Float32Array(numSplats)), // Blue
2780
+ new Column('opacity', new Float32Array(numSplats)),
2781
+ // Rotation quaternion
2782
+ new Column('rot_0', new Float32Array(numSplats)),
2783
+ new Column('rot_1', new Float32Array(numSplats)),
2784
+ new Column('rot_2', new Float32Array(numSplats)),
2785
+ new Column('rot_3', new Float32Array(numSplats))
2786
+ ];
2787
+ // Read data in chunks
2788
+ const chunkSize = 1024;
2789
+ const numChunks = Math.ceil(numSplats / chunkSize);
2790
+ const chunkData = Buffer$1.alloc(chunkSize * BYTES_PER_SPLAT);
2791
+ for (let c = 0; c < numChunks; ++c) {
2792
+ const numRows = Math.min(chunkSize, numSplats - c * chunkSize);
2793
+ const bytesToRead = numRows * BYTES_PER_SPLAT;
2794
+ const { bytesRead } = await fileHandle.read(chunkData, 0, bytesToRead);
2795
+ if (bytesRead !== bytesToRead) {
2796
+ throw new Error('Failed to read expected amount of data from .splat file');
2797
+ }
2798
+ // Parse each splat in the chunk
2799
+ for (let r = 0; r < numRows; ++r) {
2800
+ const splatIndex = c * chunkSize + r;
2801
+ const offset = r * BYTES_PER_SPLAT;
2802
+ // Read position (3 × float32)
2803
+ const x = chunkData.readFloatLE(offset + 0);
2804
+ const y = chunkData.readFloatLE(offset + 4);
2805
+ const z = chunkData.readFloatLE(offset + 8);
2806
+ // Read scale (3 × float32)
2807
+ const scaleX = chunkData.readFloatLE(offset + 12);
2808
+ const scaleY = chunkData.readFloatLE(offset + 16);
2809
+ const scaleZ = chunkData.readFloatLE(offset + 20);
2810
+ // Read color and opacity (4 × uint8)
2811
+ const red = chunkData.readUInt8(offset + 24);
2812
+ const green = chunkData.readUInt8(offset + 25);
2813
+ const blue = chunkData.readUInt8(offset + 26);
2814
+ const opacity = chunkData.readUInt8(offset + 27);
2815
+ // Read rotation quaternion (4 × uint8)
2816
+ const rot0 = chunkData.readUInt8(offset + 28);
2817
+ const rot1 = chunkData.readUInt8(offset + 29);
2818
+ const rot2 = chunkData.readUInt8(offset + 30);
2819
+ const rot3 = chunkData.readUInt8(offset + 31);
2820
+ // Store position
2821
+ columns[0].data[splatIndex] = x;
2822
+ columns[1].data[splatIndex] = y;
2823
+ columns[2].data[splatIndex] = z;
2824
+ // Store scale (convert from linear in .splat to log scale for internal use)
2825
+ columns[3].data[splatIndex] = Math.log(scaleX);
2826
+ columns[4].data[splatIndex] = Math.log(scaleY);
2827
+ columns[5].data[splatIndex] = Math.log(scaleZ);
2828
+ // Store color (convert from uint8 back to spherical harmonics)
2829
+ const SH_C0 = 0.28209479177387814;
2830
+ columns[6].data[splatIndex] = (red / 255.0 - 0.5) / SH_C0;
2831
+ columns[7].data[splatIndex] = (green / 255.0 - 0.5) / SH_C0;
2832
+ columns[8].data[splatIndex] = (blue / 255.0 - 0.5) / SH_C0;
2833
+ // Store opacity (convert from uint8 to float and apply inverse sigmoid)
2834
+ const epsilon = 1e-6;
2835
+ const normalizedOpacity = Math.max(epsilon, Math.min(1.0 - epsilon, opacity / 255.0));
2836
+ columns[9].data[splatIndex] = Math.log(normalizedOpacity / (1.0 - normalizedOpacity));
2837
+ // Store rotation quaternion (convert from uint8 [0,255] to float [-1,1] and normalize)
2838
+ const rot0Norm = (rot0 / 255.0) * 2.0 - 1.0;
2839
+ const rot1Norm = (rot1 / 255.0) * 2.0 - 1.0;
2840
+ const rot2Norm = (rot2 / 255.0) * 2.0 - 1.0;
2841
+ const rot3Norm = (rot3 / 255.0) * 2.0 - 1.0;
2842
+ // Normalize quaternion
2843
+ const length = Math.sqrt(rot0Norm * rot0Norm + rot1Norm * rot1Norm + rot2Norm * rot2Norm + rot3Norm * rot3Norm);
2844
+ if (length > 0) {
2845
+ columns[10].data[splatIndex] = rot0Norm / length;
2846
+ columns[11].data[splatIndex] = rot1Norm / length;
2847
+ columns[12].data[splatIndex] = rot2Norm / length;
2848
+ columns[13].data[splatIndex] = rot3Norm / length;
2849
+ }
2850
+ else {
2851
+ // Default to identity quaternion if invalid
2852
+ columns[10].data[splatIndex] = 0.0;
2853
+ columns[11].data[splatIndex] = 0.0;
2854
+ columns[12].data[splatIndex] = 0.0;
2855
+ columns[13].data[splatIndex] = 1.0;
2856
+ }
2857
+ }
2858
+ }
2859
+ return {
2860
+ comments: [],
2861
+ elements: [{
2862
+ name: 'vertex',
2863
+ dataTable: new DataTable(columns)
2864
+ }]
2865
+ };
2866
+ };
2867
+
2448
2868
  const sigmoid = (v) => 1 / (1 + Math.exp(-v));
2449
2869
 
2450
2870
  const q = new Quat();
@@ -3016,9 +3436,23 @@ const writeSogs = async (fileHandle, dataTable, outputFilename) => {
3016
3436
  const readFile = async (filename) => {
3017
3437
  console.log(`reading '${filename}'...`);
3018
3438
  const inputFile = await open(filename, 'r');
3019
- const plyData = await readPly(inputFile);
3439
+ const lowerFilename = filename.toLowerCase();
3440
+ let fileData;
3441
+ if (lowerFilename.endsWith('.ksplat')) {
3442
+ fileData = await readKsplat(inputFile);
3443
+ }
3444
+ else if (lowerFilename.endsWith('.splat')) {
3445
+ fileData = await readSplat(inputFile);
3446
+ }
3447
+ else if (lowerFilename.endsWith('.ply')) {
3448
+ fileData = await readPly(inputFile);
3449
+ }
3450
+ else {
3451
+ await inputFile.close();
3452
+ throw new Error(`Unsupported input file type: ${filename}`);
3453
+ }
3020
3454
  await inputFile.close();
3021
- return plyData;
3455
+ return fileData;
3022
3456
  };
3023
3457
  const getOutputFormat = (filename) => {
3024
3458
  const lowerFilename = filename.toLowerCase();
@@ -3034,9 +3468,7 @@ const getOutputFormat = (filename) => {
3034
3468
  else if (lowerFilename.endsWith('.ply')) {
3035
3469
  return 'ply';
3036
3470
  }
3037
- else {
3038
- throw new Error(`Unsupported output file type: ${filename}`);
3039
- }
3471
+ throw new Error(`Unsupported output file type: ${filename}`);
3040
3472
  };
3041
3473
  const writeFile = async (filename, dataTable, options) => {
3042
3474
  const outputFormat = getOutputFormat(filename);
@@ -3153,7 +3585,7 @@ const parseArguments = () => {
3153
3585
  scale: { type: 'string', short: 's', multiple: true },
3154
3586
  filterNaN: { type: 'boolean', short: 'n', multiple: true },
3155
3587
  filterByValue: { type: 'string', short: 'c', multiple: true },
3156
- filterBands: { type: 'string', short: 'b', multiple: true },
3588
+ filterBands: { type: 'string', short: 'b', multiple: true }
3157
3589
  }
3158
3590
  });
3159
3591
  const parseNumber = (value) => {
@@ -3255,15 +3687,15 @@ Apply geometric transforms & filters to Gaussian-splat point clouds
3255
3687
  ===================================================================
3256
3688
 
3257
3689
  USAGE
3258
- splat-transform [GLOBAL] <input.ply> [ACTIONS] ... <output.{ply|compressed.ply|meta.json|csv}> [ACTIONS]
3690
+ splat-transform [GLOBAL] <input.{ply|splat|ksplat}> [ACTIONS] ... <output.{ply|compressed.ply|meta.json|csv}> [ACTIONS]
3259
3691
 
3260
- • Every time an *.ply* appears, it becomes the current working set; the following
3692
+ • Every time an input file appears, it becomes the current working set; the following
3261
3693
  ACTIONS are applied in the order listed.
3262
3694
  • The last file on the command line is treated as the output; anything after it is
3263
3695
  interpreted as actions that modify the final result.
3264
3696
 
3265
3697
  SUPPORTED INPUTS
3266
- .ply
3698
+ .ply .splat .ksplat
3267
3699
 
3268
3700
  SUPPORTED OUTPUTS
3269
3701
  .ply .compressed.ply meta.json (SOGS) .csv