Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
SplatTransform is an open source CLI tool for converting and editing Gaussian splats. It can:

📥 Read PLY, Compressed PLY, SPLAT, KSPLAT formats
📤 Write PLY, Compressed PLY, CSV, and SOG formats
📤 Write PLY, Compressed PLY, CSV, SOG and HTML viewer formats
🔗 Merge multiple splats
🔄 Apply transformations to input splats
🎛️ Filter out Gaussians or spherical harmonic bands
Expand All @@ -33,7 +33,7 @@ npm install -g @playcanvas/splat-transform
## Usage

```bash
splat-transform [GLOBAL] <input.{ply|compressed.ply|splat|ksplat}> [ACTIONS] ... <output.{ply|compressed.ply|sog|meta.json|csv}> [ACTIONS]
splat-transform [GLOBAL] <input.{ply|compressed.ply|splat|ksplat}> [ACTIONS] ... <output.{ply|compressed.ply|sog|meta.json|csv|html}> [ACTIONS]
```

**Key points:**
Expand All @@ -54,6 +54,7 @@ splat-transform [GLOBAL] <input.{ply|compressed.ply|splat|ksplat}> [ACTIONS] .
- `.sog` - SOG bundled format
- `meta.json` - SOG unbundled format (JSON + WebP images)
- `.csv` - Comma-separated values
- `.html` - Standalone HTML viewer app

## Actions

Expand All @@ -77,6 +78,8 @@ Actions can be repeated and applied in any order:
-v, --version Show version and exit
-g, --no-gpu Disable gpu when compressing spherical harmonics.
-i, --iterations <number> Specify the number of iterations when compressing spherical harmonics. More iterations generally lead to better results. Default is 10.
-p, --cameraPos Specify the viewer starting position. Default is 2,2,-2.
-e, --cameraTarget Specify the viewer starting target. Default is 0,0,0.
```

## Examples
Expand Down Expand Up @@ -105,6 +108,9 @@ splat-transform input.ply output.sog

# Convert to SOG unbundled format
splat-transform input.ply output/meta.json

# Convert to HTML viewer with target and camera location
splat-transform -a 0,0,0 -e 0,0,10 input.ply output.html
```

### Transformations
Expand Down Expand Up @@ -140,7 +146,7 @@ splat-transform input.ply --filterBands 2 output.ply
splat-transform -w cloudA.ply -r 0,90,0 cloudB.ply -s 2 merged.compressed.ply

# Apply final transformations to combined result
splat-transform input1.ply input2.ply output.ply -t 0,0,10 -s 0.5
splat-transform input1.ply input2.ply output.ply -p 0,0,10 -e 0.5
```

## Getting Help
Expand Down
66 changes: 16 additions & 50 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"typescript": "^5.9.2"
},
"dependencies": {
"@playcanvas/supersplat-viewer": "^1.4.1",
"webgpu": "^0.3.0"
},
"scripts": {
Expand Down
28 changes: 26 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { readPly } from './readers/read-ply';
import { readSplat } from './readers/read-splat';
import { writeCompressedPly } from './writers/write-compressed-ply';
import { writeCsv } from './writers/write-csv';
import { writeHtml } from './writers/write-html';
import { writePly } from './writers/write-ply';
import { writeSog } from './writers/write-sog';

Expand All @@ -22,7 +23,9 @@ type Options = {
help: boolean,
version: boolean,
gpu: boolean,
iterations: number
iterations: number,
cameraPos: Vec3,
cameraTarget: Vec3
};

const readFile = async (filename: string) => {
Expand Down Expand Up @@ -66,6 +69,8 @@ const getOutputFormat = (filename: string) => {
return 'compressed-ply';
} else if (lowerFilename.endsWith('.ply')) {
return 'ply';
} else if (lowerFilename.endsWith('.html')) {
return 'html';
}

throw new Error(`Unsupported output file type: ${filename}`);
Expand Down Expand Up @@ -111,6 +116,15 @@ const writeFile = async (filename: string, dataTable: DataTable, options: Option
}]
});
break;
case 'html':
await writeHtml(outputFile, {
comments: [],
elements: [{
name: 'vertex',
dataTable: dataTable
}]
}, options.cameraPos, options.cameraTarget);
break;
}

await outputFile.close();
Expand Down Expand Up @@ -195,13 +209,16 @@ const parseArguments = () => {
tokens: true,
strict: true,
allowPositionals: true,
allowNegative: true,
options: {
// global options
overwrite: { type: 'boolean', short: 'w' },
help: { type: 'boolean', short: 'h' },
version: { type: 'boolean', short: 'v' },
'no-gpu': { type: 'boolean', short: 'g' },
iterations: { type: 'string', short: 'i' },
cameraPos: { type: 'string', short: 'p' },
cameraTarget: { type: 'string', short: 'e' },

// file options
translate: { type: 'string', short: 't', multiple: true },
Expand Down Expand Up @@ -256,7 +273,9 @@ const parseArguments = () => {
help: v.help ?? false,
version: v.version ?? false,
gpu: !(v['no-gpu'] ?? false),
iterations: parseInteger(v.iterations ?? '10')
iterations: parseInteger(v.iterations ?? '10'),
cameraPos: parseVec3(v.cameraPos ?? '2,2,-2'),
cameraTarget: parseVec3(v.cameraTarget ?? '0,0,0')
};

for (const t of tokens) {
Expand Down Expand Up @@ -356,13 +375,18 @@ GLOBAL OPTIONS
-v, --version Show version and exit.
-g, --no-gpu Disable gpu when compressing spherical harmonics.
-i, --iterations <number> Specify the number of iterations when compressing spherical harmonics. More iterations generally lead to better results. Default is 10.
-p, --cameraPos x,y,z Specify the viewer camera position. Default is 2,2,-2.
-e, --cameraTarget x,y,z Specify the viewer target position. Default is 0,0,0.

EXAMPLES
# Simple scale-then-translate
splat-transform bunny.ply -s 0.5 -t 0,0,10 bunny_scaled.ply

# Chain two inputs and write compressed output, overwriting if necessary
splat-transform -w cloudA.ply -r 0,90,0 cloudB.ply -s 2 merged.compressed.ply

# Create an HTML app with a custom camera and target
splat-transform -a 0,0,0 -e 0,0,10 bunny.ply bunny_app.html
`;

const main = async () => {
Expand Down
61 changes: 61 additions & 0 deletions src/writers/write-html.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { open, unlink, FileHandle } from 'node:fs/promises';
import os from 'node:os';

import { html, css, js } from '@playcanvas/supersplat-viewer';
import { Vec3 } from 'playcanvas';

import { writeCompressedPly } from './write-compressed-ply';
import { PlyData } from '../readers/read-ply';

const writeHtml = async (fileHandle: FileHandle, plyData: PlyData, camera: Vec3, target: Vec3) => {
const pad = (text: string, spaces: number) => {
const whitespace = ' '.repeat(spaces);
return text.split('\n').map(line => whitespace + line).join('\n');
};
const encodeBase64 = (bytes: Uint8Array) => {
let binary = '';
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return Buffer.from(binary, 'binary').toString('base64');
};

const experienceSettings = {
camera: {
fov: 50,
position: [camera.x, camera.y, camera.z],
target: [target.x, target.y, target.z],
startAnim: 'none',
animTrack: undefined as unknown as string | undefined
},
background: {
color: [0.4, 0.4, 0.4]
},
animTracks: [] as unknown[]
};

const tempPlyPath = `${os.tmpdir()}/temp.ply`;
const tempPly = await open(tempPlyPath, 'w+');
await writeCompressedPly(tempPly, plyData.elements[0].dataTable);
const openPly = await open(tempPlyPath, 'r');
const compressedPly = encodeBase64(await openPly.readFile());
await openPly.close();
await unlink(tempPlyPath);

const style = '<link rel="stylesheet" href="./index.css">';
const script = '<script type="module" src="./index.js"></script>';
const settings = 'settings: fetch(settingsUrl).then(response => response.json())';
const content = 'fetch(contentUrl)';

const generatedHtml = html
.replace(style, `<style>\n${pad(css, 12)}\n </style>`)
.replace(script, `<script type="module">\n${pad(js, 12)}\n </script>`)
.replace(settings, `settings: ${JSON.stringify(experienceSettings)}`)
.replace(content, `fetch("data:application/ply;base64,${compressedPly}")`);

await fileHandle.write(new TextEncoder().encode(generatedHtml));

};

export { writeHtml };