import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; const styles = css` :host { background: #0f172a; border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); min-width: 360px; min-height: 400px; } .header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: linear-gradient(135deg, #7c3aed, #2563eb); color: white; border-radius: 8px 8px 0 0; font-size: 12px; font-weight: 600; cursor: move; } .header-title { display: flex; align-items: center; gap: 6px; } .header-actions { display: flex; gap: 4px; } .header-actions button { background: transparent; border: none; color: white; cursor: pointer; padding: 2px 6px; border-radius: 4px; font-size: 14px; } .header-actions button:hover { background: rgba(255, 255, 255, 0.2); } .content { display: flex; flex-direction: column; height: calc(100% - 36px); overflow: hidden; } .controls { padding: 10px 12px; border-bottom: 1px solid #1e293b; display: flex; gap: 8px; align-items: center; } .splat-url-input { flex: 1; padding: 8px 10px; border: 2px solid #334155; border-radius: 6px; background: #1e293b; color: #e2e8f0; font-size: 12px; outline: none; } .splat-url-input:focus { border-color: #7c3aed; } .load-btn { padding: 8px 14px; background: linear-gradient(135deg, #7c3aed, #2563eb); color: white; border: none; border-radius: 6px; font-size: 12px; font-weight: 600; cursor: pointer; } .load-btn:hover { opacity: 0.9; } .viewer-area { flex: 1; position: relative; background: #0a0a0a; overflow: hidden; } .viewer-area canvas { width: 100%; height: 100%; display: block; } .placeholder { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #64748b; text-align: center; gap: 8px; } .placeholder-icon { font-size: 48px; opacity: 0.5; } .gallery-list { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; padding: 10px; overflow-y: auto; max-height: 200px; } .gallery-item { padding: 8px; background: #1e293b; border-radius: 6px; cursor: pointer; color: #cbd5e1; font-size: 11px; text-align: center; border: 2px solid transparent; transition: border-color 0.2s; } .gallery-item:hover { border-color: #7c3aed; } .gallery-item .title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .gallery-item .meta { font-size: 10px; color: #64748b; margin-top: 2px; } .loading { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 24px; gap: 12px; color: #94a3b8; } .spinner { width: 32px; height: 32px; border: 3px solid #334155; border-top-color: #7c3aed; border-radius: 50%; animation: spin 1s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } .error { color: #ef4444; padding: 12px; background: #1c1017; border-radius: 6px; font-size: 13px; margin: 10px; } .upload-btn { padding: 8px 14px; background: #334155; color: #e2e8f0; border: none; border-radius: 6px; font-size: 12px; cursor: pointer; } .upload-btn:hover { background: #475569; } `; declare global { interface HTMLElementTagNameMap { "folk-splat": FolkSplat; } } export class FolkSplat extends FolkShape { static override tagName = "folk-splat"; static { const sheet = new CSSStyleSheet(); const parentRules = Array.from(FolkShape.styles.cssRules) .map((r) => r.cssText) .join("\n"); const childRules = Array.from(styles.cssRules) .map((r) => r.cssText) .join("\n"); sheet.replaceSync(`${parentRules}\n${childRules}`); this.styles = sheet; } #splatUrl = ""; #isLoading = false; #error: string | null = null; #viewer: any = null; #viewerCanvas: HTMLCanvasElement | null = null; #gallerySplats: any[] = []; #urlInput: HTMLInputElement | null = null; get splatUrl() { return this.#splatUrl; } set splatUrl(v: string) { this.#splatUrl = v; } override createRenderRoot() { const root = super.createRenderRoot(); const wrapper = document.createElement("div"); wrapper.innerHTML = html`
🔮 3D Splat
🔮 Enter a splat URL or browse the gallery
`; const slot = root.querySelector("slot"); const containerDiv = slot?.parentElement as HTMLElement; if (containerDiv) { containerDiv.replaceWith(wrapper); } this.#urlInput = wrapper.querySelector(".splat-url-input"); const loadBtn = wrapper.querySelector(".load-btn") as HTMLButtonElement; const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement; const galleryBtn = wrapper.querySelector(".gallery-btn") as HTMLButtonElement; const galleryList = wrapper.querySelector(".gallery-list") as HTMLElement; const viewerArea = wrapper.querySelector(".viewer-area") as HTMLElement; loadBtn.addEventListener("click", (e) => { e.stopPropagation(); const url = this.#urlInput?.value.trim(); if (url) this.#loadSplat(url, viewerArea); }); this.#urlInput?.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); const url = this.#urlInput?.value.trim(); if (url) this.#loadSplat(url, viewerArea); } }); this.#urlInput?.addEventListener("pointerdown", (e) => e.stopPropagation()); galleryBtn.addEventListener("click", (e) => { e.stopPropagation(); const isVisible = galleryList.style.display !== "none"; galleryList.style.display = isVisible ? "none" : "grid"; if (!isVisible && this.#gallerySplats.length === 0) { this.#loadGallery(galleryList, viewerArea); } }); closeBtn.addEventListener("click", (e) => { e.stopPropagation(); this.dispatchEvent(new CustomEvent("close")); }); // If splatUrl was set before render if (this.#splatUrl) { this.#loadSplat(this.#splatUrl, viewerArea); } return root; } async #loadGallery(container: HTMLElement, viewerArea: HTMLElement) { container.innerHTML = '
Loading gallery...
'; try { const spaceSlug = this.#getSpaceSlug(); const res = await fetch(`/${spaceSlug}/rsplat/api/splats?limit=20`); if (!res.ok) throw new Error("Failed to load splats"); const data = await res.json(); this.#gallerySplats = data.splats || []; if (this.#gallerySplats.length === 0) { container.innerHTML = '
No splats in this space yet
'; return; } container.innerHTML = this.#gallerySplats.map((s) => ` `).join(""); container.querySelectorAll(".gallery-item").forEach((item) => { item.addEventListener("click", (e) => { e.stopPropagation(); const slug = (item as HTMLElement).dataset.slug; const splat = this.#gallerySplats.find((s) => s.slug === slug); if (splat) { const spaceSlug = this.#getSpaceSlug(); const url = `/${spaceSlug}/rsplat/api/splats/${splat.slug}/${splat.slug}.${splat.file_format}`; if (this.#urlInput) this.#urlInput.value = url; container.style.display = "none"; this.#loadSplat(url, viewerArea); } }); }); } catch (err) { container.innerHTML = `
Failed to load gallery
`; } } async #loadSplat(url: string, viewerArea: HTMLElement) { this.#splatUrl = url; this.#isLoading = true; this.#error = null; viewerArea.innerHTML = '
Loading 3D splat...
'; try { // Use Three.js + GaussianSplats3D via CDN importmap (not bundled) const threeId = "three"; const gs3dId = "@mkkellogg/gaussian-splats-3d"; const THREE = await (Function("id", "return import(id)")(threeId) as Promise); const GS3D = await (Function("id", "return import(id)")(gs3dId) as Promise); viewerArea.innerHTML = ""; const canvas = document.createElement("canvas"); canvas.style.width = "100%"; canvas.style.height = "100%"; viewerArea.appendChild(canvas); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); renderer.setSize(viewerArea.clientWidth, viewerArea.clientHeight); const viewer = new GS3D.Viewer({ renderer, cameraUp: [0, -1, 0], initialCameraPosition: [0, 0, 5], initialCameraLookAt: [0, 0, 0], }); await viewer.addSplatScene(url); this.#viewer = viewer; this.#viewerCanvas = canvas; // Simple animation loop const animate = () => { if (!this.#viewer) return; (viewer as any).update(); (viewer as any).render(); requestAnimationFrame(animate); }; animate(); } catch (err) { // Fallback: show as iframe pointing to splat viewer page console.warn("[folk-splat] Three.js not available, using iframe fallback"); const spaceSlug = this.#getSpaceSlug(); const slug = url.split("/").filter(Boolean).pop()?.split(".")[0] || ""; viewerArea.innerHTML = ``; } finally { this.#isLoading = false; } } #getSpaceSlug(): string { const pathParts = window.location.pathname.split("/").filter(Boolean); return pathParts[0] || "demo"; } #escapeHtml(text: string): string { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } override toJSON() { return { ...super.toJSON(), type: "folk-splat", splatUrl: this.#splatUrl, }; } }