diff --git a/src/asset-loader.ts b/src/asset-loader.ts index 54d84f1..647223d 100644 --- a/src/asset-loader.ts +++ b/src/asset-loader.ts @@ -1,8 +1,7 @@ import { Asset, AssetRegistry, GraphicsDevice, GSplatData, GSplatResource, TEXTURETYPE_RGBP } from 'playcanvas'; import { Splat } from './splat'; import { Env } from './env'; - -import { startSpinner, stopSpinner } from './ui/spinner'; +import { Events } from './events'; interface ModelLoadRequest { url?: string; @@ -94,17 +93,19 @@ const deserializeFromSSplat = (data: ArrayBufferLike) => { class AssetLoader { device: GraphicsDevice; registry: AssetRegistry; + events: Events; defaultAnisotropy: number; loadAllData = true; - constructor(device: GraphicsDevice, registry: AssetRegistry, defaultAnisotropy?: number) { + constructor(device: GraphicsDevice, registry: AssetRegistry, events: Events, defaultAnisotropy?: number) { this.device = device; this.registry = registry; + this.events = events; this.defaultAnisotropy = defaultAnisotropy || 1; } loadPly(loadRequest: ModelLoadRequest) { - startSpinner(); + this.events.fire('startSpinner'); return new Promise((resolve, reject) => { const asset = new Asset( @@ -154,12 +155,12 @@ class AssetLoader { this.registry.add(asset); this.registry.load(asset); }).finally(() => { - stopSpinner(); + this.events.fire('stopSpinner'); }); } loadSplat(loadRequest: ModelLoadRequest) { - startSpinner(); + this.events.fire('startSpinner'); return new Promise((resolve, reject) => { fetch(loadRequest.url || loadRequest.filename) @@ -184,7 +185,7 @@ class AssetLoader { reject('Failed to load splat data'); }); }).finally(() => { - stopSpinner(); + this.events.fire('stopSpinner'); }); } diff --git a/src/file-handler.ts b/src/file-handler.ts index 1835b1a..2b42e85 100644 --- a/src/file-handler.ts +++ b/src/file-handler.ts @@ -2,8 +2,7 @@ import { path, Vec3 } from 'playcanvas'; import { Scene } from './scene'; import { Events } from './events'; import { CreateDropHandler } from './drop-handler'; -import { convertPly, convertPlyCompressed, convertSplat } from './splat-convert'; -import { startSpinner, stopSpinner } from './ui/spinner'; +import { WriteFunc, serializePly, serializePlyCompressed, serializeSplat } from './splat-serialize'; import { ElementType } from './element'; import { Splat } from './splat'; import { localize } from './ui/localization'; @@ -47,7 +46,7 @@ let fileHandle: FileSystemFileHandle = null; const vec = new Vec3(); // download the data to the given filename -const download = (filename: string, data: ArrayBuffer) => { +const download = (filename: string, data: Uint8Array) => { const blob = new Blob([data], { type: "octet/stream" }); const url = window.URL.createObjectURL(blob); @@ -81,14 +80,6 @@ const sendToRemoteStorage = async (filename: string, data: ArrayBuffer, remoteSt }); }; -// write the data to file -const writeToFile = async (stream: FileSystemWritableFileStream, data: ArrayBuffer) => { - await stream.seek(0); - await stream.write(data); - await stream.truncate(data.byteLength); - await stream.close(); -}; - const loadCameraPoses = async (url: string, filename: string, events: Events) => { const response = await fetch(url); const json = await response.json(); @@ -331,39 +322,61 @@ const initFileHandler = async (scene: Scene, events: Events, dropTarget: HTMLEle } }); + const writeScene = async (type: ExportType, writeFunc: WriteFunc) => { + const splats = getSplats(); + + switch (type) { + case 'ply': + await serializePly(splats, writeFunc); + break; + case 'compressed-ply': + await serializePlyCompressed(splats, writeFunc); + return; + case 'splat': + await serializeSplat(splats, writeFunc); + return; + } + }; + events.function('scene.write', async (options: SceneWriteOptions) => { - startSpinner(); + events.fire('startSpinner'); try { - const splats = getSplats(); - // setTimeout so spinner has a chance to activate await new Promise((resolve) => { setTimeout(resolve); }); - const data = (() => { - switch (options.type) { - case 'ply': - return convertPly(splats); - case 'compressed-ply': - return convertPlyCompressed(splats); - case 'splat': - return convertSplat(splats); - default: - return null; - } - })(); - - if (options.stream) { - // write to stream - await writeToFile(options.stream, data); - } else if (remoteStorageDetails) { - // write data to remote storage - await sendToRemoteStorage(options.filename, data, remoteStorageDetails); + const { stream } = options; + + if (stream) { + // writer must keep track of written bytes because JS streams don't + let cursor = 0; + const writeFunc = (data: Uint8Array) => { + cursor += data.byteLength; + return stream.write(data); + }; + + await stream.seek(0); + await writeScene(options.type, writeFunc); + await stream.truncate(cursor); + await stream.close(); } else if (options.filename) { - // download file to local machine - download(options.filename, data); + // (safari): concatenate data into single buffer + let cursor = 0; + let data = new Uint8Array(1024 * 1024); + + const writeFunc = (chunk: Uint8Array) => { + if (cursor + chunk.byteLength > data.byteLength) { + const newData = new Uint8Array(data.byteLength * 2); + newData.set(data); + data = newData; + } + data.set(chunk, cursor); + cursor += chunk.byteLength; + }; + await writeScene(options.type, writeFunc); + download(options.filename, new Uint8Array(data.buffer, 0, cursor)); } } catch (err) { events.invoke('showPopup', { @@ -372,7 +385,7 @@ const initFileHandler = async (scene: Scene, events: Events, dropTarget: HTMLEle message: `${err.message ?? err} while saving file` }); } finally { - stopSpinner(); + events.fire('stopSpinner'); } }); }; diff --git a/src/scene.ts b/src/scene.ts index 05164d7..36b92f4 100644 --- a/src/scene.ts +++ b/src/scene.ts @@ -175,7 +175,7 @@ class Scene { layers.push(this.gizmoLayer); this.dataProcessor = new DataProcessor(this.app.graphicsDevice); - this.assetLoader = new AssetLoader(graphicsDevice, this.app.assets, this.app.graphicsDevice.maxAnisotropy); + this.assetLoader = new AssetLoader(graphicsDevice, this.app.assets, events, this.app.graphicsDevice.maxAnisotropy); // create root entities this.contentRoot = new Entity('contentRoot'); diff --git a/src/shaders/splat-shader.ts b/src/shaders/splat-shader.ts index be89f72..3a9ca1c 100644 --- a/src/shaders/splat-shader.ts +++ b/src/shaders/splat-shader.ts @@ -87,7 +87,7 @@ void main(void) texCoord = vertex_position.xy * 0.5; - #if FORWARD_PASS + #ifdef FORWARD_PASS // get color color = texelFetch(splatColor, splatUV, 0); diff --git a/src/splat-convert.ts b/src/splat-serialize.ts similarity index 94% rename from src/splat-convert.ts rename to src/splat-serialize.ts index d69ece8..d272724 100644 --- a/src/splat-convert.ts +++ b/src/splat-serialize.ts @@ -10,6 +10,9 @@ import { Splat } from './splat'; import { SHRotation } from './sh-utils'; import { version } from '../package.json'; +// async function for writing data +type WriteFunc = (data: Uint8Array) => void; + const generatedByString = `Generated by SuperSplat ${version}`; const countTotalSplats = (splats: Splat[]) => { @@ -45,17 +48,18 @@ class SplatTransformCache { getSHRot: (index: number) => SHRotation; constructor(splat: Splat) { - const transforms = new Map(); + const transforms = new Map(); const indices = splat.transformTexture.getSource() as unknown as Uint32Array; const tmpMat = new Mat4(); const tmpMat3 = new Mat3(); const tmpQuat = new Quat(); const getTransform = (index: number) => { - let result = transforms.get(index); + const transformIndex = indices?.[index] ?? 0; + let result = transforms.get(transformIndex); if (!result) { - result = { mat: null, rot: null, scale: null, shRot: null }; - transforms.set(index, result); + result = { transformIndex, mat: null, rot: null, scale: null, shRot: null }; + transforms.set(transformIndex, result); } return result; }; @@ -71,9 +75,8 @@ class SplatTransformCache { mat.mul2(mat, splat.entity.getWorldTransform()); // combine with transform palette matrix - const transformIndex = indices?.[index] ?? 0; - if (transformIndex > 0) { - splat.transformPalette.getTransform(transformIndex, tmpMat); + if (transform.transformIndex > 0) { + splat.transformPalette.getTransform(transform.transformIndex, tmpMat); mat.mul2(mat, tmpMat); } @@ -122,7 +125,7 @@ class SplatTransformCache { const v = new Vec3(); const q = new Quat(); -const convertPly = (splats: Splat[]) => { +const serializePly = async (splats: Splat[], write: WriteFunc) => { // count the number of non-deleted splats const totalSplats = countTotalSplats(splats); @@ -150,9 +153,8 @@ const convertPly = (splats: Splat[]) => { `` ].flat().join('\n'); - const header = (new TextEncoder()).encode(headerText); - const result = new Uint8Array(header.byteLength + totalSplats * propNames.length * 4); - const dataView = new DataView(result.buffer); + // write encoded header + await write((new TextEncoder()).encode(headerText)); // construct an object for holding a single splat's properties const splat = propNames.reduce((acc: any, name) => { @@ -160,9 +162,9 @@ const convertPly = (splats: Splat[]) => { return acc; }, {}); - result.set(header); - - let offset = header.byteLength; + const buf = new Uint8Array(1024 * propNames.length * 4); + const dataView = new DataView(buf.buffer); + let offset = 0; for (let e = 0; e < splats.length; ++e) { const splatData = splats[e].splatData; @@ -231,10 +233,19 @@ const convertPly = (splats: Splat[]) => { dataView.setFloat32(offset, splat[propNames[j]], true); offset += 4; } + + // buffer is full, write it to the output stream + if (offset === buf.byteLength) { + await write(buf); + offset = 0; + } } } - return result; + // write the last (most likely partially filled) buf + if (offset > 0) { + await write(new Uint8Array(buf.buffer, 0, offset)); + } }; interface CompressedIndex { @@ -517,7 +528,7 @@ const sortSplats = (splats: Splat[], indices: CompressedIndex[]) => { indices.sort((a, b) => morton[a.globalIndex] - morton[b.globalIndex]); }; -const convertPlyCompressed = (splats: Splat[]) => { +const serializePlyCompressed = async (splats: Splat[], write: WriteFunc) => { const chunkProps = [ 'min_x', 'min_y', 'min_z', 'max_x', 'max_y', 'max_z', @@ -634,10 +645,10 @@ const convertPlyCompressed = (splats: Splat[]) => { } } - return result; + await write(result); }; -const convertSplat = (splats: Splat[]) => { +const serializeSplat = async (splats: Splat[], write: WriteFunc) => { const totalSplats = countTotalSplats(splats); // position.xyz: float32, scale.xyz: float32, color.rgba: uint8, quaternion.ijkl: uint8 @@ -706,7 +717,12 @@ const convertSplat = (splats: Splat[]) => { } } - return result; + await write(result); }; -export { convertPly, convertPlyCompressed, convertSplat }; +export { + WriteFunc, + serializePly, + serializePlyCompressed, + serializeSplat +}; diff --git a/src/ui/editor.ts b/src/ui/editor.ts index 1ceb16f..34f0b7f 100644 --- a/src/ui/editor.ts +++ b/src/ui/editor.ts @@ -12,8 +12,9 @@ import { RightToolbar } from './right-toolbar'; import { ModeToggle } from './mode-toggle'; import { Tooltips } from './tooltips'; import { ShortcutsPopup } from './shortcuts-popup'; -import { localizeInit } from './localization'; +import { Spinner } from './spinner'; +import { localizeInit } from './localization'; import { version } from '../../package.json'; import logo from './playcanvas-logo.png'; @@ -153,6 +154,20 @@ class EditorUI { return this.popup.show(options); }); + // spinner + + const spinner = new Spinner(); + + topContainer.append(spinner); + + events.on('startSpinner', () => { + spinner.hidden = false; + }); + + events.on('stopSpinner', () => { + spinner.hidden = true; + }); + // initialize canvas to correct size before creating graphics device etc const pixelRatio = window.devicePixelRatio; canvas.width = Math.ceil(canvasContainer.dom.offsetWidth * pixelRatio); diff --git a/src/ui/spinner.scss b/src/ui/spinner.scss new file mode 100644 index 0000000..9f697d9 --- /dev/null +++ b/src/ui/spinner.scss @@ -0,0 +1,50 @@ +#spinner-container { + left: 0; + top: 0; + width: 100%; + height: 100%; + pointer-events: all; + cursor: progress; +} + +.spinner::before, +.spinner::after { + border: 2px solid; + border-left: none; + box-sizing: border-box; + content: ''; + display: block; + position: absolute; + top: 50%; + left: 50%; + transform: translateY(-50%); + transform-origin: 0% 50%; + animation: spinner-spin 1s linear 0s infinite; + border-width: 3px; + border-color: #aaa; +} + +.spinner::before { + width: 15px; + height: 30px; + border-radius: 0 30px 30px 0; +} + +.spinner::after { + width: 8px; + height: 16px; + border-radius: 0 16px 16px 0; + animation-direction: reverse; +} + +@keyframes spinner-spin { + 0% { + -webkit-transform: translateY(-50%) rotate(0deg); + transform: translateY(-50%) rotate(0deg); + } + + 100% { + -webkit-transform: translateY(-50%) rotate(360deg); + transform: translateY(-50%) rotate(360deg); + } +} \ No newline at end of file diff --git a/src/ui/spinner.ts b/src/ui/spinner.ts index 28c4b19..30f28e8 100644 --- a/src/ui/spinner.ts +++ b/src/ui/spinner.ts @@ -1,84 +1,29 @@ -const css = ` -.static-spinner::before, -.static-spinner::after { - border: 2px solid; - border-left: none; - box-sizing: border-box; - content: ''; - display: block; - position: absolute; - top: 50%; - left: 50%; - -webkit-transform: translateY(-50%); - transform: translateY(-50%); - -webkit-transform-origin: 0% 50%; - transform-origin: 0% 50%; - -webkit-animation: spinner-spin 1s linear 0s infinite; - animation: spinner-spin 1s linear 0s infinite; - border-width: 3px; - border-color: #aaa; -} +import { Container, Element } from 'pcui'; -.static-spinner::before { - width: 15px; - height: 30px; - border-radius: 0 30px 30px 0; -} +class Spinner extends Container { + constructor(args = {}) { + args = { + ...args, + id: 'spinner-container' + }; -.static-spinner::after { - width: 8px; - height: 16px; - border-radius: 0 16px 16px 0; - animation-direction: reverse; -} + super(args); -@-webkit-keyframes spinner-spin { - 0% { - -webkit-transform: translateY(-50%) rotate(0deg); - transform: translateY(-50%) rotate(0deg); - } + this.dom.tabIndex = 0; - 100% { - -webkit-transform: translateY(-50%) rotate(360deg); - transform: translateY(-50%) rotate(360deg); - } -} + const spinner = new Element({ + dom: 'div', + class: 'spinner' + }); -@keyframes spinner-spin { - 0% { - -webkit-transform: translateY(-50%) rotate(0deg); - transform: translateY(-50%) rotate(0deg); - } + this.append(spinner); - 100% { - -webkit-transform: translateY(-50%) rotate(360deg); - transform: translateY(-50%) rotate(360deg); + this.dom.addEventListener('keydown', (event) => { + if (this.hidden) return; + event.stopPropagation(); + event.preventDefault(); + }); } } -`; - -let container: HTMLElement; - -const startSpinner = () => { - if (!container) { - const style = document.createElement('style'); - style.innerText = css; - container = document.createElement('div'); - container.appendChild(style); - const spinner = document.createElement('div'); - spinner.className = 'static-spinner'; - container.appendChild(spinner); - } - document.body.appendChild(container); -}; - -const stopSpinner = () => { - if (container) { - document.body.removeChild(container); - } -}; -export { - startSpinner, - stopSpinner -}; +export { Spinner }; diff --git a/src/ui/style.scss b/src/ui/style.scss index d849433..1e7bad6 100644 --- a/src/ui/style.scss +++ b/src/ui/style.scss @@ -22,6 +22,7 @@ $bcg-darkest: #181818; @import 'data-panel.scss'; @import 'popup.scss'; @import 'mode-toggle.scss'; +@import 'spinner.scss'; * { font-size: 12px;