rspace-online/lib/folk-splat.ts

439 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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`
<div class="header">
<span class="header-title">
<span>🔮</span>
<span>3D Splat</span>
</span>
<div class="header-actions">
<button class="gallery-btn" title="Browse gallery">📂</button>
<button class="close-btn" title="Close">×</button>
</div>
</div>
<div class="content">
<div class="controls">
<input class="splat-url-input" type="text" placeholder="Paste .splat URL or pick from gallery..." />
<button class="load-btn">Load</button>
</div>
<div class="gallery-list" style="display:none"></div>
<div class="viewer-area">
<div class="placeholder">
<span class="placeholder-icon">🔮</span>
<span>Enter a splat URL or browse the gallery</span>
</div>
</div>
</div>
`;
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 = '<div class="loading"><div class="spinner"></div><span>Loading gallery...</span></div>';
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 = '<div class="placeholder" style="padding:16px"><span>No splats in this space yet</span></div>';
return;
}
container.innerHTML = this.#gallerySplats.map((s) => `
<div class="gallery-item" data-slug="${s.slug}">
<div class="title">${this.#escapeHtml(s.title)}</div>
<div class="meta">${s.file_format}${(s.file_size_bytes / 1024 / 1024).toFixed(1)}MB</div>
</div>
`).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 = `<div class="error">Failed to load gallery</div>`;
}
}
async #loadSplat(url: string, viewerArea: HTMLElement) {
this.#splatUrl = url;
this.#isLoading = true;
this.#error = null;
viewerArea.innerHTML = '<div class="loading"><div class="spinner"></div><span>Loading 3D splat...</span></div>';
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<any>);
const GS3D = await (Function("id", "return import(id)")(gs3dId) as Promise<any>);
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 = `<iframe src="/${spaceSlug}/rsplat/view/${slug}" style="width:100%;height:100%;border:none;border-radius:0 0 8px 8px"></iframe>`;
} 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,
};
}
}