@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/LICENSE +19 -19
- package/README.md +125 -39
- package/bin/cli.mjs +5 -5
- package/dist/index.mjs +556 -53
- 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.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
|
-
//
|
|
2850
|
-
|
|
2851
|
-
|
|
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 (
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
3436
|
+
return fileData;
|
|
3012
3437
|
};
|
|
3013
|
-
const
|
|
3014
|
-
|
|
3015
|
-
|
|
3438
|
+
const getOutputFormat = (filename) => {
|
|
3439
|
+
const lowerFilename = filename.toLowerCase();
|
|
3440
|
+
if (lowerFilename.endsWith('.csv')) {
|
|
3441
|
+
return 'csv';
|
|
3016
3442
|
}
|
|
3017
|
-
else if (
|
|
3018
|
-
|
|
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
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
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: '
|
|
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 = `
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
-
|
|
3204
|
-
|
|
3205
|
-
|
|
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
|
-
|
|
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
|