/** * — Gaussian splat gallery + 3D viewer web component. * * Gallery mode: card grid of splats with upload form + AI generation. * Viewer mode: full-viewport Three.js + GaussianSplats3D renderer. * * Three.js and GaussianSplats3D are loaded via CDN importmap (not bundled). */ import { splatScenesSchema, splatScenesDocId, type SplatScenesDoc } from "../schemas"; import type { DocumentId } from "../../../shared/local-first/document"; interface SplatItem { id: string; slug: string; title: string; description?: string; file_format: string; file_size_bytes: number; view_count: number; contributor_name?: string; processing_status?: string; source_file_count?: number; created_at: string; demoUrl?: 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; private _uploadMode: "splat" | "generate" = "splat"; private _inlineViewer = false; private _offlineUnsub: (() => void) | null = null; private _generatedUrl = ""; private _generatedTitle = ""; private _savedSlug = ""; private _myHistory: SplatItem[] = []; 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 { if (this._spaceSlug === "demo") { this.loadDemoData(); } else { this.subscribeOffline(); } this.loadMyHistory(); this.renderGallery(); } } private async subscribeOffline() { const runtime = (window as any).__rspaceOfflineRuntime; if (!runtime?.isInitialized) return; try { const docId = splatScenesDocId(this._spaceSlug) as DocumentId; const doc = await runtime.subscribe(docId, splatScenesSchema); this.renderFromDoc(doc); this._offlineUnsub = runtime.onChange(docId, (updated: any) => { this.renderFromDoc(updated); }); } catch { // Runtime unavailable — server-side hydration handles data } } private renderFromDoc(doc: SplatScenesDoc) { if (!doc?.items || Object.keys(doc.items).length === 0) return; if (this._splats.length > 0) return; // Don't clobber server-hydrated data this._splats = Object.values(doc.items).map(s => ({ id: s.id, slug: s.slug, title: s.title, description: s.description, file_format: s.fileFormat, file_size_bytes: s.fileSizeBytes, view_count: s.viewCount, contributor_name: s.contributorName ?? undefined, processing_status: s.processingStatus ?? undefined, source_file_count: s.sourceFileCount, created_at: new Date(s.createdAt).toISOString(), })); if (this._mode === "gallery") this.renderGallery(); } private loadDemoData() { this._splats = [ { id: "s1", slug: "train", title: "Train", description: "Classic Gaussian splatting demo scene — a model train on a track.", file_format: "splat", file_size_bytes: 6_291_456, view_count: 1842, contributor_name: "MipNeRF 360", processing_status: "ready", created_at: "2025-06-15", demoUrl: "https://huggingface.co/cakewalk/splat-data/resolve/main/train.splat" }, { id: "s2", slug: "truck", title: "Truck", description: "Photogrammetry capture of a pickup truck — 360° Gaussian splat reconstruction.", file_format: "splat", file_size_bytes: 6_291_456, view_count: 1536, contributor_name: "MipNeRF 360", processing_status: "ready", created_at: "2025-06-15", demoUrl: "https://huggingface.co/cakewalk/splat-data/resolve/main/truck.splat" }, { id: "s3", slug: "garden", title: "Garden", description: "Outdoor garden scene — dense foliage and complex lighting captured as Gaussian splats.", file_format: "splat", file_size_bytes: 6_291_456, view_count: 1203, contributor_name: "MipNeRF 360", processing_status: "ready", created_at: "2025-06-15", demoUrl: "https://huggingface.co/cakewalk/splat-data/resolve/main/garden.splat" }, ]; } private async loadMyHistory() { const token = this.getAuthToken(); if (!token || this._spaceSlug === "demo") return; try { const res = await fetch(`/${this._spaceSlug}/rsplat/api/splats/my-history`, { headers: { Authorization: `Bearer ${token}` }, }); if (res.ok) { const data = await res.json(); this._myHistory = data.splats || []; if (this._mode === "gallery") this.renderGallery(); } } catch { /* non-critical */ } } disconnectedCallback() { this._offlineUnsub?.(); this._offlineUnsub = null; if (this._viewer) { try { this._viewer.dispose(); } catch {} this._viewer = null; } if ((this as any)._glbCleanup) { try { (this as any)._glbCleanup(); } catch {} (this as any)._glbCleanup = 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 renderCard(s: SplatItem): string { const status = s.processing_status || "ready"; const isReady = status === "ready"; const isDemo = !!s.demoUrl; const tag = isReady ? (isDemo ? "div" : "a") : "div"; const href = isReady && !isDemo ? ` href="/${this._spaceSlug}/rsplat/${s.slug}"` : ""; const demoAttr = isDemo ? ` data-demo-url="${esc(s.demoUrl!)}" data-demo-title="${esc(s.title)}" data-demo-desc="${esc(s.description || "")}" role="button" tabindex="0"` : ""; const statusClass = !isReady ? ` splat-card--${status}` : ""; const demoClass = isDemo ? " splat-card--demo" : ""; let overlay = ""; if (status === "pending") { overlay = `
Queued
`; } else if (status === "processing") { overlay = `
Generating...
`; } else if (status === "failed") { overlay = `
Failed
`; } const sourceInfo = !isReady && s.source_file_count ? `${s.source_file_count} source file${s.source_file_count > 1 ? "s" : ""}` : `${s.view_count} views`; return ` <${tag} class="splat-card${statusClass}${demoClass}" data-collab-id="splat:${s.id}"${href}${demoAttr}>
${overlay} ${isReady ? "🔮" : "📸"}
${esc(s.title)}
${s.file_format} ${isReady ? `${formatSize(s.file_size_bytes)}` : ""} ${sourceInfo}
`; } private renderGallery() { const cards = this._splats.map((s) => this.renderCard(s)).join(""); // My Models section const myModelsHtml = this._myHistory.length > 0 ? `

My Models

${this._myHistory.map((s) => this.renderCard(s)).join("")}
` : ""; const empty = this._splats.length === 0 && this._myHistory.length === 0 ? `
🔮

No splats yet

Upload a .ply, .splat, or .spz file — or photos/video to generate one

` : ""; this.innerHTML = ` `; this.setupUploadHandlers(); this.setupGenerateHandlers(); this.setupToggle(); this.setupDemoCardHandlers(); } private setupToggle() { const buttons = this.querySelectorAll(".splat-upload__toggle-btn"); const splatMode = this.querySelector("#splat-mode-splat") as HTMLElement; const generateMode = this.querySelector("#splat-mode-generate") as HTMLElement; buttons.forEach((btn) => { btn.addEventListener("click", () => { const mode = btn.dataset.mode as "splat" | "generate"; this._uploadMode = mode; buttons.forEach((b) => b.classList.toggle("active", b.dataset.mode === mode)); splatMode.style.display = mode === "splat" ? "" : "none"; if (generateMode) generateMode.style.display = mode === "generate" ? "" : "none"; }); }); } 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"); 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 (this._uploadMode === "splat" && 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 = this.getAuthToken(); const res = await fetch(`/${this._spaceSlug}/rsplat/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!"; setTimeout(() => { window.location.href = `/${this._spaceSlug}/rsplat/${splat.slug}`; }, 500); } catch (e) { status.textContent = "Network error"; submitBtn.disabled = false; } }); } private setupDemoCardHandlers() { this.querySelectorAll(".splat-card--demo").forEach((card) => { card.style.cursor = "pointer"; card.addEventListener("click", (e) => { e.preventDefault(); const url = card.dataset.demoUrl; const title = card.dataset.demoTitle || ""; const desc = card.dataset.demoDesc || ""; if (!url) return; this._mode = "viewer"; this._splatUrl = url; this._splatTitle = title; this._splatDesc = desc; this._inlineViewer = true; this.renderViewer(); }); }); } // ── Image staging (replaces canvas-based resizeImage) ── private async stageImage(file: File): Promise { // Client-side HEIC detection const ext = file.name.split(".").pop()?.toLowerCase() || ""; if (ext === "heic" || ext === "heif" || file.type === "image/heic" || file.type === "image/heif") { throw new Error("HEIC files are not supported. Please convert to JPEG or PNG first."); } const formData = new FormData(); formData.append("file", file); const res = await fetch("/api/image-stage", { method: "POST", body: formData, }); if (!res.ok) { const err = await res.json().catch(() => ({ error: "Image upload failed" })); throw new Error((err as any).error || "Image upload failed"); } const data = await res.json() as { url: string }; return data.url; } private setupGenerateHandlers() { const browse = this.querySelector("#generate-browse") as HTMLElement; const fileInput = this.querySelector("#generate-file") as HTMLInputElement; const preview = this.querySelector("#generate-preview") as HTMLElement; const actions = this.querySelector("#generate-actions") as HTMLElement; const submitBtn = this.querySelector("#generate-submit") as HTMLButtonElement; const progress = this.querySelector("#generate-progress") as HTMLElement; const progressText = this.querySelector("#generate-progress-text") as HTMLElement; const status = this.querySelector("#generate-status") as HTMLElement; if (!fileInput) return; let selectedFile: File | null = null; browse?.addEventListener("click", () => fileInput.click()); fileInput.addEventListener("change", () => { if (fileInput.files?.[0]) { selectedFile = fileInput.files[0]; // Client-side HEIC check before preview const ext = selectedFile.name.split(".").pop()?.toLowerCase() || ""; if (ext === "heic" || ext === "heif") { status.textContent = "HEIC files are not supported. Please use JPEG or PNG."; return; } const reader = new FileReader(); reader.onload = () => { preview.innerHTML = `Preview`; preview.style.display = "block"; actions.style.display = "flex"; status.textContent = ""; }; reader.readAsDataURL(selectedFile); } }); submitBtn?.addEventListener("click", async () => { if (!selectedFile) return; submitBtn.disabled = true; actions.style.display = "none"; progress.style.display = "block"; // Elapsed time ticker — resilient to iOS background-tab suspension const startTime = Date.now(); let hiddenTime = 0; let hiddenAt = 0; const phases = [ { t: 0, msg: "Staging image..." }, { t: 2, msg: "Uploading to SAM 3D..." }, { t: 5, msg: "Segmenting scene..." }, { t: 10, msg: "Generating Gaussian splats..." }, { t: 18, msg: "Finalizing model..." }, { t: 30, msg: "Almost there..." }, ]; // Realistic progress curve — typical SAM 3D takes 5-30s const EXPECTED_SECONDS = 20; const progressBar = progress.querySelector(".splat-generate__progress-bar") as HTMLElement; const estimatePercent = (elapsed: number): number => { if (elapsed <= 0) return 0; // Logarithmic curve: fast start, slows toward 95% asymptote const ratio = elapsed / EXPECTED_SECONDS; return Math.min(95, 100 * (1 - Math.exp(-2.5 * ratio))); }; const onVisChange = () => { if (document.hidden) { hiddenAt = Date.now(); } else if (hiddenAt) { hiddenTime += Date.now() - hiddenAt; hiddenAt = 0; } }; document.addEventListener("visibilitychange", onVisChange); const ticker = setInterval(() => { if (document.hidden) return; // Don't update while hidden const elapsed = Math.floor((Date.now() - startTime - hiddenTime) / 1000); const phase = [...phases].reverse().find(p => elapsed >= p.t); const pct = Math.round(estimatePercent(elapsed)); if (progressBar) progressBar.style.setProperty("--splat-progress", `${pct}%`); if (progressText && phase) { progressText.textContent = `${phase.msg} ${pct}% (${elapsed}s)`; } }, 500); try { const imageUrl = await this.stageImage(selectedFile!); const res = await fetch("/api/3d-gen", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ image_url: imageUrl }), }); clearInterval(ticker); document.removeEventListener("visibilitychange", onVisChange); if (res.status === 524 || res.status === 504) { status.textContent = "Generation timed out — try a simpler image."; progress.style.display = "none"; actions.style.display = "flex"; submitBtn.disabled = false; return; } if (res.status === 503) { status.textContent = "AI generation not available — FAL_KEY not configured"; progress.style.display = "none"; actions.style.display = "flex"; submitBtn.disabled = false; return; } if (!res.ok) { const err = await res.json().catch(() => ({ error: "Generation failed" })); status.textContent = (err as any).error || "Generation failed"; progress.style.display = "none"; actions.style.display = "flex"; submitBtn.disabled = false; return; } const data = await res.json() as { url: string; format: string }; // Jump to 100% before hiding if (progressBar) progressBar.style.setProperty("--splat-progress", "100%"); if (progressText) progressText.textContent = "Complete!"; await new Promise(r => setTimeout(r, 400)); progress.style.display = "none"; const elapsed = Math.floor((Date.now() - startTime - hiddenTime) / 1000); status.textContent = `Generated in ${elapsed}s`; // Store generated info for save-to-gallery this._generatedUrl = data.url; this._generatedTitle = selectedFile.name.replace(/\.[^.]+$/, ""); // Auto-save if authenticated await this.autoSave(); // Open inline viewer with generated model this._mode = "viewer"; this._splatUrl = data.url; this._splatTitle = this._generatedTitle; this._splatDesc = "AI-generated 3D model"; this._inlineViewer = true; this.renderViewer(); } catch (e: any) { clearInterval(ticker); document.removeEventListener("visibilitychange", onVisChange); if (e.name === "AbortError") { status.textContent = "Request timed out — try a simpler image."; } else { status.textContent = e.message || "Network error — could not reach server"; } progress.style.display = "none"; actions.style.display = "flex"; submitBtn.disabled = false; } }); } // ── Auto-save after generation ── private async autoSave() { const token = this.getAuthToken(); if (!token || !this._generatedUrl || this._spaceSlug === "demo") return; try { const res = await fetch(`/${this._spaceSlug}/rsplat/api/splats/save-generated`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify({ url: this._generatedUrl, title: this._generatedTitle || "AI Generated Model", description: "AI-generated 3D model via SAM 3D", }), }); if (res.ok) { const data = await res.json() as { slug: string }; this._savedSlug = data.slug; } } catch { /* auto-save is best-effort */ } } // ── Viewer ── private renderViewer() { const backEl = this._inlineViewer ? `` : `← Gallery`; // Show "View in Gallery" if auto-saved, otherwise "Save" if generated let actionEl = ""; if (this._savedSlug) { actionEl = `View in Gallery`; } else if (this._generatedUrl && this._inlineViewer && this._spaceSlug !== "demo") { actionEl = ``; } // Download button const downloadLabel = this._splatUrl.endsWith(".glb") ? "Download GLB" : this._splatUrl.endsWith(".ply") ? "Download PLY" : this._splatUrl.endsWith(".splat") ? "Download SPLAT" : this._splatUrl.endsWith(".spz") ? "Download SPZ" : "Download"; const downloadEl = ``; // Format info const formatExt = this._splatUrl.split(".").pop()?.toUpperCase() || ""; const formatInfo = formatExt === "GLB" ? "GLB format — opens in Blender, Windows 3D Viewer, Unity" : formatExt === "PLY" ? "PLY format — opens in MeshLab, CloudCompare, Blender" : formatExt === "SPLAT" ? "SPLAT format — Gaussian splat point cloud" : ""; this.innerHTML = `
Loading 3D model...
${backEl} ${actionEl} ${downloadEl}
${this._splatTitle ? `

${esc(this._splatTitle)}

${this._splatDesc ? `

${esc(this._splatDesc)}

` : ""} ${formatInfo ? `

${formatInfo}

` : ""}
` : ""}
`; if (this._inlineViewer) { this.querySelector("#splat-back-btn")?.addEventListener("click", () => { this.cleanupViewer(); this._mode = "gallery"; this._inlineViewer = false; this._splatUrl = ""; this._splatTitle = ""; this._splatDesc = ""; this._generatedUrl = ""; this._generatedTitle = ""; this._savedSlug = ""; if (this._spaceSlug === "demo") this.loadDemoData(); this.renderGallery(); }); } if (!this._savedSlug && this._generatedUrl && this._inlineViewer && this._spaceSlug !== "demo") { this.querySelector("#splat-save-btn")?.addEventListener("click", () => this.saveToGallery()); } // Download handler this.querySelector("#splat-download-btn")?.addEventListener("click", () => this.downloadModel()); this.initThreeViewer(); } private async downloadModel() { const btn = this.querySelector("#splat-download-btn") as HTMLButtonElement; if (!btn || !this._splatUrl) return; btn.disabled = true; btn.textContent = "Downloading..."; try { const res = await fetch(this._splatUrl); if (!res.ok) throw new Error("Download failed"); const blob = await res.blob(); const filename = this._splatUrl.split("/").pop() || "model.glb"; const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); btn.textContent = "Downloaded!"; setTimeout(() => { const ext = this._splatUrl.split(".").pop()?.toUpperCase() || ""; btn.textContent = `Download ${ext}`; btn.disabled = false; }, 2000); } catch { btn.textContent = "Download failed"; setTimeout(() => { const ext = this._splatUrl.split(".").pop()?.toUpperCase() || ""; btn.textContent = `Download ${ext}`; btn.disabled = false; }, 2000); } } private cleanupViewer() { if (this._viewer) { try { this._viewer.dispose(); } catch {} this._viewer = null; } if ((this as any)._glbCleanup) { try { (this as any)._glbCleanup(); } catch {} (this as any)._glbCleanup = null; } } private getAuthToken(): string { return localStorage.getItem("encryptid_token") || document.cookie.match(/encryptid_token=([^;]+)/)?.[1] || ""; } private async saveToGallery() { const saveBtn = this.querySelector("#splat-save-btn") as HTMLButtonElement; if (!saveBtn || !this._generatedUrl) return; saveBtn.disabled = true; saveBtn.textContent = "Saving..."; try { const token = this.getAuthToken(); if (!token) { saveBtn.textContent = "Sign in to save"; saveBtn.disabled = false; return; } const res = await fetch(`/${this._spaceSlug}/rsplat/api/splats/save-generated`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify({ url: this._generatedUrl, title: this._generatedTitle || "AI Generated Model", description: "AI-generated 3D model via SAM 3D", }), }); if (res.status === 401) { saveBtn.textContent = "Session expired — sign in again"; saveBtn.disabled = false; return; } if (!res.ok) { const err = await res.json().catch(() => ({ error: "Save failed" })); saveBtn.textContent = (err as any).error || "Save failed"; setTimeout(() => { saveBtn.textContent = "Save to Gallery"; saveBtn.disabled = false; }, 2000); return; } const data = await res.json() as { slug: string }; this._savedSlug = data.slug; saveBtn.textContent = "Saved!"; this._generatedUrl = ""; // Replace save button with view link setTimeout(() => { window.location.href = `/${this._spaceSlug}/rsplat/${data.slug}`; }, 800); } catch { saveBtn.textContent = "Network error"; setTimeout(() => { saveBtn.textContent = "Save to Gallery"; saveBtn.disabled = false; }, 2000); } } private async initThreeViewer() { const container = this.querySelector("#splat-container") as HTMLElement; const loading = this.querySelector("#splat-loading") as HTMLElement; if (!container || !this._splatUrl) return; const isGlb = this._splatUrl.endsWith(".glb") || this._splatUrl.endsWith(".gltf"); try { if (isGlb) { await this.initGlbViewer(container, loading); } else { await this.initSplatViewer(container, loading); } } catch (e) { console.error("[rSplat] Viewer init error:", e); if (loading) { const text = loading.querySelector(".splat-loading__text"); if (text) text.textContent = `Error loading viewer: ${(e as Error).message}`; const spinner = loading.querySelector(".splat-loading__spinner") as HTMLElement; if (spinner) spinner.style.display = "none"; } } } private async initSplatViewer(container: HTMLElement, loading: HTMLElement | null) { const GaussianSplats3D = await import("@mkkellogg/gaussian-splats-3d"); const viewer = new GaussianSplats3D.Viewer({ cameraUp: [0, 1, 0], initialCameraPosition: [5, 3, 5], initialCameraLookAt: [0, 0, 0], rootElement: container, sharedMemoryForWorkers: false, }); this._viewer = viewer; viewer.addSplatScene(this._splatUrl!, { showLoadingUI: false, progressiveLoad: true, }) .then(() => { viewer.start(); if (loading) loading.classList.add("hidden"); }) .catch((e: Error) => { console.error("[rSplat] Scene load error:", e); if (loading) { const text = loading.querySelector(".splat-loading__text"); if (text) text.textContent = `Error: ${e.message}`; const spinner = loading.querySelector(".splat-loading__spinner") as HTMLElement; if (spinner) spinner.style.display = "none"; } }); } private async initGlbViewer(container: HTMLElement, loading: HTMLElement | null) { const THREE = await import("three"); const { OrbitControls } = await import("three/addons/controls/OrbitControls.js"); const { GLTFLoader } = await import("three/addons/loaders/GLTFLoader.js"); const w = container.clientWidth || 800; const h = container.clientHeight || 600; const scene = new THREE.Scene(); scene.background = new THREE.Color(0x0f0f14); const camera = new THREE.PerspectiveCamera(50, w / h, 0.01, 1000); camera.position.set(3, 2, 3); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(w, h); renderer.setPixelRatio(window.devicePixelRatio); renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.2; container.appendChild(renderer.domElement); const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.08; controls.target.set(0, 0, 0); // Lighting const ambient = new THREE.AmbientLight(0xffffff, 0.6); scene.add(ambient); const dirLight = new THREE.DirectionalLight(0xffffff, 1.2); dirLight.position.set(5, 10, 7); scene.add(dirLight); const fillLight = new THREE.DirectionalLight(0x8888ff, 0.4); fillLight.position.set(-5, 3, -5); scene.add(fillLight); // Load GLB const loader = new GLTFLoader(); loader.load( this._splatUrl!, (gltf: any) => { const model = gltf.scene; // Auto-center and scale const box = new THREE.Box3().setFromObject(model); const center = box.getCenter(new THREE.Vector3()); const size = box.getSize(new THREE.Vector3()); const maxDim = Math.max(size.x, size.y, size.z); const scale = 2 / maxDim; model.scale.setScalar(scale); model.position.sub(center.multiplyScalar(scale)); scene.add(model); controls.target.set(0, 0, 0); controls.update(); if (loading) loading.classList.add("hidden"); }, undefined, (err: any) => { console.error("[rSplat] GLB load error:", err); if (loading) { const text = loading.querySelector(".splat-loading__text"); if (text) text.textContent = `Error loading GLB: ${err.message || err}`; const spinner = loading.querySelector(".splat-loading__spinner") as HTMLElement; if (spinner) spinner.style.display = "none"; } } ); // Animation loop let animId: number; const animate = () => { animId = requestAnimationFrame(animate); controls.update(); renderer.render(scene, camera); }; animate(); // Handle resize const onResize = () => { const rw = container.clientWidth || 800; const rh = container.clientHeight || 600; camera.aspect = rw / rh; camera.updateProjectionMatrix(); renderer.setSize(rw, rh); }; window.addEventListener("resize", onResize); // Store cleanup reference (this as any)._glbCleanup = () => { cancelAnimationFrame(animId); window.removeEventListener("resize", onResize); controls.dispose(); renderer.dispose(); }; } } // ── 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);