Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Native document format #389

Merged
merged 32 commits into from
Jan 28, 2025
Merged
Show file tree
Hide file tree
Changes from 29 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
280 changes: 140 additions & 140 deletions package-lock.json

Large diffs are not rendered by default.

12 changes: 6 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,22 +34,22 @@
"@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^12.1.2",
"@types/wicg-file-system-access": "^2023.10.5",
"@typescript-eslint/eslint-plugin": "^8.20.0",
"@typescript-eslint/parser": "^8.20.0",
"@typescript-eslint/eslint-plugin": "^8.21.0",
"@typescript-eslint/parser": "^8.21.0",
"autoprefixer": "^10.4.20",
"concurrently": "^9.1.2",
"cors": "^2.8.5",
"cross-env": "^7.0.3",
"eslint": "^9.18.0",
"eslint": "^9.19.0",
"eslint-import-resolver-typescript": "^3.7.0",
"globals": "^15.14.0",
"i18next": "^24.2.1",
"i18next": "^24.2.2",
"i18next-browser-languagedetector": "^8.0.2",
"jest": "^29.7.0",
"jszip": "^3.10.1",
"playcanvas": "^2.4.1",
"playcanvas": "^2.4.2",
"postcss": "^8.5.1",
"rollup": "^4.30.1",
"rollup": "^4.32.0",
"rollup-plugin-postcss": "^4.0.2",
"rollup-plugin-visualizer": "^5.14.0",
"sass": "^1.83.4",
Expand Down
2 changes: 1 addition & 1 deletion src/animation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ const registerAnimationEvents = (events: Events) => {

const file = animationFiles[frame];
const url = URL.createObjectURL(file);
const newSplat = await events.invoke('load', url, file.name, !animationSplat, true) as Splat;
const newSplat = await events.invoke('import', url, file.name, !animationSplat, true) as Splat;
URL.revokeObjectURL(url);

// wait for first frame render
Expand Down
28 changes: 23 additions & 5 deletions src/camera.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import {
RenderTarget,
Texture,
Vec3,
Vec4,
WebglGraphicsDevice
} from 'playcanvas';

Expand Down Expand Up @@ -129,10 +128,6 @@ class Camera extends Element {
return new Vec3(t.x, t.y, t.z);
}

setFocalPoint(point: Vec3, dampingFactorFactor: number = 1) {
this.focalPointTween.goto(point, dampingFactorFactor * this.scene.config.controls.dampingFactor);
}

// azimuth, elevation
get azimElev() {
return this.azimElevTween.target;
Expand All @@ -150,6 +145,10 @@ class Camera extends Element {
return this.distanceTween.target.distance;
}

setFocalPoint(point: Vec3, dampingFactorFactor: number = 1) {
this.focalPointTween.goto(point, dampingFactorFactor * this.scene.config.controls.dampingFactor);
}

setAzimElev(azim: number, elev: number, dampingFactorFactor: number = 1) {
// clamp
azim = mod(azim, 360);
Expand Down Expand Up @@ -584,6 +583,25 @@ class Camera extends Element {

return result;
}

docSerialize() {
const pack3 = (v: Vec3) => [v.x, v.y, v.z];

return {
focalPoint: pack3(this.focalPointTween.target),
azim: this.azim,
elev: this.elevation,
distance: this.distance,
fov: this.fov
};
}

docDeserialize(settings: any) {
this.setFocalPoint(new Vec3(settings.focalPoint), 0);
this.setAzimElev(settings.azim, settings.elev, 0);
this.setDistance(settings.distance, 0);
this.fov = settings.fov;
}
}

export { Camera };
300 changes: 300 additions & 0 deletions src/doc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,300 @@
import { Events } from './events';
import { Scene } from './scene';
import { DownloadWriter, FileStreamWriter } from './serialize/writer';
import { ZipWriter } from './serialize/zip-writer';
import { Splat } from './splat';
import { serializePly } from './splat-serialize';
import { localize } from './ui/localization';

// ts compiler and vscode find this type, but eslint does not
type FilePickerAcceptType = unknown;

const SuperFileType: FilePickerAcceptType[] = [{
description: 'SuperSplat document',
accept: {
'application/octet-stream': ['.super']
slimbuck marked this conversation as resolved.
Show resolved Hide resolved
}
}];

type FileSelectorCallback = (fileList: File) => void;

// helper class to show a file selector dialog.
// used when showOpenFilePicker is not available.
class FileSelector {
show: (callbackFunc: FileSelectorCallback) => void;

constructor() {
const fileSelector = document.createElement('input');
fileSelector.setAttribute('id', 'document-file-selector');
fileSelector.setAttribute('type', 'file');
fileSelector.setAttribute('accept', '.super');
fileSelector.setAttribute('multiple', 'false');

document.body.append(fileSelector);

let callbackFunc: FileSelectorCallback = null;

fileSelector.addEventListener('change', () => {
callbackFunc(fileSelector.files[0]);
});

fileSelector.addEventListener('cancel', () => {
callbackFunc(null);
});

this.show = (func: FileSelectorCallback) => {
callbackFunc = func;
fileSelector.click();
};
}
}

const registerDocEvents = (scene: Scene, events: Events) => {
// construct the file selector
const fileSelector = window.showOpenFilePicker ? null : new FileSelector();

// this file handle is updated as the current document is loaded and saved
let documentFileHandle: FileSystemFileHandle = null;

// show the user a reset confirmation popup
const getResetConfirmation = async () => {
const result = await events.invoke('showPopup', {
type: 'yesno',
header: localize('doc.reset'),
message: localize(events.invoke('scene.dirty') ? 'doc.unsaved-message' : 'doc.reset-message')
});

if (result.action !== 'yes') {
return false;
}

return true;
};

// reset the scene
const resetScene = () => {
events.fire('scene.clear');
events.fire('camera.reset');
events.fire('doc.setName', null);
documentFileHandle = null;
};

// load the document from the given file
const loadDocument = async (file: File) => {
events.fire('startSpinner');
try {
// reset the scene
resetScene();

// read the document
/* global JSZip */
// @ts-ignore
const zip = new JSZip();
await zip.loadAsync(file);
const document = JSON.parse(await zip.file('document.json').async('text'));

// run through each splat and load it
for (let i = 0; i < document.splats.length; ++i) {
const filename = `splat_${i}.ply`;
const splatSettings = document.splats[i];

// construct the splat asset
const contents = await zip.file(`splat_${i}.ply`).async('blob');
const url = URL.createObjectURL(contents);
const splat = await scene.assetLoader.loadModel({
url,
filename
});
URL.revokeObjectURL(url);

scene.add(splat);

splat.docDeserialize(splatSettings);
}

// FIXME: trigger scene bound calc in a better way
const tmp = scene.bound;
if (tmp === null) {
console.error('this should never fire');
}

events.invoke('docDeserialize.poseSets', document.poseSets);
events.invoke('docDeserialize.view', document.view);
scene.camera.docDeserialize(document.camera);
} catch (error) {
await events.invoke('showPopup', {
type: 'error',
header: localize('doc.load-failed'),
message: `'${error.message ?? error}'`
});
} finally {
events.fire('stopSpinner');
}
};

const saveDocument = async (options: { stream?: FileSystemWritableFileStream, filename?: string }) => {
events.fire('startSpinner');

try {
const splats = events.invoke('scene.allSplats') as Splat[];

const document = {
version: 0,
camera: scene.camera.docSerialize(),
view: events.invoke('docSerialize.view'),
poseSets: events.invoke('docSerialize.poseSets'),
splats: splats.map(s => s.docSerialize())
};

const serializeSettings = {
// even though we support saving selection state, we disable that for now
// because including a uint8 array in the document PLY results in slow loading
// path.
keepStateData: false,
keepWorldTransform: true,
keepColorTint: true
};

const writer = options.stream ? new FileStreamWriter(options.stream) : new DownloadWriter(options.filename);
const zipWriter = new ZipWriter(writer);
await zipWriter.file('document.json', JSON.stringify(document));
for (let i = 0; i < splats.length; ++i) {
await zipWriter.start(`splat_${i}.ply`);
await serializePly([splats[i]], serializeSettings, zipWriter);
}
await zipWriter.close();
await writer.close();
} catch (error) {
await events.invoke('showPopup', {
type: 'error',
header: localize('doc.save-failed'),
message: `'${error.message ?? error}'`
});
} finally {
events.fire('stopSpinner');
}
};

// handle user requesting a new document
events.function('doc.new', async () => {
if (!await getResetConfirmation()) {
return false;
}
resetScene();
return true;
});

// handle document file being dropped
// NOTE: on chrome it's possible to get the FileSystemFileHandle from the DataTransferItem
// (which would result in more seamless user experience), but this is not yet supported in
// other browsers.
events.function('doc.dropped', async (file: File) => {
if (!events.invoke('scene.empty') && !await getResetConfirmation()) {
return false;
}

await loadDocument(file);

events.fire('doc.setName', file.name);
});

events.function('doc.open', async () => {
if (!events.invoke('scene.empty') && !await getResetConfirmation()) {
return false;
}

if (fileSelector) {
fileSelector.show(async (file?: File) => {
if (file) {
await loadDocument(file);
}
});
} else {
try {
const fileHandles = await window.showOpenFilePicker({
id: 'SuperSplatDocumentOpen',
multiple: false,
types: SuperFileType
});

if (fileHandles?.length === 1) {
const fileHandle = fileHandles[0];

// null file handle incase loadDocument fails
await loadDocument(await fileHandle.getFile());

// store file handle for subsequent saves
documentFileHandle = fileHandle;
events.fire('doc.setName', fileHandle.name);
}
} catch (error) {
if (error.name !== 'AbortError') {
console.error(error);
}
}
}
});

events.function('doc.save', async () => {
if (documentFileHandle) {
try {
await saveDocument({
stream: await documentFileHandle.createWritable()
});
events.fire('doc.saved');
} catch (error) {
if (error.name !== 'AbortError' && error.name !== 'NotAllowedError') {
console.error(error);
}
}
} else {
await events.invoke('doc.saveAs');
}
});

events.function('doc.saveAs', async () => {
if (window.showSaveFilePicker) {
try {
const handle = await window.showSaveFilePicker({
id: 'SuperSplatDocumentSave',
types: SuperFileType,
suggestedName: 'scene.super'
});
await saveDocument({ stream: await handle.createWritable() });
documentFileHandle = handle;
events.fire('doc.setName', handle.name);
events.fire('doc.saved');
} catch (error) {
if (error.name !== 'AbortError') {
console.error(error);
}
}
} else {
await saveDocument({
filename: 'scene.super'
});
events.fire('doc.saved');
}
});

// doc name

let docName: string = null;

const setDocName = (name: string) => {
if (name !== docName) {
docName = name;
events.fire('doc.name', docName);
}
};

events.function('doc.name', () => {
return docName;
});

events.on('doc.setName', (name) => {
setDocName(name);
});
};

export { registerDocEvents };
Loading
Loading