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

701 lines
30 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Gaussian Splat Dual 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; display: flex; }
#viewerContainer.slider-mode { display: block; }
/* Fullscreen target. The UA stylesheet stretches a non-:root fullscreen
element to fill the screen; we only set the backdrop colour. */
#viewerContainer:fullscreen, #viewerContainer:-webkit-full-screen { background: #1a1a1a; }
#viewerContainer::backdrop { background: #1a1a1a; }
.viewport { flex: 1; position: relative; min-width: 0; }
.viewport canvas { width: 100%; height: 100%; display: block; }
.viewport-label {
position: absolute; top: 8px; left: 12px; z-index: 10;
font: 11px monospace; color: #aaa; background: rgba(0,0,0,0.6);
padding: 2px 8px; border-radius: 3px; pointer-events: none;
}
.viewport-label.right { left: auto; right: 12px; }
.divider { width: 2px; background: #333; flex-shrink: 0; }
/* Slider mode */
.slider-viewport { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
.slider-viewport canvas { width: 100%; height: 100%; display: block; }
#sliderOverlay {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
z-index: 50; cursor: col-resize; display: none;
}
#sliderLine {
position: absolute; top: 0; width: 2px; height: 100%;
background: #00e0ff; box-shadow: 0 0 6px #00e0ff;
pointer-events: none;
}
#sliderHandle {
position: absolute; top: 50%; width: 20px; height: 40px;
margin-top: -20px; margin-left: -9px;
background: rgba(0,224,255,0.8); border-radius: 4px;
cursor: col-resize; pointer-events: auto;
}
.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; align-items: center; gap: 6px;
padding: 4px 8px; background: #111; border-top: 1px solid #333;
flex-shrink: 0; flex-wrap: wrap;
}
#controlsBar button {
padding: 3px 8px; font-size: 10px; border: 1px solid #444;
border-radius: 3px; background: #222; color: #ccc; cursor: pointer;
}
#controlsBar button:hover { background: #333; }
.view-axes { display: flex; gap: 2px; }
</style>
<script type="importmap">
{
"imports": {
"three": "./js/three.module.min.mjs",
"three/addons/controls/TrackballControls.js": "./js/TrackballControls.mjs"
}
}
</script>
</head>
<body>
<div id="viewerContainer">
<!-- Side-by-side: two viewports -->
<div id="vp1" class="viewport">
<canvas id="canvas1"></canvas>
<div class="viewport-label">Gaussian 1</div>
</div>
<div class="divider" id="divider"></div>
<div id="vp2" class="viewport">
<canvas id="canvas2"></canvas>
<div class="viewport-label right">Gaussian 2</div>
</div>
<!-- Slider overlay (shown in slider mode) -->
<div id="sliderOverlay">
<div id="sliderLine"><div id="sliderHandle"></div></div>
</div>
<div id="error" class="error hidden"></div>
</div>
<div id="controlsBar">
<span class="view-axes">
<button class="view-btn" data-axis="x" data-sign="1">+X</button>
<button class="view-btn" data-axis="x" data-sign="-1">&minus;X</button>
<button class="view-btn" data-axis="y" data-sign="1">+Y</button>
<button class="view-btn" data-axis="y" data-sign="-1">&minus;Y</button>
<button class="view-btn" data-axis="z" data-sign="1">+Z</button>
<button class="view-btn" data-axis="z" data-sign="-1">&minus;Z</button>
</span>
<button id="resetCamera">Reset View</button>
<button id="fullscreenBtn" title="Toggle fullscreen">Fullscreen</button>
</div>
<script type="module">
import * as THREE from 'three';
import { TrackballControls } from 'three/addons/controls/TrackballControls.js';
import { SparkRenderer, SplatMesh } from './js/spark.module.min.mjs';
const params = new URLSearchParams(window.location.search);
const LAYOUT = params.get("layout") || "side_by_side";
const MODE = params.get("mode") === "spectate" ? "spectate" : "orbit";
const SPECTATE_INITIAL_SPEED = parseFloat(params.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(); });
})();
const container = document.getElementById('viewerContainer');
const vp1 = document.getElementById('vp1');
const vp2 = document.getElementById('vp2');
const canvas1 = document.getElementById('canvas1');
const canvas2 = document.getElementById('canvas2');
const divider = document.getElementById('divider');
const errorEl = document.getElementById('error');
const sliderOverlay = document.getElementById('sliderOverlay');
const sliderLine = document.getElementById('sliderLine');
// Lazy <script> loader
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);
});
}
function showError(msg) { errorEl.textContent = msg; errorEl.classList.remove('hidden'); }
// ============================================================
// Minimal SparkAdapter (single canvas, no controls)
// ============================================================
class MiniSparkAdapter {
constructor(canvas) {
this.canvas = canvas;
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(50, 1, 0.05, 1000);
this.camera.position.set(0, 0, -3);
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: false, preserveDrawingBuffer: true });
this.renderer.setClearColor(0x1a1a1a, 1);
this.sparkRenderer = new SparkRenderer({ renderer: this.renderer });
this.scene.add(this.sparkRenderer);
this.splatMesh = null;
this._resize();
}
_resize() {
const w = this.canvas.clientWidth || 1, h = this.canvas.clientHeight || 1;
this.renderer.setSize(w, h, false);
this.renderer.setPixelRatio(window.devicePixelRatio || 1);
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
}
render() { this.renderer.render(this.scene, this.camera); }
async loadSplat(blobUrl, filename) {
if (this.splatMesh) { this.scene.remove(this.splatMesh); this.splatMesh = null; }
const sm = new SplatMesh({ url: blobUrl, fileName: filename });
this.scene.add(sm);
await sm.initialized;
this.splatMesh = sm;
}
getBounds() {
if (!this.splatMesh) return null;
try {
const bb = this.splatMesh.getBoundingBox(true);
if (!bb || bb.isEmpty()) return null;
return {
center: { x: (bb.min.x+bb.max.x)/2, y: (bb.min.y+bb.max.y)/2, z: (bb.min.z+bb.max.z)/2 },
size: { x: bb.max.x-bb.min.x, y: bb.max.y-bb.min.y, z: bb.max.z-bb.min.z },
};
} catch(_) { return null; }
}
setScale(f) { if (this.splatMesh) this.splatMesh.splatScale = f / 20.0; }
setOpacity(f) { if (this.splatMesh) this.splatMesh.splatOpacity = f; }
setFov(d) { this.camera.fov = d; this.camera.updateProjectionMatrix(); }
setCamera(pos, target) {
this.camera.position.set(pos.x, pos.y, pos.z);
this.camera.lookAt(target.x, target.y, target.z);
}
// Mirror another THREE camera's full pose (incl. roll/up) onto this one,
// keeping our own projection (aspect/fov) intact.
syncCameraFrom(src) {
this.camera.position.copy(src.position);
this.camera.quaternion.copy(src.quaternion);
this.camera.up.copy(src.up);
}
}
// ============================================================
// Minimal PlayCanvasAdapter (single canvas, no controls)
// ============================================================
class MiniPlayCanvasAdapter {
constructor(canvas) {
this.canvas = canvas;
this._app = null;
this._cam = null;
this._splatEntity = null;
this._splatAsset = null;
this.splatMesh = null;
}
async init() {
await loadScript('./js/playcanvas.min.mjs');
const pc = window.pc;
this._app = new pc.Application(this.canvas, {
graphicsDeviceOptions: { antialias: false, alpha: false, preserveDrawingBuffer: true },
});
this._app.setCanvasFillMode(pc.FILLMODE_NONE);
this._app.setCanvasResolution(pc.RESOLUTION_AUTO);
this._app.start();
this._cam = new pc.Entity("Camera");
this._cam.addComponent("camera", {
nearClip: 0.05, farClip: 1000, fov: 50,
clearColor: new pc.Color(0.102, 0.102, 0.102, 1),
});
this._cam.setPosition(0, 0, -3);
this._cam.lookAt(0, 0, 0);
this._app.root.addChild(this._cam);
}
render() { /* PlayCanvas renders automatically */ }
_resize() {
if (this._app) {
const w = this.canvas.clientWidth || 1, h = this.canvas.clientHeight || 1;
this._app.resizeCanvas(w, h);
}
}
async loadSplat(blobUrl, filename) {
const pc = window.pc;
if (this._splatEntity) { this._splatEntity.destroy(); this._splatEntity = null; }
if (this._splatAsset) { this._app.assets.remove(this._splatAsset); this._splatAsset = null; }
const asset = await new Promise((res, rej) => {
this._app.assets.loadFromUrlAndFilename(blobUrl, filename, 'gsplat', (err, a) => {
if (err) rej(new Error(String(err))); else res(a);
});
});
const entity = new pc.Entity('Splat');
entity.addComponent('gsplat', { asset });
// No euler flip — match Spark's convention.
this._app.root.addChild(entity);
this._splatEntity = entity;
this._splatAsset = asset;
this.splatMesh = { numSplats: 0 };
try { const r = asset.resource; if (r?.numSplats) this.splatMesh.numSplats = r.numSplats; } catch(_) {}
}
getBounds() {
if (!this._splatEntity) return null;
try {
const aabb = this._splatEntity.gsplat?.instance?.meshInstance?.aabb;
if (!aabb) return null;
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(_) { return null; }
}
setScale(f) { if (this._splatEntity) { const s = f/20; this._splatEntity.setLocalScale(s,s,s); } }
setOpacity(_f) { /* not supported */ }
setFov(d) { if (this._cam) this._cam.camera.fov = d; }
setCamera(pos, target) {
if (this._cam) {
this._cam.setPosition(pos.x, pos.y, pos.z);
this._cam.lookAt(target.x, target.y, target.z);
}
}
}
// ============================================================
// Setup
// ============================================================
let adapter1, adapter2;
let controls; // Single TrackballControls on canvas1, synced to both
let currentFov = 50;
let initialPose = null;
let sliderPos = 0.5;
async function createAdapter(canvas, renderer) {
// PlayCanvas can't drive two side-by-side gsplat views: its renderer keys
// off the single global `pc.app` (the last-created Application), so only one
// canvas ever draws its splat. The dual viewer therefore always uses Spark,
// which supports two independent THREE scenes/cameras and our shared-camera
// controls. (Single-view PlayCanvas is unaffected.)
if (renderer === "playcanvas") {
console.warn("[GaussianDualViewer] PlayCanvas can't render two gsplat views in one page; using Spark for the dual view.");
}
return new MiniSparkAdapter(canvas);
}
// Slider mode: stack canvases, clip canvas2
if (LAYOUT === "slider") {
container.classList.add('slider-mode');
divider.style.display = 'none';
vp1.style.position = 'absolute';
vp1.style.inset = '0';
vp2.style.position = 'absolute';
vp2.style.inset = '0';
vp2.style.zIndex = '2';
sliderOverlay.style.display = 'block';
// Let camera orbit happen anywhere on the canvas: the overlay itself is
// click-through, only the handle grabs the slider (it has pointer-events:auto).
sliderOverlay.style.pointerEvents = 'none';
function updateSlider(pct) {
sliderPos = Math.max(0, Math.min(1, pct));
const left = (sliderPos * 100).toFixed(2);
vp2.style.clipPath = `inset(0 0 0 ${left}%)`;
sliderLine.style.left = `calc(${left}% - 1px)`;
}
updateSlider(0.5);
// Drag only via the handle. Position is measured against the container so the
// slider tracks the pointer across the full width; stopPropagation keeps the
// drag from also orbiting the camera.
const sliderHandle = document.getElementById('sliderHandle');
let dragging = false;
sliderHandle.addEventListener('pointerdown', (e) => {
dragging = true;
sliderHandle.setPointerCapture(e.pointerId);
e.stopPropagation();
});
sliderHandle.addEventListener('pointermove', (e) => {
if (!dragging) return;
const rect = container.getBoundingClientRect();
updateSlider((e.clientX - rect.left) / rect.width);
e.stopPropagation();
});
sliderHandle.addEventListener('pointerup', (e) => {
dragging = false;
sliderHandle.releasePointerCapture(e.pointerId);
e.stopPropagation();
});
}
// SpectateControls — WASD fly-cam with a TrackballControls-compatible surface
// (target / update() / handleResize() / dispose()), ported from the single
// viewer so the dual tick loop and camera sync work unchanged. It drives
// adapter1's THREE camera; syncCameras() mirrors the full pose to adapter2.
class SpectateControls {
constructor(camera, domElement) {
this.camera = camera;
this.dom = domElement;
this.moveSpeed = SPECTATE_INITIAL_SPEED;
this.lookSensitivity = 0.0025;
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._fwd = new THREE.Vector3();
this._right = new THREE.Vector3();
this._up = new THREE.Vector3(0, 1, 0);
this._move = new THREE.Vector3();
this._onKeyDown = (e) => {
this._keys.add(e.code);
if (["Space","KeyW","KeyA","KeyS","KeyD","KeyC",
"ArrowUp","ArrowDown","ArrowLeft","ArrowRight"].includes(e.code)) e.preventDefault();
};
this._onKeyUp = (e) => this._keys.delete(e.code);
this._onMouseDown = (e) => {
if (e.button !== 0) return;
// Don't start a look-drag when grabbing the slider handle. The
// handle uses pointer events (stopPropagation), which doesn't stop
// these parallel mouse events, so guard explicitly.
if (e.target && e.target.closest && e.target.closest('#sliderHandle')) return;
this._isDragging = true; this._lastX = e.clientX; this._lastY = e.clientY;
this.dom.style.cursor = "grabbing";
};
this._onMouseMove = (e) => {
if (!this._isDragging) return;
const dx = e.clientX - this._lastX, 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);
};
this._onMouseUp = () => { this._isDragging = false; this.dom.style.cursor = "grab"; };
this._onContextMenu = (e) => e.preventDefault();
this._onWheel = (e) => { e.preventDefault(); this.setSpeed(this.moveSpeed * (e.deltaY > 0 ? 0.9 : 1.111)); };
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 });
this.dom.style.cursor = "grab";
}
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");
const roll = (k, sign) => {
if (this._keys.has(k)) { this._scratchQ.setFromAxisAngle(this._axisZ, sign * turn); this.camera.quaternion.multiply(this._scratchQ); }
};
const yaw = (k, sign) => {
if (this._keys.has(k)) { this._scratchQ.setFromAxisAngle(this._axisY, sign * turn); this.camera.quaternion.multiply(this._scratchQ); }
};
if (shift) { roll("ArrowLeft", 1); roll("ArrowRight", -1); }
else { yaw("ArrowLeft", 1); yaw("ArrowRight", -1); }
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._fwd.set(0, 0, -1).applyQuaternion(this.camera.quaternion);
this._right.set(1, 0, 0).applyQuaternion(this.camera.quaternion);
// Up/down relative to the camera's CURRENT orientation, not a fixed
// world axis, so Space/C follow where you're looking now.
this._up.set(0, 1, 0).applyQuaternion(this.camera.quaternion);
this._move.set(0, 0, 0);
if (this._keys.has("KeyW")) this._move.add(this._fwd);
if (this._keys.has("KeyS")) this._move.sub(this._fwd);
if (this._keys.has("KeyD")) this._move.add(this._right);
if (this._keys.has("KeyA")) this._move.sub(this._right);
if (this._keys.has("Space")) this._move.add(this._up);
if (this._keys.has("KeyC")) this._move.sub(this._up);
if (this._move.lengthSq() > 0) {
this._move.normalize().multiplyScalar(this.moveSpeed * (shift ? this.boostMultiplier : 1) * dt);
this.camera.position.add(this._move);
}
}
setSpeed(s) { this.moveSpeed = Math.max(0.05, Math.min(50, s)); }
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);
}
}
// Camera controls on the container (works for both layouts). Orbit =
// TrackballControls; spectate = WASD fly-cam. Both drive adapter1's camera.
function setupControls(camera) {
// Bind to the whole container so input works anywhere on the canvas in both
// layouts. In slider mode the overlay is click-through, so only the handle
// grabs the slider and everything else drives the camera.
const target = container;
if (MODE === "spectate") {
controls = new SpectateControls(camera, target);
return;
}
controls = new TrackballControls(camera, target);
controls.rotateSpeed = 3.5;
controls.zoomSpeed = 1.2;
controls.panSpeed = 0.8;
controls.staticMoving = true;
controls.dynamicDampingFactor = 0.15;
controls.target.set(0, 0, 0);
controls.update();
}
function syncCameras() {
if (!adapter1 || !adapter2 || !controls) return;
// Spark: adapter1's THREE camera is driven by TrackballControls. Mirror its
// full pose (position + orientation incl. roll) onto adapter2 so the two
// views stay locked. Don't touch adapter1 — that would fight the trackball.
if (adapter1.camera && adapter2.syncCameraFrom) {
adapter2.syncCameraFrom(adapter1.camera);
return;
}
// PlayCanvas (no THREE camera on the adapter): sync position + target.
const pos = { x: adapter1.camera?.position?.x ?? 0, y: adapter1.camera?.position?.y ?? 0, z: adapter1.camera?.position?.z ?? 0 };
const tgt = { x: controls.target.x, y: controls.target.y, z: controls.target.z };
if (adapter1.setCamera) adapter1.setCamera(pos, tgt);
if (adapter2.setCamera) adapter2.setCamera(pos, tgt);
}
function frameOnBounds(fov) {
// Use bounds from adapter1, or adapter2 as fallback
const b = adapter1?.getBounds() || adapter2?.getBounds();
if (!b) return;
currentFov = fov;
adapter1?.setFov(fov);
adapter2?.setFov(fov);
const maxExt = Math.max(b.size.x, b.size.y, b.size.z);
const radius = Math.max(0.1, (maxExt / 2) / Math.tan(fov * Math.PI / 360) * 1.15);
if (controls) {
controls.target.set(b.center.x, b.center.y, b.center.z);
}
// Position camera along -Z
const camPos = { x: b.center.x, y: b.center.y, z: b.center.z - radius };
const camTgt = { x: b.center.x, y: b.center.y, z: b.center.z };
if (adapter1?.camera) {
adapter1.camera.position.set(camPos.x, camPos.y, camPos.z);
adapter1.camera.lookAt(camTgt.x, camTgt.y, camTgt.z);
}
adapter1?.setCamera?.(camPos, camTgt);
adapter2?.setCamera?.(camPos, camTgt);
initialPose = { pos: [camPos.x, camPos.y, camPos.z], target: [camTgt.x, camTgt.y, camTgt.z], fov };
emitCameraState();
}
function viewAlongAxis(axis, sign) {
const b = adapter1?.getBounds() || adapter2?.getBounds();
if (!b) return;
const maxExt = Math.max(b.size.x, b.size.y, b.size.z);
const radius = Math.max(0.1, (maxExt / 2) / Math.tan(currentFov * Math.PI / 360) * 1.5);
const off = { x: 0, y: 0, z: 0 };
off[axis] = sign * radius;
if (controls) controls.target.set(b.center.x, b.center.y, b.center.z);
const camPos = { x: b.center.x + off.x, y: b.center.y + off.y, z: b.center.z + off.z };
const camTgt = { x: b.center.x, y: b.center.y, z: b.center.z };
if (adapter1?.camera) {
adapter1.camera.position.set(camPos.x, camPos.y, camPos.z);
adapter1.camera.lookAt(camTgt.x, camTgt.y, camTgt.z);
}
adapter1?.setCamera?.(camPos, camTgt);
adapter2?.setCamera?.(camPos, camTgt);
emitCameraState();
}
// Camera state
let _lastEmitted = "";
function emitCameraState() {
const pos = adapter1?.camera?.position || { x: 0, y: 0, z: 0 };
const tgt = controls?.target || { x: 0, y: 0, z: 0 };
const state = {
v: 1,
pos: [pos.x, pos.y, pos.z],
target: [tgt.x, tgt.y, tgt.z],
fov: currentFov,
};
const s = JSON.stringify(state);
if (s === _lastEmitted) return;
_lastEmitted = s;
try { window.parent.postMessage({ type: "CAMERA_STATE", state }, "*"); } catch(_) {}
}
function applyCameraState(state) {
if (!state) return;
if (Array.isArray(state.pos) && controls && adapter1?.camera) {
adapter1.camera.position.set(state.pos[0], state.pos[1], state.pos[2]);
}
if (Array.isArray(state.target) && controls) {
controls.target.set(state.target[0], state.target[1], state.target[2]);
}
if (typeof state.fov === "number") {
currentFov = state.fov;
adapter1?.setFov(state.fov);
adapter2?.setFov(state.fov);
}
syncCameras();
}
// Render loop
function tick() {
requestAnimationFrame(tick);
if (controls) controls.update();
syncCameras();
adapter1?.render();
adapter2?.render();
}
// Wiring
document.querySelectorAll('.view-btn').forEach(btn => {
btn.addEventListener('click', () => viewAlongAxis(btn.dataset.axis, parseInt(btn.dataset.sign, 10)));
});
document.getElementById('resetCamera').addEventListener('click', () => {
if (!initialPose) return;
applyCameraState(initialPose);
});
// Fullscreen the canvas container (a non-:root element, so the UA stretches it
// to the screen). Both adapters re-fit via the kick + the container RO.
const fsReq = container.requestFullscreen || container.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(container);
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); }
});
const fsKick = () => {
const r = () => { adapter1?._resize(); adapter2?._resize(); controls?.handleResize?.(); };
requestAnimationFrame(() => requestAnimationFrame(r));
setTimeout(r, 150);
};
document.addEventListener('fullscreenchange', fsKick);
document.addEventListener('webkitfullscreenchange', fsKick);
window.addEventListener('resize', () => { adapter1?._resize(); adapter2?._resize(); });
canvas1.addEventListener('pointerup', emitCameraState);
canvas1.addEventListener('wheel', emitCameraState, { passive: true });
canvas2.addEventListener('pointerup', emitCameraState);
canvas2.addEventListener('wheel', emitCameraState, { passive: true });
if (LAYOUT === "slider") {
sliderOverlay.addEventListener('pointerup', emitCameraState);
sliderOverlay.addEventListener('wheel', emitCameraState, { passive: true });
}
// Resize
const ro = new ResizeObserver(() => {
adapter1?._resize();
adapter2?._resize();
controls?.handleResize();
});
ro.observe(container);
// ============================================================
// Message handling
// ============================================================
let pendingRestore = null;
async function loadDual(data1, data2, filename1, filename2, renderer, fov) {
try {
adapter1 = await createAdapter(canvas1, renderer);
adapter2 = await createAdapter(canvas2, renderer);
// Setup controls on adapter1's Three.js camera (Spark) or a proxy (PlayCanvas)
if (adapter1.camera) {
setupControls(adapter1.camera);
}
adapter1._resize();
adapter2._resize();
tick(); // Start render loop
const blob1 = new Blob([data1], { type: 'application/octet-stream' });
const blob2 = new Blob([data2], { type: 'application/octet-stream' });
const url1 = URL.createObjectURL(blob1);
const url2 = URL.createObjectURL(blob2);
await Promise.all([
adapter1.loadSplat(url1, filename1),
adapter2.loadSplat(url2, filename2),
]);
URL.revokeObjectURL(url1);
URL.revokeObjectURL(url2);
currentFov = fov || 50;
frameOnBounds(currentFov);
if (pendingRestore) { applyCameraState(pendingRestore); pendingRestore = null; }
window.parent.postMessage({ type: 'MESH_LOADED', timestamp: Date.now() }, '*');
} catch (err) {
showError('Load error: ' + (err?.message || err));
window.parent.postMessage({ type: 'MESH_ERROR', error: String(err?.message || err), timestamp: Date.now() }, '*');
}
}
window.addEventListener('message', (e) => {
const msg = e.data || {};
if (msg.type === 'LOAD_DUAL_GAUSSIAN' && msg.data1 && msg.data2) {
loadDual(msg.data1, msg.data2, msg.filename1 || 'g1.ply', msg.filename2 || 'g2.ply',
msg.renderer || 'spark', msg.fov || 50);
} else if (msg.type === 'RESTORE_CAMERA_STATE' && msg.state) {
if (adapter1) applyCameraState(msg.state);
else pendingRestore = msg.state;
}
});
console.log(`[GaussianDualViewer] Ready (layout=${LAYOUT})`);
</script>
</body>
</html>