Files
PozzettiAndrea a342f20402 Serve frontend JS from javascript/, keep vendor bundles out of the main realm
Move web/ to javascript/ and declare it via [tool.comfy] web.

Rename the seven third-party bundles (playcanvas, spark, three.js, gsplat,
OrbitControls, TrackballControls) to .mjs. Every reference to them is inside the
HTML viewers, but ComfyUI auto-imports **/*.js under the web dir into the shared
browser realm, so ~8MB of vendor code was being evaluated in the main page on
every startup -- and with it 36 collision errors: document.body injection,
document-level listeners, unguarded message listeners and self.onmessage writes.
The importmap values, script srcs and three.module's internal ./three.core ref
are rewritten to match.

DisplayName becomes "GaussianPack": the namespace enforced on
registerExtension names is derived from it, and "ComfyUI-GaussianPack" made
that prefix invalid. The analysis extension is renamed to match.

js/gaussian_preview.js hooked "PlyPreviewLoadGaussianPLYEnhance", a node id
that appears nowhere in this repo, so the PLY dropdown auto-refresh had never
run; it now targets LoadPLY and LoadPLYOutput, the two nodes that actually carry
the ply_file widget. Guard its message listener on event.source.

Asset seeding resolves the input directory through
folder_paths.get_input_directory(), so it follows ComfyUI Desktop and
--input-directory instead of writing into the code tree.

comfy-test.toml moves to the current schema: [test.workflows] cuda (there is
no "gpu" key -- the accelerator is named by backend, and the old spelling parsed
to an empty list, so CUDA runners silently ran the full unfiltered set), an
explicit [test.platforms] platforms allowlist (per-platform booleans are no
longer supported), and the javascript level.
2026-08-22 20:56:09 +02:00

1495 lines
62 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Gaussian Splat Viewer</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html { height: 100%; }
body {
background: #1a1a1a;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
flex-direction: column;
height: 100vh;
}
/* #viewerContainer is the fullscreen target. The UA stylesheet already
gives a non-:root fullscreen element position:fixed; inset:0;
width/height:100% !important, so we don't re-declare size — only the
backdrop colour (avoids a black flash, esp. WebKit). The canvas is a
100%/100% child, so it fills the container. */
#viewerContainer:fullscreen,
#viewerContainer:-webkit-full-screen { background: #1a1a1a; }
#viewerContainer::backdrop { background: #1a1a1a; }
#viewerContainer {
position: relative;
flex: 1;
min-height: 200px;
}
canvas {
width: 100%;
height: 100%;
display: block;
}
.error {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
color: #ff6b6b;
padding: 20px;
max-width: 80%;
}
.error.hidden {
display: none;
}
#controlsBar {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
background: #252525;
padding: 8px 12px;
border-top: 1px solid #333;
flex-shrink: 0;
}
#controlsBar button {
background: #444;
border: none;
color: #fff;
padding: 4px 10px;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
}
#controlsBar button:hover {
background: #555;
}
.view-axes {
display: inline-flex;
gap: 2px;
padding: 2px 4px;
background: #1f1f1f;
border-radius: 4px;
}
.view-axes .view-btn {
background: #333;
color: #ddd;
font-family: monospace;
font-size: 11px;
min-width: 30px;
padding: 4px 6px;
}
.view-axes .view-btn:hover {
background: #4a6;
color: #fff;
}
.info-panel {
position: absolute;
top: 8px;
left: 8px;
background: rgba(0,0,0,0.7);
padding: 8px 12px;
border-radius: 4px;
color: #aaa;
font-size: 11px;
font-family: monospace;
z-index: 50;
}
.info-panel.hidden {
display: none;
}
#settingsPanel {
position: absolute;
top: 8px;
right: 8px;
background: rgba(20,20,20,0.92);
border: 1px solid #444;
border-radius: 4px;
padding: 10px 12px;
min-width: 220px;
z-index: 60;
font-size: 11px;
color: #ddd;
display: flex;
flex-direction: column;
gap: 10px;
}
#settingsPanel.hidden { display: none; }
#settingsPanel .row {
display: grid;
grid-template-columns: 70px 1fr 48px;
gap: 6px;
align-items: center;
}
#settingsPanel .row label { color: #888; }
#settingsPanel .row input[type="range"] { width: 100%; }
#settingsPanel .row input[type="number"] {
background: #333;
border: 1px solid #555;
color: #fff;
font-size: 11px;
padding: 2px 4px;
border-radius: 3px;
width: 100%;
}
#settingsPanel .panel-title {
font-weight: 600;
color: #ccc;
border-bottom: 1px solid #333;
padding-bottom: 4px;
}
</style>
<!-- ES-module import map so Spark can find `three` -->
<script type="importmap">
{
"imports": {
"three": "./js/three.module.min.mjs",
"three/addons/controls/OrbitControls.js": "./js/OrbitControls.mjs",
"three/addons/controls/TrackballControls.js": "./js/TrackballControls.mjs"
}
}
</script>
</head>
<body>
<div id="viewerContainer">
<canvas id="canvas"></canvas>
<div id="error" class="error hidden"></div>
<div id="infoPanel" class="info-panel hidden">
<div id="infoContent"></div>
</div>
<div id="settingsPanel" class="hidden">
<div class="panel-title">Settings</div>
<div class="row">
<label for="fovInput">FOV</label>
<input type="range" id="fovRange" min="5" max="170" step="1" value="50">
<input type="number" id="fovInput" min="5" max="170" step="1" value="50">
</div>
<div class="row">
<label for="scaleInput">Scale</label>
<input type="range" id="scaleRange" min="1" max="50" step="0.5" value="20">
<input type="number" id="scaleInput" min="1" max="50" step="0.5" value="20">
</div>
<div class="row">
<label for="opacityInput">Opacity</label>
<input type="range" id="opacityRange" min="0.05" max="1" step="0.01" value="1.0">
<input type="number" id="opacityInput" min="0.05" max="1" step="0.01" value="1.0">
</div>
</div>
</div>
<div id="controlsBar">
<button id="settingsBtn" title="Settings">&#9881; Settings</button>
<span class="view-axes">
<button class="view-btn" data-axis="x" data-sign="1" title="View along +X (looks at YZ plane)">+X</button>
<button class="view-btn" data-axis="x" data-sign="-1" title="View along &minus;X">&minus;X</button>
<button class="view-btn" data-axis="y" data-sign="1" title="View along +Y (looks at XZ plane)">+Y</button>
<button class="view-btn" data-axis="y" data-sign="-1" title="View along &minus;Y">&minus;Y</button>
<button class="view-btn" data-axis="z" data-sign="1" title="View along +Z (looks at XY plane)">+Z</button>
<button class="view-btn" data-axis="z" data-sign="-1" title="View along &minus;Z">&minus;Z</button>
</span>
<button id="resetCamera" title="Reset camera view">Reset View</button>
<button id="screenshot" title="Take screenshot">Screenshot</button>
<button id="fullscreenBtn" title="Toggle fullscreen">Fullscreen</button>
<!-- spectate-mode UI; shown only when ?mode=spectate -->
<span id="spectateBar" style="display:none; margin-left: 10px; align-items: center; gap: 6px;">
<label for="spectateSpeed" style="color:#aaa; font: 10px monospace;">Speed</label>
<input type="range" id="spectateSpeed" min="0" max="1" step="0.001" value="0.5" style="width: 120px;">
<span id="spectateSpeedLabel" style="color:#cfc; font: 10px monospace; min-width: 3em;">2.00</span>
</span>
</div>
<script type="module">
// ============================================================
// Gaussian Splat Viewer — Spark (Three.js + WebGL2)
//
// Renderer is @sparkjsdev/spark — auto-detects file format
// (PLY / compressed.ply / SPZ / SPLAT / KSPLAT / SOG / ZIP / RAD)
// from header bytes, and preserves SH3 better than mkkellogg's
// SPZ-quantized path.
//
// The renderer dropdown still has a `webgpu` option; for now it
// logs a fallback warning and uses Spark (WebGL2). A real
// PlayCanvas WebGPU adapter is a follow-up.
// ============================================================
import * as THREE from 'three';
// TrackballControls: ParaView-style free rotation (no fixed up vector,
// no gimbal lock at poles, supports roll). OrbitControls kept its
// azimuth/up constraints which felt locked when looking from below.
import { TrackballControls } from 'three/addons/controls/TrackballControls.js';
import { SparkRenderer, SplatMesh } from './js/spark.module.min.mjs';
// ============================================================
// Mode detection — `?mode=spectate` swaps TrackballControls for
// SpectateControls (WASD + Space/C + drag-to-look). Defaults to
// trackball when the URL param is absent.
// ============================================================
const _urlParams = new URLSearchParams(window.location.search);
const VIEWER_MODE = _urlParams.get("mode") === "spectate" ? "spectate" : "trackball";
const SPECTATE_INITIAL_SPEED = parseFloat(_urlParams.get("speed") || "2.0");
// Forward navigation key state to the parent node so it can light up its
// on-screen keypad indicator (useful for screen recordings).
(() => {
const _down = new Set();
const fwd = (code, down) => {
try { window.parent.postMessage({ type: "KEY_STATE", code, down }, "*"); } catch (_) {}
};
window.addEventListener("keydown", (e) => { if (!_down.has(e.code)) { _down.add(e.code); fwd(e.code, true); } });
window.addEventListener("keyup", (e) => { if (_down.delete(e.code)) fwd(e.code, false); });
window.addEventListener("blur", () => { _down.forEach(c => fwd(c, false)); _down.clear(); });
})();
// ============================================================
// SpectateControls — TrackballControls-compatible API for fly-cam.
//
// Bindings:
// W A S D in-plane move (camera-local forward/strafe)
// Space / C up / down (world up axis)
// Shift boost (3x speed)
// Left-drag mouse look (yaw + clamped pitch)
// Wheel scale move speed
//
// Mirrors the TrackballControls surface used elsewhere in this file
// (`target`, `update()`, `handleResize()`, `dispose()`) so the
// adapter's existing update loop and resize observer keep working.
// ============================================================
class SpectateControls {
constructor(camera, domElement) {
this.camera = camera;
this.dom = domElement;
this.moveSpeed = SPECTATE_INITIAL_SPEED;
this.lookSensitivity = 0.0025; // radians per pixel
this.boostMultiplier = 3.0;
this._keys = new Set();
this._isDragging = false;
this._lastX = 0;
this._lastY = 0;
this._lastTime = performance.now();
this.target = new THREE.Vector3(0, 0, 0);
this._scratchQ = new THREE.Quaternion();
this._axisX = new THREE.Vector3(1, 0, 0);
this._axisY = new THREE.Vector3(0, 1, 0);
this._axisZ = new THREE.Vector3(0, 0, 1);
this._scratchFwd = new THREE.Vector3();
this._scratchRight = new THREE.Vector3();
this._scratchUp = new THREE.Vector3(0, 1, 0);
this._scratchMove = new THREE.Vector3();
this._onKeyDown = this._onKeyDown.bind(this);
this._onKeyUp = this._onKeyUp.bind(this);
this._onMouseDown = this._onMouseDown.bind(this);
this._onMouseMove = this._onMouseMove.bind(this);
this._onMouseUp = this._onMouseUp.bind(this);
this._onContextMenu = (e) => e.preventDefault();
this._onWheel = this._onWheel.bind(this);
this._onBlur = () => this._keys.clear();
window.addEventListener("keydown", this._onKeyDown);
window.addEventListener("keyup", this._onKeyUp);
window.addEventListener("blur", this._onBlur);
this.dom.addEventListener("mousedown", this._onMouseDown);
window.addEventListener("mousemove", this._onMouseMove);
window.addEventListener("mouseup", this._onMouseUp);
this.dom.addEventListener("contextmenu", this._onContextMenu);
this.dom.addEventListener("wheel", this._onWheel, { passive: false });
}
_onKeyDown(e) {
this._keys.add(e.code);
if (["Space", "KeyW", "KeyA", "KeyS", "KeyD", "KeyC",
"ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.code)) {
e.preventDefault();
}
}
_onKeyUp(e) { this._keys.delete(e.code); }
_onMouseDown(e) {
if (e.button !== 0) return;
this._isDragging = true;
this._lastX = e.clientX;
this._lastY = e.clientY;
this.dom.style.cursor = "grabbing";
}
_onMouseMove(e) {
if (!this._isDragging) return;
const dx = e.clientX - this._lastX;
const dy = e.clientY - this._lastY;
this._lastX = e.clientX;
this._lastY = e.clientY;
this._scratchQ.setFromAxisAngle(this._axisY, -dx * this.lookSensitivity);
this.camera.quaternion.multiply(this._scratchQ);
this._scratchQ.setFromAxisAngle(this._axisX, -dy * this.lookSensitivity);
this.camera.quaternion.multiply(this._scratchQ);
}
_onMouseUp() {
this._isDragging = false;
this.dom.style.cursor = "grab";
}
_onWheel(e) {
e.preventDefault();
const factor = e.deltaY > 0 ? 0.9 : 1.111;
this.setSpeed(this.moveSpeed * factor);
}
update() {
const now = performance.now();
const dt = Math.min(0.1, (now - this._lastTime) / 1000);
this._lastTime = now;
const turn = Math.PI / 2 * dt;
const shift = this._keys.has("ShiftLeft") || this._keys.has("ShiftRight");
if (shift) {
if (this._keys.has("ArrowLeft")) {
this._scratchQ.setFromAxisAngle(this._axisZ, turn);
this.camera.quaternion.multiply(this._scratchQ);
}
if (this._keys.has("ArrowRight")) {
this._scratchQ.setFromAxisAngle(this._axisZ, -turn);
this.camera.quaternion.multiply(this._scratchQ);
}
} else {
if (this._keys.has("ArrowLeft")) {
this._scratchQ.setFromAxisAngle(this._axisY, turn);
this.camera.quaternion.multiply(this._scratchQ);
}
if (this._keys.has("ArrowRight")) {
this._scratchQ.setFromAxisAngle(this._axisY, -turn);
this.camera.quaternion.multiply(this._scratchQ);
}
}
if (this._keys.has("ArrowUp")) {
this._scratchQ.setFromAxisAngle(this._axisX, turn);
this.camera.quaternion.multiply(this._scratchQ);
}
if (this._keys.has("ArrowDown")) {
this._scratchQ.setFromAxisAngle(this._axisX, -turn);
this.camera.quaternion.multiply(this._scratchQ);
}
this._scratchFwd.set(0, 0, -1).applyQuaternion(this.camera.quaternion);
this._scratchRight.set(1, 0, 0).applyQuaternion(this.camera.quaternion);
// Up/down is relative to the camera's CURRENT orientation, not a
// fixed world axis, so Space/C follow where you're looking now.
this._scratchUp.set(0, 1, 0).applyQuaternion(this.camera.quaternion);
this._scratchMove.set(0, 0, 0);
if (this._keys.has("KeyW")) this._scratchMove.add(this._scratchFwd);
if (this._keys.has("KeyS")) this._scratchMove.sub(this._scratchFwd);
if (this._keys.has("KeyD")) this._scratchMove.add(this._scratchRight);
if (this._keys.has("KeyA")) this._scratchMove.sub(this._scratchRight);
if (this._keys.has("Space")) this._scratchMove.add(this._scratchUp);
if (this._keys.has("KeyC")) this._scratchMove.sub(this._scratchUp);
if (this._scratchMove.lengthSq() > 0) {
const boost = shift ? this.boostMultiplier : 1;
this._scratchMove.normalize().multiplyScalar(this.moveSpeed * boost * dt);
this.camera.position.add(this._scratchMove);
}
}
setSpeed(s) {
this.moveSpeed = Math.max(0.05, Math.min(50, s));
try {
this.dom.dispatchEvent(new CustomEvent("spectatespeedchange",
{ detail: { speed: this.moveSpeed }}));
} catch (_) {}
}
handleResize() {}
dispose() {
window.removeEventListener("keydown", this._onKeyDown);
window.removeEventListener("keyup", this._onKeyUp);
window.removeEventListener("blur", this._onBlur);
this.dom.removeEventListener("mousedown", this._onMouseDown);
window.removeEventListener("mousemove", this._onMouseMove);
window.removeEventListener("mouseup", this._onMouseUp);
this.dom.removeEventListener("contextmenu", this._onContextMenu);
this.dom.removeEventListener("wheel", this._onWheel);
}
}
// Lazy <script> loader for non-module bundles (PlayCanvas).
const _loaded = new Set();
function loadScript(src) {
if (_loaded.has(src)) return Promise.resolve();
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = src; s.async = false;
s.onload = () => { _loaded.add(src); resolve(); };
s.onerror = () => reject(new Error("Failed to load " + src));
document.head.appendChild(s);
});
}
const canvas = document.getElementById('canvas');
const errorEl = document.getElementById('error');
const infoPanel = document.getElementById('infoPanel');
const infoContent = document.getElementById('infoContent');
const settingsPanel = document.getElementById('settingsPanel');
const settingsBtn = document.getElementById('settingsBtn');
const fovRange = document.getElementById('fovRange');
const fovInput = document.getElementById('fovInput');
const scaleRange = document.getElementById('scaleRange');
const scaleInput = document.getElementById('scaleInput');
const opacityRange = document.getElementById('opacityRange');
const opacityInput = document.getElementById('opacityInput');
canvas.tabIndex = 0;
canvas.style.outline = "none";
canvas.addEventListener('pointerdown', () => canvas.focus({ preventScroll: true }));
function showError(msg) {
errorEl.textContent = msg;
errorEl.classList.remove('hidden');
console.error('[GaussianViewer]', msg);
}
// ---- shared viewer state ------------------------------------
let adapter = null;
let adapterReady = false;
let pendingRestore = null;
let currentFovDeg = 50;
let currentScale = 20.0;
let currentOpacity = 1.0;
let initialPose = null;
function setFovInputs(v) { fovRange.value = v; fovInput.value = v; }
function setScaleInputs(v) { scaleRange.value = v; scaleInput.value = v; }
function setOpacityInputs(v) { opacityRange.value = v; opacityInput.value = v; }
// ============================================================
// Base: Three.js scene + camera + WebGLRenderer + TrackballControls
// ============================================================
class ThreeBaseAdapter {
constructor(canvas) {
this.canvas = canvas;
this.scene = null;
this.camera = null;
this.renderer = null;
this.controls = null;
this.splatMesh = null;
this._frameCount = 0;
this._raf = null;
}
_initThree(logTag) {
const W = this.canvas.clientWidth || 512;
const H = this.canvas.clientHeight || 512;
const dpr = window.devicePixelRatio || 1;
console.log(`[${logTag}] init: canvas client=${W}x${H} dpr=${dpr.toFixed(2)}`);
let renderer;
try {
renderer = new THREE.WebGLRenderer({
canvas: this.canvas,
antialias: false,
alpha: false,
preserveDrawingBuffer: true,
});
} catch (err) {
const msg = "WebGL context creation refused — reload the ComfyUI tab once "
+ "(Chrome anti-DoS guard). Detail: " + (err?.message || err);
console.error(`[${logTag}] FATAL:`, msg);
showError(msg);
try {
window.parent.postMessage({ type: "MESH_ERROR", error: msg, timestamp: Date.now() }, "*");
} catch (_) {}
throw err;
}
renderer.setPixelRatio(dpr);
renderer.setSize(W, H, false);
renderer.setClearColor(0x1a1a1a, 1.0);
this.renderer = renderer;
this.canvas.addEventListener("webglcontextlost", (e) => {
e.preventDefault();
console.warn(`[${logTag}] WebGL context LOST`);
try {
window.parent.postMessage({
type: "MESH_ERROR",
error: "WebGL context lost; will retry on restore.",
timestamp: Date.now(),
}, "*");
} catch (_) {}
});
this.canvas.addEventListener("webglcontextrestored", () => {
console.log(`[${logTag}] WebGL context RESTORED`);
});
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(50, W / Math.max(1, H), 0.05, 1000);
if (VIEWER_MODE === "spectate") {
this.camera.position.set(0, 0, 0);
this.camera.lookAt(0, 0, 1);
} else {
this.camera.position.set(0, 0, -3);
this.camera.lookAt(0, 0, 0);
}
if (VIEWER_MODE === "spectate") {
// Fly-cam: WASD + Space/C + drag-to-look. Initial position
// pulled back a bit so the user starts looking at the scene
// origin (the framing pass after splat load will replace
// this anyway).
this.controls = new SpectateControls(this.camera, this.canvas);
this.canvas.style.cursor = "grab";
} else {
this.controls = new TrackballControls(this.camera, this.canvas);
// ParaView-ish tuning: snappier rotate, modest pan speed.
this.controls.rotateSpeed = 3.5;
this.controls.zoomSpeed = 1.2;
this.controls.panSpeed = 0.8;
this.controls.staticMoving = true; // no inertia drift
this.controls.dynamicDampingFactor = 0.15;
this.controls.target.set(0, 0, 0);
this.controls.update();
}
const ro = new ResizeObserver(() => this.resize());
// Observe the container: it's the box that changes on fullscreen
// (the canvas only follows). Falls back to the canvas if unattached.
ro.observe(this.canvas.parentElement || this.canvas);
}
resize() {
const w = this.canvas.clientWidth || 1;
const h = this.canvas.clientHeight || 1;
// Re-apply DPR: fullscreening onto a different-density display would
// otherwise leave the canvas rendering soft.
this.renderer.setPixelRatio(window.devicePixelRatio || 1);
this.renderer.setSize(w, h, false);
this.camera.aspect = w / Math.max(1, h);
this.camera.updateProjectionMatrix();
// TrackballControls maps mouse pixels to NDC against a cached
// screen rect — must be refreshed after each resize.
this.controls?.handleResize?.();
}
_startRenderLoop(logTag) {
const tick = () => {
this._raf = requestAnimationFrame(tick);
this.controls.update();
this.renderer.render(this.scene, this.camera);
this._frameCount += 1;
if (this._frameCount === 1) console.log(`[${logTag}] first frame rendered.`);
};
tick();
}
getCameraPosition() {
const p = this.camera.position;
return { x: p.x, y: p.y, z: p.z };
}
setCameraPosition(x, y, z) {
this.camera.position.set(x, y, z);
if (VIEWER_MODE === "spectate") {
const t = this.controls.target;
this.camera.lookAt(t.x, t.y, t.z);
}
}
getCameraTarget() {
const t = this.controls.target;
return { x: t.x, y: t.y, z: t.z };
}
setCameraTarget(x, y, z) {
this.controls.target.set(x, y, z);
if (VIEWER_MODE === "spectate") {
this.camera.lookAt(x, y, z);
} else {
this.controls.update();
}
}
setFov(deg) {
this.camera.fov = deg;
this.camera.updateProjectionMatrix();
}
screenshot() {
this.renderer.render(this.scene, this.camera);
return this.renderer.domElement.toDataURL("image/png");
}
}
// ============================================================
// Adapter: Spark — best SH3 fidelity, all formats
// ============================================================
class SparkAdapter extends ThreeBaseAdapter {
constructor(canvas) {
super(canvas);
this.sparkRenderer = null;
}
kind() { return "spark"; }
init() {
this._initThree("Spark");
// SparkRenderer is itself a THREE.Mesh — adding it to the scene
// hooks its onBeforeRender for per-frame splat sort / LOD work.
this.sparkRenderer = new SparkRenderer({ renderer: this.renderer });
this.scene.add(this.sparkRenderer);
this._startRenderLoop("Spark");
}
async loadSplat(blobUrl, filename) {
// Replace any existing scene.
if (this.splatMesh) {
console.log("[Spark] removing existing SplatMesh before reload");
this.scene.remove(this.splatMesh);
try { this.splatMesh.dispose(); } catch (_) {}
this.splatMesh = null;
}
console.log(`[Spark] new SplatMesh url=${filename}`);
const t0 = performance.now();
const splatMesh = new SplatMesh({
url: blobUrl,
fileName: filename, // hint for Spark's format sniff (also reads bytes)
onProgress: (e) => {
if (e?.total) {
const pct = (e.loaded / e.total) * 100;
if (Math.floor(pct) % 10 === 0) {
console.log(`[Spark] parse ${pct.toFixed(0)}%`);
}
}
},
});
this.scene.add(splatMesh);
await splatMesh.initialized;
this.splatMesh = splatMesh;
const dt = performance.now() - t0;
console.log(`[Spark] loaded ${splatMesh.numSplats} splats in ${dt.toFixed(0)} ms`);
}
getBounds() {
if (!this.splatMesh) return null;
let bbox;
try { bbox = this.splatMesh.getBoundingBox(true); } catch (e) {
console.warn("[Spark] getBoundingBox failed:", e?.message);
return null;
}
if (!bbox || bbox.isEmpty()) return null;
const min = bbox.min, max = bbox.max;
return {
center: { x: (min.x + max.x) / 2, y: (min.y + max.y) / 2, z: (min.z + max.z) / 2 },
size: { x: max.x - min.x, y: max.y - min.y, z: max.z - min.z },
};
}
// Spark per-mesh splatScale / splatOpacity multipliers.
setScaleMultiplier(f) {
if (this.splatMesh) this.splatMesh.splatScale = f / 20.0;
}
setOpacityMultiplier(f) {
if (this.splatMesh) this.splatMesh.splatOpacity = f;
}
}
// ============================================================
// Adapter: PlayCanvas Engine v2.19+ — WebGPU GSplat
//
// Loads playcanvas.min.js as a classic <script> (attaches to
// window.pc). Creates a pc.Application on the shared canvas,
// adds a camera entity with orbit-style mouse controls, and
// loads gaussian splats via pc.Asset with file.contents.
// ============================================================
class PlayCanvasAdapter {
constructor(canvas) {
this.canvas = canvas;
this._app = null;
this._cameraEntity = null;
this._splatEntity = null;
this._splatAsset = null;
this._target = { x: 0, y: 0, z: 0 };
this._frameCount = 0;
this._isDragging = false;
this._lastMouse = { x: 0, y: 0 };
this._orbitDist = 3;
this._yaw = 0;
this._pitch = 0;
this._camQuat = null; // initialized in init() after pc is loaded
this._keys = new Set();
this._moveSpeed = SPECTATE_INITIAL_SPEED;
this._lastTime = performance.now();
this._isSpectate = (VIEWER_MODE === "spectate");
this.controls = {
target: this._target,
update: () => this._tickControls(),
handleResize() {},
setSpeed: (s) => { this._moveSpeed = Math.max(0.05, Math.min(50, s)); },
};
// Stubs for compatibility with ThreeBaseAdapter consumers
this.scene = null;
this.camera = null;
this.renderer = null;
this.splatMesh = null;
}
kind() { return "playcanvas"; }
async init() {
// Load PlayCanvas engine (non-module, attaches to window.pc)
await loadScript('./js/playcanvas.min.mjs');
const pc = window.pc;
if (!pc) throw new Error("PlayCanvas engine failed to load");
console.log("[PlayCanvas] engine version:", pc.version);
const app = new pc.Application(this.canvas, {
graphicsDeviceOptions: {
antialias: false,
alpha: false,
preserveDrawingBuffer: true,
},
});
app.setCanvasFillMode(pc.FILLMODE_NONE);
app.setCanvasResolution(pc.RESOLUTION_AUTO);
app.start();
this._app = app;
// Force initial canvas size to match container
const initW = this.canvas.clientWidth || 512;
const initH = this.canvas.clientHeight || 512;
app.resizeCanvas(initW, initH);
this.canvas.width = initW * (window.devicePixelRatio || 1);
this.canvas.height = initH * (window.devicePixelRatio || 1);
// Camera
const cam = new pc.Entity("Camera");
cam.addComponent("camera", {
nearClip: 0.05,
farClip: 1000,
fov: 50,
clearColor: new pc.Color(0.102, 0.102, 0.102, 1), // #1a1a1a
});
if (this._isSpectate) {
cam.setPosition(0, 0, 0);
this._camQuat = new pc.Quat();
cam.setRotation(this._camQuat);
} else {
cam.setPosition(0, 0, -3);
cam.lookAt(0, 0, 0);
}
app.root.addChild(cam);
this._cameraEntity = cam;
// Resize — sync canvas pixel size + PlayCanvas internal resolution
// to match the CSS layout size (which tracks the ComfyUI node/iframe).
// Observe the CONTAINER, not the canvas: PlayCanvas pins the
// canvas to fixed px (FILLMODE_NONE), so the canvas box never
// changes on fullscreen and a canvas RO would never fire.
const ro = new ResizeObserver(() => this.resize());
ro.observe(this.canvas.parentElement || this.canvas);
// --- Input handling (orbit OR spectate) ---
this.canvas.addEventListener("pointerdown", (e) => {
this._isDragging = true;
this._lastMouse = { x: e.clientX, y: e.clientY };
this.canvas.setPointerCapture(e.pointerId);
this.canvas.style.cursor = this._isSpectate ? "grabbing" : "default";
});
this.canvas.addEventListener("pointermove", (e) => {
if (!this._isDragging) return;
const dx = e.clientX - this._lastMouse.x;
const dy = e.clientY - this._lastMouse.y;
this._lastMouse = { x: e.clientX, y: e.clientY };
if (this._isSpectate) {
const sens = 0.15; // degrees per pixel
const q = this._camQuat;
const tmp = new pc.Quat();
tmp.setFromAxisAngle(pc.Vec3.UP, -dx * sens);
q.mul(tmp);
tmp.setFromAxisAngle(pc.Vec3.RIGHT, -dy * sens);
q.mul(tmp);
} else if (e.buttons === 1) {
// Orbit: left drag. Pitch uses +dy so dragging down tilts the
// view down (matches the Spark/Trackball viewer's direction).
this._yaw -= dx * 0.3;
this._pitch = Math.max(-89, Math.min(89, this._pitch + dy * 0.3));
this._updateOrbitCamera();
} else if (e.buttons === 2 || e.buttons === 4) {
// Orbit: right/middle drag -> pan
const panScale = this._orbitDist * 0.002;
const camRight = new pc.Vec3();
const camUp = new pc.Vec3();
cam.getWorldTransform().getX(camRight);
cam.getWorldTransform().getY(camUp);
this._target.x -= (camRight.x * dx - camUp.x * dy) * panScale;
this._target.y -= (camRight.y * dx - camUp.y * dy) * panScale;
this._target.z -= (camRight.z * dx - camUp.z * dy) * panScale;
this._updateOrbitCamera();
}
});
this.canvas.addEventListener("pointerup", (e) => {
this._isDragging = false;
this.canvas.releasePointerCapture(e.pointerId);
this.canvas.style.cursor = this._isSpectate ? "grab" : "default";
});
this.canvas.addEventListener("wheel", (e) => {
if (this._isSpectate) {
// Spectate: scroll = adjust speed
const factor = e.deltaY > 0 ? 0.9 : 1.111;
this._moveSpeed = Math.max(0.05, Math.min(50, this._moveSpeed * factor));
try {
this.canvas.dispatchEvent(new CustomEvent("spectatespeedchange",
{ detail: { speed: this._moveSpeed }}));
} catch (_) {}
} else {
// Orbit: scroll = zoom
this._orbitDist *= (1 + e.deltaY * 0.001);
this._orbitDist = Math.max(0.01, Math.min(1000, this._orbitDist));
this._updateOrbitCamera();
}
e.preventDefault();
}, { passive: false });
this.canvas.addEventListener("contextmenu", (e) => e.preventDefault());
// Keyboard for spectate
window.addEventListener("keydown", (e) => {
this._keys.add(e.code);
if (this._isSpectate && ["Space","KeyW","KeyA","KeyS","KeyD","KeyC",
"ArrowUp","ArrowDown","ArrowLeft","ArrowRight"].includes(e.code)) {
e.preventDefault();
}
});
window.addEventListener("keyup", (e) => this._keys.delete(e.code));
window.addEventListener("blur", () => this._keys.clear());
if (!this._isSpectate) this._updateOrbitCamera();
this.canvas.style.cursor = this._isSpectate ? "grab" : "default";
// Per-frame update hook for spectate fly-cam movement
if (this._isSpectate) {
app.on("update", () => this._tickControls());
}
console.log(`[PlayCanvas] adapter initialized (mode=${this._isSpectate ? "spectate" : "orbit"})`);
}
resize() {
if (!this._app) return;
// Measure the container, not the canvas: PlayCanvas pins the canvas
// to fixed px, so reading canvas.clientWidth would just echo the
// stale size back. resizeCanvas() then re-pins the canvas to fill.
const host = this.canvas.parentElement || this.canvas;
const w = host.clientWidth || 1;
const h = host.clientHeight || 1;
const dpr = window.devicePixelRatio || 1;
this.canvas.width = Math.round(w * dpr);
this.canvas.height = Math.round(h * dpr);
this._app.resizeCanvas(w, h);
if (this._cameraEntity?.camera) this._cameraEntity.camera.aspectRatio = w / h;
}
_updateOrbitCamera() {
const yawRad = this._yaw * Math.PI / 180;
const pitchRad = this._pitch * Math.PI / 180;
const d = this._orbitDist;
const x = this._target.x + d * Math.cos(pitchRad) * Math.sin(yawRad);
const y = this._target.y + d * Math.sin(pitchRad);
const z = this._target.z + d * Math.cos(pitchRad) * Math.cos(yawRad);
this._cameraEntity.setPosition(x, y, z);
this._cameraEntity.lookAt(this._target.x, this._target.y, this._target.z);
}
_tickControls() {
if (!this._isSpectate || !this._cameraEntity) return;
const now = performance.now();
const dt = Math.min(0.1, (now - this._lastTime) / 1000);
this._lastTime = now;
const pc = window.pc;
const cam = this._cameraEntity;
const q = this._camQuat;
const turn = 90 * dt; // degrees this frame
const shift = this._keys.has("ShiftLeft") || this._keys.has("ShiftRight");
const tmp = new pc.Quat();
if (shift) {
if (this._keys.has("ArrowLeft")) {
tmp.setFromAxisAngle(pc.Vec3.BACK, turn);
q.mul(tmp);
}
if (this._keys.has("ArrowRight")) {
tmp.setFromAxisAngle(pc.Vec3.BACK, -turn);
q.mul(tmp);
}
} else {
if (this._keys.has("ArrowLeft")) {
tmp.setFromAxisAngle(pc.Vec3.UP, turn);
q.mul(tmp);
}
if (this._keys.has("ArrowRight")) {
tmp.setFromAxisAngle(pc.Vec3.UP, -turn);
q.mul(tmp);
}
}
if (this._keys.has("ArrowUp")) {
tmp.setFromAxisAngle(pc.Vec3.RIGHT, turn);
q.mul(tmp);
}
if (this._keys.has("ArrowDown")) {
tmp.setFromAxisAngle(pc.Vec3.RIGHT, -turn);
q.mul(tmp);
}
cam.setRotation(q);
const fwd = new pc.Vec3();
const right = new pc.Vec3();
const up = new pc.Vec3();
cam.getWorldTransform().getZ(fwd);
cam.getWorldTransform().getX(right);
cam.getWorldTransform().getY(up); // camera's CURRENT up
fwd.mulScalar(-1);
// WASD: move camera. Space/C use the camera's current up axis, so
// up/down is relative to where you're looking now, not world Y.
let mx = 0, my = 0, mz = 0;
if (this._keys.has("KeyW")) { mx += fwd.x; my += fwd.y; mz += fwd.z; }
if (this._keys.has("KeyS")) { mx -= fwd.x; my -= fwd.y; mz -= fwd.z; }
if (this._keys.has("KeyD")) { mx += right.x; my += right.y; mz += right.z; }
if (this._keys.has("KeyA")) { mx -= right.x; my -= right.y; mz -= right.z; }
if (this._keys.has("Space")) { mx += up.x; my += up.y; mz += up.z; }
if (this._keys.has("KeyC")) { mx -= up.x; my -= up.y; mz -= up.z; }
const lenSq = mx*mx + my*my + mz*mz;
if (lenSq > 0) {
const boost = (this._keys.has("ShiftLeft") || this._keys.has("ShiftRight")) ? 3 : 1;
const s = this._moveSpeed * boost * dt / Math.sqrt(lenSq);
const pos = cam.getPosition();
cam.setPosition(pos.x + mx*s, pos.y + my*s, pos.z + mz*s);
}
}
async loadSplat(blobUrl, filename) {
const pc = window.pc;
// Remove existing splat
if (this._splatEntity) {
this._splatEntity.destroy();
this._splatEntity = null;
}
if (this._splatAsset) {
this._app.assets.remove(this._splatAsset);
this._splatAsset = null;
}
console.log(`[PlayCanvas] loading splat: ${filename}`);
const t0 = performance.now();
// Use loadFromUrlAndFilename with the blob URL — PlayCanvas
// fetches it internally and the PLY parser gets a proper
// Response object with a readable body stream.
const asset = await new Promise((resolve, reject) => {
this._app.assets.loadFromUrlAndFilename(
blobUrl, filename, 'gsplat',
(err, asset) => {
if (err) reject(new Error(String(err)));
else resolve(asset);
},
);
});
const entity = new pc.Entity('Splat');
entity.addComponent('gsplat', { asset: asset });
// No euler flip — match Spark's convention (no transform on load).
this._app.root.addChild(entity);
this._splatEntity = entity;
this._splatAsset = asset;
this.splatMesh = { numSplats: 0 };
try {
const res = asset.resource;
if (res && res.numSplats) this.splatMesh.numSplats = res.numSplats;
} catch (_) {}
console.log(`[PlayCanvas] loaded in ${(performance.now() - t0).toFixed(0)} ms`);
}
getBounds() {
if (!this._splatEntity) return null;
try {
// Try multiple paths to get bounds from PlayCanvas GSplat
let aabb = null;
const gsplat = this._splatEntity.gsplat;
if (gsplat) {
// v2.19: instance.meshInstance.aabb
aabb = gsplat.instance?.meshInstance?.aabb;
// Fallback: customAabb
if (!aabb) aabb = gsplat.customAabb;
// Fallback: resource.aabb
if (!aabb && this._splatAsset?.resource) {
aabb = this._splatAsset.resource.aabb;
}
}
// Last resort: compute from entity's rendered bounding box
if (!aabb) {
const renders = this._splatEntity.findComponents("gsplat");
for (const r of renders) {
if (r.instance?.meshInstance?.aabb) {
aabb = r.instance.meshInstance.aabb;
break;
}
}
}
if (!aabb) {
console.warn("[PlayCanvas] getBounds: no AABB found, using fallback");
return { center: { x: 0, y: 0, z: 0 }, size: { x: 2, y: 2, z: 2 } };
}
return {
center: { x: aabb.center.x, y: aabb.center.y, z: aabb.center.z },
size: { x: aabb.halfExtents.x * 2, y: aabb.halfExtents.y * 2, z: aabb.halfExtents.z * 2 },
};
} catch (e) {
console.warn("[PlayCanvas] getBounds error:", e);
return { center: { x: 0, y: 0, z: 0 }, size: { x: 2, y: 2, z: 2 } };
}
}
getCameraPosition() {
const p = this._cameraEntity.getPosition();
return { x: p.x, y: p.y, z: p.z };
}
setCameraPosition(x, y, z) {
if (this._isSpectate && this._camQuat) {
this._cameraEntity.setPosition(x, y, z);
this._cameraEntity.lookAt(this._target.x, this._target.y, this._target.z);
this._camQuat.copy(this._cameraEntity.getRotation());
return;
}
const dx = x - this._target.x;
const dy = y - this._target.y;
const dz = z - this._target.z;
this._orbitDist = Math.sqrt(dx*dx + dy*dy + dz*dz);
this._pitch = Math.asin(dy / Math.max(this._orbitDist, 1e-6)) * 180 / Math.PI;
this._yaw = Math.atan2(dx, dz) * 180 / Math.PI;
this._updateOrbitCamera();
}
getCameraTarget() {
return { x: this._target.x, y: this._target.y, z: this._target.z };
}
setCameraTarget(x, y, z) {
this._target.x = x;
this._target.y = y;
this._target.z = z;
if (this._isSpectate && this._camQuat) {
this._cameraEntity.lookAt(x, y, z);
this._camQuat.copy(this._cameraEntity.getRotation());
return;
}
this._updateOrbitCamera();
}
setFov(deg) {
if (this._cameraEntity) this._cameraEntity.camera.fov = deg;
}
setScaleMultiplier(f) {
if (this._splatEntity) {
const s = f / 20.0;
this._splatEntity.setLocalScale(s, s, s);
}
}
setOpacityMultiplier(_f) {
// PlayCanvas GSplat doesn't expose a per-entity opacity multiplier.
// Scene-wide alpha clip is available but not equivalent.
}
screenshot() {
if (this._app) this._app.renderNextFrame = true;
return this.canvas.toDataURL("image/png");
}
}
async function bootRenderer(kind) {
if (kind === "playcanvas") return new PlayCanvasAdapter(canvas);
// Default + any unknown value -> Spark.
return new SparkAdapter(canvas);
}
// ============================================================
// Camera helpers, framing, persistence
// ============================================================
let _lastEmitted = "";
function emitCameraState() {
if (!adapterReady) return;
const p = adapter.getCameraPosition();
const t = adapter.getCameraTarget();
const state = {
v: 1,
pos: [p.x, p.y, p.z],
target: [t.x, t.y, t.z],
fov: currentFovDeg,
scale: currentScale,
opacity: currentOpacity,
};
const s = JSON.stringify(state);
if (s === _lastEmitted) return;
_lastEmitted = s;
try { window.parent.postMessage({ type: "CAMERA_STATE", state }, "*"); }
catch (_) {}
}
function applyCameraState(state) {
if (!adapterReady || !state) return;
if (Array.isArray(state.pos)) {
adapter.setCameraPosition(state.pos[0], state.pos[1], state.pos[2]);
}
if (Array.isArray(state.target)) {
adapter.setCameraTarget(state.target[0], state.target[1], state.target[2]);
}
if (typeof state.fov === "number") {
currentFovDeg = state.fov;
setFovInputs(state.fov);
adapter.setFov(state.fov);
}
if (typeof state.scale === "number") {
currentScale = state.scale;
setScaleInputs(state.scale);
adapter.setScaleMultiplier(state.scale);
}
if (typeof state.opacity === "number") {
currentOpacity = state.opacity;
setOpacityInputs(state.opacity);
adapter.setOpacityMultiplier(state.opacity);
}
console.log("[GaussianViewer] Restored camera state from workflow");
}
function frameOnBounds(fovDeg) {
const b = adapter.getBounds();
if (!b) {
console.warn("[GaussianViewer] frameOnBounds: getBounds() returned null — nothing to frame.");
return;
}
console.log(`[GaussianViewer] frameOnBounds: bounds center=(${b.center.x.toFixed(3)}, ${b.center.y.toFixed(3)}, ${b.center.z.toFixed(3)}) size=(${b.size.x.toFixed(3)}, ${b.size.y.toFixed(3)}, ${b.size.z.toFixed(3)})`);
currentFovDeg = fovDeg;
adapter.setFov(fovDeg);
const maxExtent = Math.max(b.size.x, b.size.y, b.size.z);
const fovRad = fovDeg * Math.PI / 180;
const radius = Math.max(0.1, (maxExtent / 2) / Math.tan(fovRad / 2) * 1.15);
adapter.setCameraPosition(b.center.x, b.center.y, b.center.z - radius);
adapter.setCameraTarget(b.center.x, b.center.y, b.center.z);
initialPose = {
pos: [b.center.x, b.center.y, b.center.z - radius],
target: [b.center.x, b.center.y, b.center.z],
fov: fovDeg,
};
const pp = adapter.getCameraPosition();
const pt = adapter.getCameraTarget();
console.log(`[GaussianViewer] Framed: cam=(${pp.x.toFixed(2)}, ${pp.y.toFixed(2)}, ${pp.z.toFixed(2)}) target=(${pt.x.toFixed(2)}, ${pt.y.toFixed(2)}, ${pt.z.toFixed(2)}) maxExtent=${maxExtent.toFixed(2)} radius=${radius.toFixed(2)} fov=${fovDeg}`);
emitCameraState();
}
function viewAlongAxis(axis, sign) {
const b = adapter.getBounds();
if (!b) return;
const maxExtent = Math.max(b.size.x, b.size.y, b.size.z);
const fovRad = currentFovDeg * Math.PI / 180;
const radius = Math.max(0.1, (maxExtent / 2) / Math.tan(fovRad / 2) * 1.5);
const off = { x: 0, y: 0, z: 0 };
off[axis] = sign * radius;
adapter.setCameraPosition(b.center.x + off.x, b.center.y + off.y, b.center.z + off.z);
adapter.setCameraTarget(b.center.x, b.center.y, b.center.z);
console.log('[GaussianViewer] view along', (sign > 0 ? '+' : '-') + axis.toUpperCase());
emitCameraState();
}
function resetCamera() {
if (!initialPose) return;
const { pos, target, fov } = initialPose;
currentFovDeg = fov;
setFovInputs(fov);
adapter.setFov(fov);
adapter.setCameraPosition(pos[0], pos[1], pos[2]);
adapter.setCameraTarget(target[0], target[1], target[2]);
emitCameraState();
}
function updateFov(v) {
v = Math.max(5, Math.min(170, parseFloat(v) || 50));
currentFovDeg = v;
setFovInputs(v);
if (adapterReady) {
const b = adapter.getBounds();
if (b) frameOnBounds(v); else { adapter.setFov(v); emitCameraState(); }
}
}
function updateGaussianScale(v) {
v = Math.max(0.1, Math.min(100, parseFloat(v) || 1.0));
currentScale = v;
setScaleInputs(v);
if (adapterReady) adapter.setScaleMultiplier(v);
emitCameraState();
}
function updateOpacity(v) {
v = Math.max(0, Math.min(1, parseFloat(v) || 1.0));
currentOpacity = v;
setOpacityInputs(v);
if (adapterReady) adapter.setOpacityMultiplier(v);
emitCameraState();
}
// ============================================================
// Bootstrap on first LOAD_MESH_DATA
// ============================================================
let _bootPromise = null;
async function ensureRenderer(kind) {
if (!_bootPromise) {
_bootPromise = (async () => {
adapter = await bootRenderer(kind);
await adapter.init();
adapterReady = true;
console.log("[GaussianViewer] Adapter ready:", adapter.kind());
})();
}
await _bootPromise;
}
async function loadPLYFromData(arrayBuffer, filename, extrinsics, intrinsics, renderer) {
try {
console.log(`[GaussianViewer] ensureRenderer('${renderer || 'webgl2'}') starting...`);
await ensureRenderer(renderer || "webgl2");
const blob = new Blob([arrayBuffer], { type: 'application/octet-stream' });
const blobUrl = URL.createObjectURL(blob);
try {
console.log(`[GaussianViewer] Loading splat (${arrayBuffer.byteLength.toLocaleString()} bytes) via ${adapter.kind()}...`);
const tLoad0 = performance.now();
await adapter.loadSplat(blobUrl, filename);
console.log(`[GaussianViewer] adapter.loadSplat resolved in ${(performance.now()-tLoad0).toFixed(0)} ms`);
} finally {
URL.revokeObjectURL(blobUrl);
}
let fovDeg = currentFovDeg;
if (intrinsics && Array.isArray(intrinsics) && intrinsics.length >= 2) {
const fy = intrinsics[1][1];
const cy = intrinsics[1][2];
const h = cy * 2;
fovDeg = 2 * Math.atan(h / (2 * fy)) * 180 / Math.PI;
}
fovDeg = Math.max(20, Math.min(120, fovDeg));
setFovInputs(fovDeg);
if (VIEWER_MODE === "spectate") {
// Spectate: start at world origin looking forward
adapter.setFov(fovDeg);
adapter.setCameraPosition(0, 0, 0);
adapter.setCameraTarget(0, 0, 1);
currentFovDeg = fovDeg;
initialPose = { pos: [0, 0, 0], target: [0, 0, 1], fov: fovDeg };
} else {
frameOnBounds(fovDeg);
}
adapter.setScaleMultiplier(currentScale);
adapter.setOpacityMultiplier(currentOpacity);
if (pendingRestore) {
applyCameraState(pendingRestore);
pendingRestore = null;
}
infoPanel.classList.remove('hidden');
infoContent.innerHTML =
'<span style="color:#6cc;">Gaussian Splat Loaded</span><br>'
+ `<span style="color:#888;">${filename || ''}</span>`;
window.parent.postMessage({
type: 'MESH_LOADED', error: null, timestamp: Date.now(),
}, '*');
console.log('[GaussianViewer] Loaded successfully');
} catch (err) {
console.error('[GaussianViewer] Load error:', err);
showError('Failed to load splat: ' + (err?.message || err));
window.parent.postMessage({
type: 'MESH_ERROR', error: String(err?.message || err), timestamp: Date.now(),
}, '*');
}
}
// ============================================================
// Wiring
// ============================================================
fovRange.addEventListener('input', e => updateFov(e.target.value));
fovInput.addEventListener('change', e => updateFov(e.target.value));
scaleRange.addEventListener('input', e => updateGaussianScale(e.target.value));
scaleInput.addEventListener('change', e => updateGaussianScale(e.target.value));
opacityRange.addEventListener('input', e => updateOpacity(e.target.value));
opacityInput.addEventListener('change', e => updateOpacity(e.target.value));
settingsBtn.addEventListener('click', () => settingsPanel.classList.toggle('hidden'));
document.querySelectorAll('.view-btn').forEach(btn => {
btn.addEventListener('click', () => {
viewAlongAxis(btn.dataset.axis, parseInt(btn.dataset.sign, 10));
});
});
document.getElementById('resetCamera').addEventListener('click', resetCamera);
// Fullscreen <body> (not documentElement): with html as the fullscreen
// element the body/canvas stayed at the original node size; body fills.
// Fullscreen the canvas's direct parent (a non-:root element). The UA
// stylesheet stretches `*:not(:root):fullscreen` to position:fixed; inset:0;
// width/height:100% — but EXCLUDES :root, which is why fullscreening <html>
// resized the viewport yet never grew the canvas.
const fsTarget = document.getElementById('viewerContainer');
const fsReq = fsTarget.requestFullscreen || fsTarget.webkitRequestFullscreen;
const fsExit = document.exitFullscreen || document.webkitExitFullscreen;
function fsElement() { return document.fullscreenElement || document.webkitFullscreenElement; }
document.getElementById('fullscreenBtn').addEventListener('click', () => {
try {
if (!fsElement()) {
const p = fsReq.call(fsTarget);
// WebKit (<16.4) returns undefined instead of a Promise.
if (p && p.then) p.catch((err) => console.warn('[FS] requestFullscreen rejected:', err && err.name, err && err.message));
} else {
fsExit.call(document);
}
} catch (err) {
console.warn('[FS] toggle threw:', err);
}
});
// The top-layer layout commits by the 2nd frame; one late timer covers
// Safari's late layout. ResizeObserver on the container is the real signal.
const kickResize = () => {
requestAnimationFrame(() => requestAnimationFrame(() => adapter?.resize?.()));
setTimeout(() => adapter?.resize?.(), 150);
};
document.addEventListener('fullscreenchange', kickResize);
document.addEventListener('webkitfullscreenchange', kickResize);
window.addEventListener('resize', () => adapter?.resize?.());
document.getElementById('screenshot').addEventListener('click', () => {
if (!adapterReady) return;
try {
window.parent.postMessage({
type: 'SCREENSHOT',
image: adapter.screenshot(),
timestamp: Date.now(),
}, '*');
console.log('[GaussianViewer] Screenshot captured');
} catch (err) {
console.error('[GaussianViewer] Screenshot error:', err);
}
});
canvas.addEventListener('pointerup', () => emitCameraState());
canvas.addEventListener('wheel', () => emitCameraState(), { passive: true });
// -------- Spectate-mode speed slider + sync --------------------
// The slider in #spectateBar is hidden unless VIEWER_MODE==spectate.
// Two-way sync: slider input -> SpectateControls.setSpeed; wheel
// scroll on canvas -> CustomEvent("spectatespeedchange") -> slider.
const spectateBar = document.getElementById('spectateBar');
const spectateSlider = document.getElementById('spectateSpeed');
const spectateSpeedLabel = document.getElementById('spectateSpeedLabel');
const SPEED_MIN = 0.05, SPEED_MAX = 50;
const LOG_MIN = Math.log(SPEED_MIN), LOG_MAX = Math.log(SPEED_MAX);
function speedToSlider(speed) {
return (Math.log(Math.max(SPEED_MIN, Math.min(SPEED_MAX, speed))) - LOG_MIN) / (LOG_MAX - LOG_MIN);
}
function sliderToSpeed(t) {
return Math.exp(LOG_MIN + t * (LOG_MAX - LOG_MIN));
}
function setSpectateSpeedUI(v) {
const clamped = Math.max(SPEED_MIN, Math.min(SPEED_MAX, v));
spectateSlider.value = speedToSlider(clamped);
spectateSpeedLabel.textContent = clamped.toFixed(2);
}
if (VIEWER_MODE === "spectate") {
spectateBar.style.display = "inline-flex";
setSpectateSpeedUI(SPECTATE_INITIAL_SPEED);
spectateSlider.addEventListener('input', (e) => {
const v = sliderToSpeed(parseFloat(e.target.value));
spectateSpeedLabel.textContent = v.toFixed(2);
if (adapter && adapter.controls && adapter.controls.setSpeed) {
adapter.controls.setSpeed(v);
}
});
canvas.addEventListener('spectatespeedchange', (e) => {
setSpectateSpeedUI(e.detail.speed);
});
}
// Receive from parent
window.addEventListener('message', (event) => {
const msg = event.data || {};
if (msg.type === 'LOAD_MESH_DATA' && msg.data) {
console.log('[GaussianViewer] Received LOAD_MESH_DATA, size:',
msg.data.byteLength, 'renderer:', msg.renderer);
loadPLYFromData(msg.data, msg.filename || 'gaussian.ply',
msg.extrinsics, msg.intrinsics, msg.renderer);
} else if (msg.type === 'RESTORE_CAMERA_STATE' && msg.state) {
if (adapterReady) applyCameraState(msg.state);
else pendingRestore = msg.state;
} else if (msg.type === 'SPECTATE_CONFIG') {
// Parent (ComfyUI JS) pushed an updated move_speed from the
// node widget. Apply to controls + UI slider if we're in
// spectate mode.
if (VIEWER_MODE === "spectate" && typeof msg.moveSpeed === "number") {
setSpectateSpeedUI(msg.moveSpeed);
if (adapter && adapter.controls && adapter.controls.setSpeed) {
adapter.controls.setSpeed(msg.moveSpeed);
}
}
}
});
console.log('[GaussianViewer] Ready — waiting for LOAD_MESH_DATA');
// -------- Debug hooks (headless Playwright drives these) -----
window.__gs = {
state() {
return {
adapterReady,
adapterKind: adapter && adapter.kind && adapter.kind(),
splatCount: adapter?.splatMesh?.numSplats ?? 0,
cameraPos: adapter ? adapter.getCameraPosition() : null,
cameraTarget: adapter ? adapter.getCameraTarget() : null,
fov: currentFovDeg,
frames: adapter && adapter._frameCount,
};
},
run(fnSrc) {
const fn = new Function('adapter', 'splatMesh', 'THREE', 'scene', 'camera', 'renderer', fnSrc);
return fn(
adapter,
adapter && adapter.splatMesh,
THREE,
adapter && adapter.scene,
adapter && adapter.camera,
adapter && adapter.renderer,
);
},
};
</script>
</body>
</html>