/** * — Gaussian splat gallery + 3D viewer web component. * * Gallery mode: card grid of splats with upload form. * Viewer mode: full-viewport Three.js + GaussianSplats3D renderer. * * Three.js and GaussianSplats3D are loaded via CDN importmap (not bundled). */ interface SplatItem { id: string; slug: string; title: string; description?: string; file_format: string; file_size_bytes: number; view_count: number; contributor_name?: string; created_at: string; } export class FolkSplatViewer extends HTMLElement { private _mode: "gallery" | "viewer" = "gallery"; private _splats: SplatItem[] = []; private _spaceSlug = "demo"; private _splatUrl = ""; private _splatTitle = ""; private _splatDesc = ""; private _viewer: any = null; static get observedAttributes() { return ["mode", "splat-url", "splat-title", "splat-desc", "space-slug"]; } set splats(val: SplatItem[]) { this._splats = val; if (this._mode === "gallery") this.renderGallery(); } set spaceSlug(val: string) { this._spaceSlug = val; } connectedCallback() { this._mode = (this.getAttribute("mode") as "gallery" | "viewer") || "gallery"; this._splatUrl = this.getAttribute("splat-url") || ""; this._splatTitle = this.getAttribute("splat-title") || ""; this._splatDesc = this.getAttribute("splat-desc") || ""; this._spaceSlug = this.getAttribute("space-slug") || "demo"; if (this._mode === "viewer") { this.renderViewer(); } else { this.renderGallery(); } } disconnectedCallback() { if (this._viewer) { try { this._viewer.dispose(); } catch {} this._viewer = null; } } attributeChangedCallback(name: string, _old: string, val: string) { if (name === "mode") this._mode = val as "gallery" | "viewer"; if (name === "splat-url") this._splatUrl = val; if (name === "splat-title") this._splatTitle = val; if (name === "splat-desc") this._splatDesc = val; if (name === "space-slug") this._spaceSlug = val; } // ── Gallery ── private renderGallery() { const cards = this._splats.map((s) => `
🔮
${esc(s.title)}
${s.file_format} ${formatSize(s.file_size_bytes)} ${s.view_count} views
`).join(""); const empty = this._splats.length === 0 ? `
🔮

No splats yet

Upload a .ply, .splat, or .spz file to get started

` : ""; this.innerHTML = ` `; this.setupUploadHandlers(); } private setupUploadHandlers() { const drop = this.querySelector("#splat-drop") as HTMLElement; const fileInput = this.querySelector("#splat-file") as HTMLInputElement; const browse = this.querySelector("#splat-browse") as HTMLElement; const form = this.querySelector("#splat-form") as HTMLElement; const titleInput = this.querySelector("#splat-title-input") as HTMLInputElement; const descInput = this.querySelector("#splat-desc-input") as HTMLTextAreaElement; const tagsInput = this.querySelector("#splat-tags-input") as HTMLInputElement; const submitBtn = this.querySelector("#splat-submit") as HTMLButtonElement; const status = this.querySelector("#splat-status") as HTMLElement; if (!drop || !fileInput) return; let selectedFile: File | null = null; browse?.addEventListener("click", () => fileInput.click()); fileInput.addEventListener("change", () => { if (fileInput.files?.[0]) { selectedFile = fileInput.files[0]; form.classList.add("active"); // Auto-populate title from filename const name = selectedFile.name.replace(/\.(ply|splat|spz)$/i, ""); titleInput.value = name.replace(/[-_]/g, " "); titleInput.dispatchEvent(new Event("input")); } }); drop.addEventListener("dragover", (e) => { e.preventDefault(); drop.classList.add("splat-upload--dragover"); }); drop.addEventListener("dragleave", () => { drop.classList.remove("splat-upload--dragover"); }); drop.addEventListener("drop", (e) => { e.preventDefault(); drop.classList.remove("splat-upload--dragover"); const file = e.dataTransfer?.files[0]; if (file && /\.(ply|splat|spz)$/i.test(file.name)) { selectedFile = file; form.classList.add("active"); const name = file.name.replace(/\.(ply|splat|spz)$/i, ""); titleInput.value = name.replace(/[-_]/g, " "); titleInput.dispatchEvent(new Event("input")); } }); titleInput?.addEventListener("input", () => { submitBtn.disabled = !titleInput.value.trim() || !selectedFile; }); submitBtn?.addEventListener("click", async () => { if (!selectedFile || !titleInput.value.trim()) return; submitBtn.disabled = true; status.textContent = "Uploading..."; const formData = new FormData(); formData.append("file", selectedFile); formData.append("title", titleInput.value.trim()); formData.append("description", descInput.value.trim()); formData.append("tags", tagsInput.value.trim()); try { const token = localStorage.getItem("encryptid_token") || ""; const res = await fetch(`/${this._spaceSlug}/splat/api/splats`, { method: "POST", headers: token ? { Authorization: `Bearer ${token}` } : {}, body: formData, }); if (res.status === 402) { status.textContent = "Payment required for upload (x402)"; submitBtn.disabled = false; return; } if (res.status === 401) { status.textContent = "Sign in with rStack Identity to upload"; submitBtn.disabled = false; return; } if (!res.ok) { const err = await res.json().catch(() => ({ error: "Upload failed" })); status.textContent = (err as any).error || "Upload failed"; submitBtn.disabled = false; return; } const splat = await res.json() as SplatItem; status.textContent = "Uploaded!"; // Navigate to viewer setTimeout(() => { window.location.href = `/${this._spaceSlug}/splat/view/${splat.slug}`; }, 500); } catch (e) { status.textContent = "Network error"; submitBtn.disabled = false; } }); } // ── Viewer ── private renderViewer() { this.innerHTML = `
Loading splat...
${this._splatTitle ? `

${esc(this._splatTitle)}

${this._splatDesc ? `

${esc(this._splatDesc)}

` : ""}
` : ""}
`; this.initThreeViewer(); } private async initThreeViewer() { const container = this.querySelector("#splat-container") as HTMLElement; const loading = this.querySelector("#splat-loading") as HTMLElement; if (!container || !this._splatUrl) return; try { // Dynamic import from CDN (via importmap) const THREE = await import("three"); const GaussianSplats3D = await import("@mkkellogg/gaussian-splats-3d"); const viewer = new GaussianSplats3D.Viewer({ cameraUp: [0, -1, 0], initialCameraPosition: [1, 0.5, 1], initialCameraLookAt: [0, 0, 0], rootElement: container, sharedMemoryForWorkers: false, }); this._viewer = viewer; await viewer.addSplatScene(this._splatUrl, { showLoadingUI: false, progressiveLoad: true, }); viewer.start(); if (loading) { loading.classList.add("hidden"); } } catch (e) { console.error("[rSplat] Viewer init error:", e); if (loading) { const text = loading.querySelector(".splat-loading__text"); if (text) text.textContent = `Error loading splat: ${(e as Error).message}`; const spinner = loading.querySelector(".splat-loading__spinner") as HTMLElement; if (spinner) spinner.style.display = "none"; } } } } // ── Helpers ── function esc(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } customElements.define("folk-splat-viewer", FolkSplatViewer);