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.
1495 lines
62 KiB
HTML
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">⚙ 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 −X">−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 −Y">−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 −Z">−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>
|