Spaces:
Running on Zero
Running on Zero
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width,initial-scale=1" /> | |
| <title>3DGS Viewer & Stroke Playback - ZipSplatPlus</title> | |
| <style> | |
| *, | |
| *::before, | |
| *::after { | |
| box-sizing: border-box; | |
| margin: 0; | |
| padding: 0; | |
| } | |
| html, | |
| body { | |
| width: 100%; | |
| height: 100%; | |
| overflow: hidden; | |
| background: #080b12; | |
| font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; | |
| user-select: none; | |
| } | |
| canvas { | |
| display: block; | |
| width: 100%; | |
| height: 100%; | |
| } | |
| /* HUD Top Overlay */ | |
| #hud-top { | |
| position: fixed; | |
| top: 12px; | |
| left: 12px; | |
| right: 12px; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| pointer-events: none; | |
| z-index: 10; | |
| } | |
| .hud-badge { | |
| color: #94a3b8; | |
| font-size: 12px; | |
| background: rgba(15, 23, 42, 0.75); | |
| padding: 6px 14px; | |
| border-radius: 20px; | |
| backdrop-filter: blur(10px); | |
| border: 1px solid rgba(255, 255, 255, 0.12); | |
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); | |
| } | |
| #hud-stats { | |
| color: #38bdf8; | |
| font-weight: 600; | |
| } | |
| /* Stroke Control Bar */ | |
| #control-panel { | |
| position: fixed; | |
| bottom: 14px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| width: min(94%, 680px); | |
| background: rgba(15, 23, 42, 0.85); | |
| backdrop-filter: blur(16px); | |
| border: 1px solid rgba(255, 255, 255, 0.15); | |
| border-radius: 16px; | |
| padding: 10px 16px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| z-index: 20; | |
| box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); | |
| transition: opacity 0.2s ease, transform 0.2s ease; | |
| } | |
| .control-row { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 10px; | |
| } | |
| /* Buttons & Controls */ | |
| .ctrl-btn { | |
| background: rgba(30, 41, 59, 0.8); | |
| color: #e2e8f0; | |
| border: 1px solid rgba(255, 255, 255, 0.12); | |
| padding: 6px 12px; | |
| border-radius: 8px; | |
| font-size: 13px; | |
| font-weight: 500; | |
| cursor: pointer; | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 4px; | |
| transition: all 0.15s ease; | |
| outline: none; | |
| } | |
| .ctrl-btn:hover { | |
| background: rgba(51, 65, 85, 0.9); | |
| border-color: rgba(96, 165, 250, 0.5); | |
| color: #fff; | |
| } | |
| .ctrl-btn.active { | |
| background: #2563eb; | |
| border-color: #60a5fa; | |
| color: #fff; | |
| box-shadow: 0 0 10px rgba(37, 99, 235, 0.5); | |
| } | |
| .ctrl-btn-primary { | |
| background: #3b82f6; | |
| color: #fff; | |
| font-weight: 600; | |
| padding: 6px 16px; | |
| border-radius: 8px; | |
| } | |
| .ctrl-btn-primary:hover { | |
| background: #2563eb; | |
| } | |
| /* Slider Styling */ | |
| .slider-container { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| flex: 1; | |
| } | |
| input[type="range"] { | |
| -webkit-appearance: none; | |
| appearance: none; | |
| width: 100%; | |
| height: 6px; | |
| background: #334155; | |
| border-radius: 3px; | |
| outline: none; | |
| cursor: pointer; | |
| } | |
| input[type="range"]::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| appearance: none; | |
| width: 16px; | |
| height: 16px; | |
| border-radius: 50%; | |
| background: #38bdf8; | |
| cursor: pointer; | |
| border: 2px solid #0f172a; | |
| box-shadow: 0 0 8px rgba(56, 189, 248, 0.8); | |
| transition: transform 0.1s ease; | |
| } | |
| input[type="range"]::-webkit-slider-thumb:hover { | |
| transform: scale(1.25); | |
| } | |
| .mode-pills { | |
| display: flex; | |
| background: rgba(30, 41, 59, 0.6); | |
| border-radius: 8px; | |
| padding: 2px; | |
| border: 1px solid rgba(255, 255, 255, 0.08); | |
| } | |
| .pill-btn { | |
| background: transparent; | |
| color: #94a3b8; | |
| border: none; | |
| padding: 4px 10px; | |
| font-size: 11px; | |
| font-weight: 600; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| transition: all 0.15s ease; | |
| } | |
| .pill-btn.active { | |
| background: #3b82f6; | |
| color: #ffffff; | |
| } | |
| #loading { | |
| position: fixed; | |
| inset: 0; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| color: #94a3b8; | |
| font-size: 14px; | |
| gap: 12px; | |
| background: #080b12; | |
| z-index: 100; | |
| } | |
| #spinner { | |
| width: 36px; | |
| height: 36px; | |
| border: 3px solid #334155; | |
| border-top-color: #60a5fa; | |
| border-radius: 50%; | |
| animation: spin 0.9s linear infinite; | |
| } | |
| @keyframes spin { | |
| to { | |
| transform: rotate(360deg); | |
| } | |
| } | |
| #error { | |
| display: none; | |
| position: fixed; | |
| inset: 0; | |
| align-items: center; | |
| justify-content: center; | |
| color: #f87171; | |
| font-size: 13px; | |
| padding: 32px; | |
| text-align: center; | |
| white-space: pre-wrap; | |
| background: rgba(15, 23, 42, 0.95); | |
| z-index: 100; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="loading"> | |
| <div id="spinner"></div> | |
| <div id="loading-label">Loading 3D Gaussian Splat…</div> | |
| </div> | |
| <div id="error"></div> | |
| <div id="hud-top"> | |
| <div class="hud-badge"> | |
| drag to orbit · scroll to zoom · right-drag to pan | |
| </div> | |
| <div id="hud-stats" class="hud-badge"> | |
| Stroke 0 / 0 · 0 / 0 Gaussians | |
| </div> | |
| </div> | |
| <div id="control-panel"> | |
| <div class="control-row"> | |
| <button id="btn-play-pause" class="ctrl-btn ctrl-btn-primary">▶ Play</button> | |
| <button id="btn-prev" class="ctrl-btn" title="Previous Stroke (Left Arrow)">⏮</button> | |
| <button id="btn-next" class="ctrl-btn" title="Next Stroke (Right Arrow)">⏭</button> | |
| <div class="slider-container"> | |
| <input type="range" id="stroke-slider" min="1" max="100" value="100" /> | |
| </div> | |
| <button id="btn-loop" class="ctrl-btn active" title="Loop Playback">🔁</button> | |
| </div> | |
| <div class="control-row"> | |
| <div class="mode-pills"> | |
| <button id="mode-cumulative" class="pill-btn active" title="Build up splat stroke by stroke">Cumulative</button> | |
| <button id="mode-single" class="pill-btn" title="Isolate active stroke only">Single Stroke</button> | |
| <button id="mode-colored" class="pill-btn" title="Color code strokes">Colorized</button> | |
| </div> | |
| <div style="display: flex; gap: 4px; align-items: center;"> | |
| <span style="color: #64748b; font-size: 11px; font-weight: 600;">SPEED:</span> | |
| <button class="pill-btn speed-btn" data-speed="0.5">0.5x</button> | |
| <button class="pill-btn speed-btn active" data-speed="1.0">1x</button> | |
| <button class="pill-btn speed-btn" data-speed="2.0">2x</button> | |
| <button class="pill-btn speed-btn" data-speed="5.0">5x</button> | |
| </div> | |
| </div> | |
| </div> | |
| <script type="importmap"> | |
| { | |
| "imports": { | |
| "three": "https://cdnjs.cloudflare.com/ajax/libs/three.js/0.180.0/three.module.js", | |
| "three/addons/": "https://unpkg.com/three@0.180.0/examples/jsm/", | |
| "@sparkjsdev/spark": "https://unpkg.com/@sparkjsdev/spark@2.1.0/dist/spark.module.js" | |
| } | |
| } | |
| </script> | |
| <script type="module"> | |
| import * as THREE from "three"; | |
| import { OrbitControls } from "three/addons/controls/OrbitControls.js"; | |
| import { SparkRenderer, SplatMesh } from "@sparkjsdev/spark"; | |
| const urlParams = new URLSearchParams(window.location.search); | |
| const config = window.PLY_CONFIG || {}; | |
| const plyUrl = config.plyUrl || urlParams.get("ply"); | |
| const hasPlyData = config.plyB64 || plyUrl; | |
| const loadingEl = document.getElementById("loading"); | |
| const loadLabel = document.getElementById("loading-label"); | |
| const errorEl = document.getElementById("error"); | |
| const hudStats = document.getElementById("hud-stats"); | |
| const btnPlayPause = document.getElementById("btn-play-pause"); | |
| const btnPrev = document.getElementById("btn-prev"); | |
| const btnNext = document.getElementById("btn-next"); | |
| const btnLoop = document.getElementById("btn-loop"); | |
| const strokeSlider = document.getElementById("stroke-slider"); | |
| const modeCumulative = document.getElementById("mode-cumulative"); | |
| const modeSingle = document.getElementById("mode-single"); | |
| const modeColored = document.getElementById("mode-colored"); | |
| const speedBtns = document.querySelectorAll(".speed-btn"); | |
| function showError(msg) { | |
| loadingEl.style.display = "none"; | |
| errorEl.style.display = "flex"; | |
| errorEl.textContent = msg; | |
| } | |
| if (!hasPlyData) { | |
| showError("No PLY data provided."); | |
| } else { | |
| initViewer(plyUrl); | |
| } | |
| function hslToRgb(h, s, l) { | |
| s /= 100; | |
| l /= 100; | |
| const k = (n) => (n + h / 30) % 12; | |
| const a = s * Math.min(l, 1 - l); | |
| const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))); | |
| return { r: f(0), g: f(8), b: f(4) }; | |
| } | |
| function initViewer(url) { | |
| try { | |
| loadLabel.textContent = "Downloading and rendering 3D Gaussian Splat…"; | |
| const scene = new THREE.Scene(); | |
| const renderer = new THREE.WebGLRenderer({ | |
| antialias: false, | |
| alpha: false, | |
| }); | |
| renderer.setPixelRatio(window.devicePixelRatio); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.setClearColor(0x080b12, 1); | |
| document.body.appendChild(renderer.domElement); | |
| const spark = new SparkRenderer({ renderer }); | |
| scene.add(spark); | |
| const cx = parseFloat(config.cx !== undefined ? config.cx : (urlParams.get("cx") || "0")); | |
| const cy = parseFloat(config.cy !== undefined ? config.cy : (urlParams.get("cy") || "0")); | |
| const cz = parseFloat(config.cz !== undefined ? config.cz : (urlParams.get("cz") || "0")); | |
| const r = parseFloat(config.r !== undefined ? config.r : (urlParams.get("r") || "2")); | |
| let splatOptions = {}; | |
| if (config.plyB64) { | |
| const binaryString = atob(config.plyB64); | |
| const len = binaryString.length; | |
| const bytes = new Uint8Array(len); | |
| for (let i = 0; i < len; i++) { | |
| bytes[i] = binaryString.charCodeAt(i); | |
| } | |
| splatOptions = { fileBytes: bytes }; | |
| } else { | |
| splatOptions = { url: url }; | |
| } | |
| let splat = null; | |
| let totalGaussians = 0; | |
| let totalStrokes = 0; | |
| const gaussiansPerStroke = 32; | |
| let currentStroke = 1; | |
| let isPlaying = false; | |
| let isLooping = true; | |
| let playSpeed = 1.0; | |
| let displayMode = "cumulative"; // "cumulative", "single", "colored" | |
| let origOpacities = null; | |
| let origColorsR = null; | |
| let origColorsG = null; | |
| let origColorsB = null; | |
| let strokeColors = []; | |
| let needsReset = false; | |
| splat = new SplatMesh({ | |
| ...splatOptions, | |
| onLoad: () => { | |
| loadingEl.style.display = "none"; | |
| setupStrokePlayback(); | |
| }, | |
| }); | |
| if (splat.initialized && splat.initialized.catch) { | |
| splat.initialized.catch((err) => { | |
| showError("Failed to load 3D Gaussian Splat:\n" + (err.message || err)); | |
| }); | |
| } | |
| // Re-orient for Three.js (+Y up) | |
| splat.rotation.y = Math.PI / 2; | |
| splat.rotation.x = Math.PI; | |
| scene.add(splat); | |
| splat.updateMatrixWorld(true); | |
| const center = new THREE.Vector3(cx, cy, cz); | |
| center.applyMatrix4(splat.matrixWorld); | |
| const camera = new THREE.PerspectiveCamera( | |
| 45, | |
| window.innerWidth / window.innerHeight, | |
| Math.min(0.01, r * 0.05), | |
| Math.max(1000, r * 10), | |
| ); | |
| const dist = Math.max(r * 2.0, 0.5); | |
| camera.position.copy(center).add(new THREE.Vector3(0, r * 0.2, dist)); | |
| const controls = new OrbitControls(camera, renderer.domElement); | |
| controls.enableDamping = true; | |
| controls.dampingFactor = 0.07; | |
| controls.minDistance = 0.01; | |
| controls.maxDistance = Math.max(1000, r * 20); | |
| controls.target.copy(center); | |
| controls.update(); | |
| function setupStrokePlayback() { | |
| const splatData = splat.splats || splat.packedSplats || splat.extSplats; | |
| if (!splatData) return; | |
| totalGaussians = splatData.getNumSplats(); | |
| if (totalGaussians === 0) return; | |
| totalStrokes = Math.ceil(totalGaussians / gaussiansPerStroke); | |
| currentStroke = totalStrokes; | |
| strokeSlider.min = "1"; | |
| strokeSlider.max = totalStrokes.toString(); | |
| strokeSlider.value = totalStrokes.toString(); | |
| // Cache original opacity and RGB values | |
| origOpacities = new Float32Array(totalGaussians); | |
| origColorsR = new Float32Array(totalGaussians); | |
| origColorsG = new Float32Array(totalGaussians); | |
| origColorsB = new Float32Array(totalGaussians); | |
| let idx = 0; | |
| splat.forEachSplat((i, c, s, q, opacity, color) => { | |
| if (idx < totalGaussians) { | |
| origOpacities[idx] = opacity; | |
| origColorsR[idx] = color.r; | |
| origColorsG[idx] = color.g; | |
| origColorsB[idx] = color.b; | |
| idx++; | |
| } | |
| }); | |
| // Pre-calculate vibrant HSL colors per stroke for Colorized mode | |
| strokeColors = []; | |
| for (let s = 0; s < totalStrokes; s++) { | |
| const hue = (s * 137.5) % 360; // Golden angle distribution | |
| strokeColors.push(hslToRgb(hue, 85, 60)); | |
| } | |
| applyStrokeState(); | |
| } | |
| function applyStrokeState() { | |
| const splatData = splat.splats || splat.packedSplats || splat.extSplats; | |
| if (!splatData || totalGaussians === 0) return; | |
| const startIdx = (currentStroke - 1) * gaussiansPerStroke; | |
| const endIdx = Math.min(currentStroke * gaussiansPerStroke, totalGaussians); | |
| if (displayMode === "cumulative") { | |
| if (needsReset) { | |
| for (let i = 0; i < totalGaussians; i++) { | |
| splatData.setOpacity(i, origOpacities[i]); | |
| splatData.setColor(i, origColorsR[i], origColorsG[i], origColorsB[i]); | |
| } | |
| needsReset = false; | |
| } | |
| splatData.numSplats = endIdx; | |
| } else if (displayMode === "single") { | |
| needsReset = true; | |
| splatData.numSplats = totalGaussians; | |
| for (let i = 0; i < totalGaussians; i++) { | |
| if (i >= startIdx && i < endIdx) { | |
| splatData.setOpacity(i, origOpacities[i]); | |
| splatData.setColor(i, origColorsR[i], origColorsG[i], origColorsB[i]); | |
| } else { | |
| splatData.setOpacity(i, 0.0); | |
| } | |
| } | |
| } else if (displayMode === "colored") { | |
| needsReset = true; | |
| splatData.numSplats = endIdx; | |
| for (let i = 0; i < endIdx; i++) { | |
| const strokeIdx = Math.floor(i / gaussiansPerStroke); | |
| const rgb = strokeColors[strokeIdx % strokeColors.length]; | |
| splatData.setOpacity(i, origOpacities[i]); | |
| splatData.setColor(i, rgb.r, rgb.g, rgb.b); | |
| } | |
| } | |
| // Update HUD Stats | |
| const percent = ((endIdx / totalGaussians) * 100).toFixed(1); | |
| hudStats.textContent = `Stroke ${currentStroke} / ${totalStrokes} · ${endIdx.toLocaleString()} / ${totalGaussians.toLocaleString()} Gaussians (${percent}%)`; | |
| strokeSlider.value = currentStroke.toString(); | |
| } | |
| // UI Event Listeners | |
| strokeSlider.addEventListener("input", (e) => { | |
| currentStroke = parseInt(e.target.value, 10); | |
| applyStrokeState(); | |
| }); | |
| btnPlayPause.addEventListener("click", () => { | |
| isPlaying = !isPlaying; | |
| btnPlayPause.textContent = isPlaying ? "⏸ Pause" : "▶ Play"; | |
| btnPlayPause.classList.toggle("ctrl-btn-primary", !isPlaying); | |
| }); | |
| btnPrev.addEventListener("click", () => { | |
| currentStroke = Math.max(1, currentStroke - 1); | |
| applyStrokeState(); | |
| }); | |
| btnNext.addEventListener("click", () => { | |
| currentStroke = Math.min(totalStrokes, currentStroke + 1); | |
| applyStrokeState(); | |
| }); | |
| btnLoop.addEventListener("click", () => { | |
| isLooping = !isLooping; | |
| btnLoop.classList.toggle("active", isLooping); | |
| }); | |
| modeCumulative.addEventListener("click", () => { | |
| displayMode = "cumulative"; | |
| modeCumulative.classList.add("active"); | |
| modeSingle.classList.remove("active"); | |
| modeColored.classList.remove("active"); | |
| applyStrokeState(); | |
| }); | |
| modeSingle.addEventListener("click", () => { | |
| displayMode = "single"; | |
| modeSingle.classList.add("active"); | |
| modeCumulative.classList.remove("active"); | |
| modeColored.classList.remove("active"); | |
| applyStrokeState(); | |
| }); | |
| modeColored.addEventListener("click", () => { | |
| displayMode = "colored"; | |
| modeColored.classList.add("active"); | |
| modeCumulative.classList.remove("active"); | |
| modeSingle.classList.remove("active"); | |
| applyStrokeState(); | |
| }); | |
| speedBtns.forEach((btn) => { | |
| btn.addEventListener("click", () => { | |
| speedBtns.forEach((b) => b.classList.remove("active")); | |
| btn.classList.add("active"); | |
| playSpeed = parseFloat(btn.dataset.speed); | |
| }); | |
| }); | |
| // Keyboard Shortcuts | |
| window.addEventListener("keydown", (e) => { | |
| if (e.target.tagName === "INPUT") return; | |
| if (e.code === "Space") { | |
| e.preventDefault(); | |
| btnPlayPause.click(); | |
| } else if (e.code === "ArrowLeft") { | |
| e.preventDefault(); | |
| btnPrev.click(); | |
| } else if (e.code === "ArrowRight") { | |
| e.preventDefault(); | |
| btnNext.click(); | |
| } else if (e.code === "Home") { | |
| currentStroke = 1; | |
| applyStrokeState(); | |
| } else if (e.code === "End") { | |
| currentStroke = totalStrokes; | |
| applyStrokeState(); | |
| } | |
| }); | |
| window.addEventListener("resize", () => { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| }); | |
| let lastTime = performance.now(); | |
| let accumulatedStrokes = 0; | |
| renderer.setAnimationLoop((time) => { | |
| controls.update(); | |
| const dt = (time - lastTime) / 1000; | |
| lastTime = time; | |
| if (isPlaying && totalStrokes > 0) { | |
| const strokesPerSec = 20 * playSpeed; | |
| accumulatedStrokes += strokesPerSec * dt; | |
| if (accumulatedStrokes >= 1) { | |
| const step = Math.floor(accumulatedStrokes); | |
| accumulatedStrokes -= step; | |
| currentStroke += step; | |
| if (currentStroke > totalStrokes) { | |
| if (isLooping) { | |
| currentStroke = 1; | |
| } else { | |
| currentStroke = totalStrokes; | |
| isPlaying = false; | |
| btnPlayPause.textContent = "▶ Play"; | |
| } | |
| } | |
| applyStrokeState(); | |
| } | |
| } | |
| renderer.render(scene, camera); | |
| }); | |
| } catch (err) { | |
| showError( | |
| "Failed to render 3D Gaussian Splat:\n" + (err.message || err), | |
| ); | |
| } | |
| } | |
| </script> | |
| </body> | |
| </html> | |