Files
PozzettiAndrea-ComfyUI-Turn…/web/viewer_gaussian.html
T
PozzettiAndrea d4be0bda1d Fix /view 404 for PLYs under input/; remove Spark heartbeat log
Two paper cuts spotted while wiring LoadPLY through PreviewGaussians:

## 1. Hardcoded `type=output` in the /view fetch

Repro: LoadPLY hands /home/work/.../ComfyUI/input/apple.ply to
PreviewGaussians. The JS fetches `/view?filename=apple.ply&type=output
&subfolder=` and gets 404 because the file lives in input/, not
output/. PLYs produced by GaussianMerge/GaussianExport/LiToExportPLY
(under output/) worked because the hardcoded `type=output` matched.

Fix is two-sided:

- Python (nodes/preview_gaussian.py): new `_resolve_for_view()`
  walks the abs path against folder_paths.get_{output,input}_directory()
  and returns (filename, subfolder, folder_kind). The kind is shipped
  as a new UI field `ply_type` ("input" | "output"), plus
  `ply_subfolder` for nested locations.

- JS (web/js/gaussian_preview.js): reads `ply_type` / `ply_subfolder`
  off the onExecuted message and substitutes them into both URLs —
  `/view?type=<kind>&subfolder=<sub>` for raw PLY transport, and
  `/gaussianpack/spz?type=<kind>&subfolder=<sub>` for the SPZ
  transcode route. The Python SPZ route already accepts both
  type=input and type=output (spz_route.py:53-56), so no server-side
  change needed.

Files truly outside ComfyUI's input/output trees still 404 (the
fallback returns type=output + bare basename), which matches the
prior behavior — the previewer can't serve arbitrary disk paths.

## 2. Spark heartbeat log spam

Every 120 frames (~2 s) the iframe printed
"[Spark] heartbeat: N frames" to the console. Useful while bringing
up the rendering loop, noise now that everything works. Dropped the
line; the "first frame rendered" trace stays as the smoke test.
2026-05-16 15:24:22 +02:00

787 lines
30 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Gaussian Splat Viewer</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #1a1a1a;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
flex-direction: column;
height: 100vh;
}
#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.js",
"three/addons/controls/OrbitControls.js": "./js/OrbitControls.js",
"three/addons/controls/TrackballControls.js": "./js/TrackballControls.js"
}
}
</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>
</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.js';
// 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);
this.camera.position.set(0, 0, -3);
this.camera.lookAt(0, 0, 0);
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(() => {
const w = this.canvas.clientWidth || 1;
const h = this.canvas.clientHeight || 1;
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();
});
ro.observe(this.canvas);
}
_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); }
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);
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 — WebGPU (TODO real impl)
// ============================================================
class PlayCanvasAdapter {
constructor(canvas) {
this.canvas = canvas;
this._fallback = null;
}
kind() { return "playcanvas-fallback"; }
async init() {
console.warn("[PlayCanvas] adapter not yet implemented; falling back to Spark.");
this._fallback = new SparkAdapter(this.canvas);
this._fallback.init();
}
async loadSplat(u, f) { return this._fallback.loadSplat(u, f); }
getBounds() { return this._fallback.getBounds(); }
getCameraPosition() { return this._fallback.getCameraPosition(); }
setCameraPosition(x,y,z) { return this._fallback.setCameraPosition(x,y,z); }
getCameraTarget() { return this._fallback.getCameraTarget(); }
setCameraTarget(x,y,z){ return this._fallback.setCameraTarget(x,y,z); }
setFov(d) { return this._fallback.setFov(d); }
setScaleMultiplier(f) { return this._fallback.setScaleMultiplier(f); }
setOpacityMultiplier(f){ return this._fallback.setOpacityMultiplier(f); }
screenshot() { return this._fallback.screenshot(); }
get scene() { return this._fallback?.scene; }
get camera() { return this._fallback?.camera; }
get renderer() { return this._fallback?.renderer; }
get splatMesh() { return this._fallback?.splatMesh; }
get _frameCount() { return this._fallback?._frameCount; }
}
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);
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);
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 });
// 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;
}
});
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>