Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
10 changes: 8 additions & 2 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` - supersplat standalone html viewer app

## Actions

Expand All @@ -64,6 +65,8 @@ Actions can be repeated and applied in any order:
-r, --rotate x,y,z Rotate splats by Euler angles (deg)
-s, --scale x Uniformly scale splats by factor x
-n, --filterNaN Remove any Gaussian containing NaN/Inf
-a, --camera Camera location ( for viewer generation )
-e, --target Target location ( for viewer generation )
-c, --filterByValue name,cmp,value Keep splats where <name> <cmp> <value>
cmp ∈ {lt,lte,gt,gte,eq,neq}
-b, --filterBands {0|1|2|3} Strip spherical-harmonic bands > N
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 supersplat-html-viewer with target and camera location
splat-transform -a 0,0,0 -e 0,0,10 input.ply output.html
```

### Transformations
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
40 changes: 38 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 { writeHtmlApp } from './writers/write-html-app';
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,
camera: Vec3,
target: 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 writeHtmlApp(outputFile, {
comments: [],
elements: [{
name: 'vertex',
dataTable: dataTable
}]
}, options.camera, options.target);
break;
}

await outputFile.close();
Expand Down Expand Up @@ -195,6 +209,7 @@ const parseArguments = () => {
tokens: true,
strict: true,
allowPositionals: true,
allowNegative: true,
options: {
// global options
overwrite: { type: 'boolean', short: 'w' },
Expand All @@ -207,6 +222,8 @@ const parseArguments = () => {
translate: { type: 'string', short: 't', multiple: true },
rotate: { type: 'string', short: 'r', multiple: true },
scale: { type: 'string', short: 's', multiple: true },
camera: { type: 'string', short: 'a', multiple: true },
target: { type: 'string', short: 'e', multiple: true },
filterNaN: { type: 'boolean', short: 'n', multiple: true },
filterByValue: { type: 'string', short: 'c', multiple: true },
filterBands: { type: 'string', short: 'b', 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'),
camera: parseVec3(v.camera?.[0] ?? '0,0,0'),
target: parseVec3(v.target?.[0] ?? '0,0,0')
};

for (const t of tokens) {
Expand Down Expand Up @@ -286,6 +305,18 @@ const parseArguments = () => {
value: parseNumber(t.value)
});
break;
case 'camera':
current.processActions.push({
kind: 'camera',
value: options.camera
});
break;
case 'target':
current.processActions.push({
kind: 'target',
value: options.target
});
break;
case 'filterNaN':
current.processActions.push({
kind: 'filterNaN'
Expand Down Expand Up @@ -351,6 +382,8 @@ ACTIONS (can be repeated, in any order)
-b, --filterBands {0|1|2|3} Strip spherical-harmonic bands > N

GLOBAL OPTIONS
-a, --camera x,y,z Set the camera position
-e, --target x,y,z Set the target position
-w, --overwrite Overwrite output file if it already exists. Default is false.
-h, --help Show this help and exit.
-v, --version Show version and exit.
Expand All @@ -363,6 +396,9 @@ EXAMPLES

# 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 a 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
12 changes: 11 additions & 1 deletion src/process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ type Scale = {
value: number;
};

type Camera = {
kind: 'camera';
value: Vec3;
};

type Target = {
kind: 'target';
value: Vec3;
};

type FilterNaN = {
kind: 'filterNaN';
};
Expand All @@ -34,7 +44,7 @@ type FilterBands = {
value: 0 | 1 | 2 | 3;
};

type ProcessAction = Translate | Rotate | Scale | FilterNaN | FilterByValue | FilterBands;
type ProcessAction = Translate | Rotate | Scale | Camera | Target | FilterNaN | FilterByValue | FilterBands;

const shNames = new Array(45).fill('').map((_, i) => `f_rest_${i}`);

Expand Down
62 changes: 62 additions & 0 deletions src/writers/write-html-app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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 writeHtmlApp = 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 { writeHtmlApp };