Files
PozzettiAndrea-ComfyUI-Turn…/web/viewer_gaussian.html
T
2026-05-13 00:00:18 +02:00

799 lines
31 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>GeomPack 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: 0;
}
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;
}
.scale-control {
display: flex;
align-items: center;
gap: 6px;
}
.scale-control span {
color: #888;
font-size: 11px;
}
.scale-control input[type="number"] {
width: 60px;
background: #333;
border: 1px solid #555;
color: #fff;
font-size: 11px;
padding: 4px 6px;
border-radius: 3px;
}
.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>
</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>
</div>
<!-- Load bundled gsplat.js -->
<script src="./js/gsplat-bundle.js"></script>
<script type="module">
// Use the globally exported GSPLAT
const SPLAT = window.GSPLAT;
// DOM elements
const canvas = document.getElementById('canvas');
const errorEl = document.getElementById('error');
const infoPanel = document.getElementById('infoPanel');
const infoContent = document.getElementById('infoContent');
// Make canvas focusable so keyboard controls (WASD/arrows) work after clicking the viewer
canvas.tabIndex = 0;
canvas.style.outline = "none";
const focusCanvas = () => canvas.focus({ preventScroll: true });
canvas.addEventListener('pointerdown', focusCanvas);
// Persist camera state to workflow on every drag-release and wheel-zoom.
canvas.addEventListener('pointerup', () => emitCameraState());
canvas.addEventListener('wheel', () => emitCameraState(), { passive: true });
// gsplat.js components
let scene = null;
let camera = null;
let renderer = null;
let controls = null;
let animationId = null;
let currentSplat = null;
let originalScales = null; // Store original scales for dynamic adjustment
let originalOpacities = null; // Store original per-gaussian alpha bytes
let opacityStride = 4; // Bytes per color entry (RGBA expected)
const SCALE_CHUNK_SIZE = 200000; // Number of float entries processed per animation frame
const DEFAULT_SCALE_MULTIPLIER = 20.0;
let scaleUpdateJob = null; // Tracks incremental scale updates to avoid UI stalls
// Default focal length
const DEFAULT_FOCAL_LENGTH = 30;
// Initialize the viewer
function initViewer() {
try {
scene = new SPLAT.Scene();
camera = new SPLAT.Camera();
// WebGLRenderer - preserveDrawingBuffer enabled in gsplat-bundle.js
renderer = new SPLAT.WebGLRenderer(canvas);
controls = new SPLAT.OrbitControls(camera, canvas);
// Set default focal length
camera.data.fx = DEFAULT_FOCAL_LENGTH;
camera.data.fy = DEFAULT_FOCAL_LENGTH;
// Handle resize
const resize = () => {
renderer.setSize(canvas.clientWidth, canvas.clientHeight);
};
window.addEventListener('resize', resize);
resize();
// Start render loop
const frame = () => {
controls.update();
renderer.render(scene, camera);
animationId = requestAnimationFrame(frame);
};
frame();
console.log('[GaussianViewer] Initialized successfully');
} catch (err) {
console.error('[GaussianViewer] Init error:', err);
showError('Failed to initialize WebGL viewer: ' + err.message);
}
}
// Show error
function showError(message) {
errorEl.textContent = message;
errorEl.classList.remove('hidden');
}
// Store initial camera params for reset
let initialCameraData = null;
// Track the current vertical FOV (deg) and target so we can re-frame
// when the user changes settings.
let currentFovDeg = 50;
// Cached current orbit target, kept in sync with controls.setCameraTarget calls.
let currentTarget = { x: 0, y: 0, z: 0 };
// Pending restore from parent; consumed once after the next MESH_LOADED.
let pendingRestore = null;
// Push the live camera+settings state up to the parent so it can
// persist it in the workflow JSON via the DOM widget's getValue().
let _lastEmitted = "";
function emitCameraState() {
if (!camera) return;
const state = {
v: 1,
pos: [camera.position.x, camera.position.y, camera.position.z],
target: [currentTarget.x, currentTarget.y, currentTarget.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 (e) { /* parent may be cross-origin or gone */ }
}
// Apply a state previously snapshotted via emitCameraState.
function applyCameraState(state) {
if (!state || !camera || !controls) return;
if (Array.isArray(state.pos)) {
camera.position.x = state.pos[0];
camera.position.y = state.pos[1];
camera.position.z = state.pos[2];
}
if (Array.isArray(state.target)) {
currentTarget = { x: state.target[0], y: state.target[1], z: state.target[2] };
controls.setCameraTarget(new SPLAT.Vector3(currentTarget.x, currentTarget.y, currentTarget.z));
}
if (typeof state.fov === "number") {
currentFovDeg = state.fov;
setFovInputs(state.fov);
const fovRad = state.fov * Math.PI / 180;
const canvasHeight = canvas.clientHeight || 512;
const focal = canvasHeight / (2 * Math.tan(fovRad / 2));
camera.data.fx = focal;
camera.data.fy = focal;
}
if (typeof state.scale === "number") {
currentScale = state.scale;
setScaleInputs(state.scale);
if (originalScales) scheduleScaleUpdate(state.scale);
}
if (typeof state.opacity === "number") {
currentOpacity = state.opacity;
setOpacityInputs(state.opacity);
updateOpacity(state.opacity);
}
controls.update();
console.log("[GaussianViewer] Restored camera state from workflow");
}
// Frame the camera on the splat's bounding box. Always centers on
// bounds.center(). Camera sits behind the center along -Z at a
// distance derived from max bbox extent + fov so the object fits.
function frameOnBounds(splat, fovDeg) {
if (!camera || !controls || !splat || !splat.bounds) return;
const center = splat.bounds.center();
const size = splat.bounds.size();
const maxExtent = Math.max(size.x, size.y, size.z);
currentFovDeg = fovDeg;
const fovRad = fovDeg * Math.PI / 180;
const canvasHeight = canvas.clientHeight || 512;
const focal = canvasHeight / (2 * Math.tan(fovRad / 2));
camera.data.fx = focal;
camera.data.fy = focal;
// Distance such that maxExtent fits in view with ~50% margin
const margin = 1.5;
const radius = Math.max(0.1, (maxExtent / 2) / Math.tan(fovRad / 2) * margin);
const camPos = new SPLAT.Vector3(center.x, center.y, center.z - radius);
const target = new SPLAT.Vector3(center.x, center.y, center.z);
camera.position.x = camPos.x;
camera.position.y = camPos.y;
camera.position.z = camPos.z;
controls.setCameraTarget(target);
currentTarget = { x: target.x, y: target.y, z: target.z };
initialCameraData = { position: camPos, target: target, fx: focal, fy: focal };
console.log('[GaussianViewer] Framed on bounds. center=',
center.x.toFixed(2), center.y.toFixed(2), center.z.toFixed(2),
'maxExtent=', maxExtent.toFixed(2), 'radius=', radius.toFixed(2),
'fov=', fovDeg);
emitCameraState();
}
// Snap the camera onto one of the cardinal axes through the bbox center.
// axis: 'x'|'y'|'z', sign: +1|-1. Distance = same radius as frameOnBounds.
function viewAlongAxis(axis, sign) {
if (!currentSplat || !currentSplat.bounds || !camera || !controls) return;
const center = currentSplat.bounds.center();
const size = currentSplat.bounds.size();
const maxExtent = Math.max(size.x, size.y, size.z);
const fovRad = currentFovDeg * Math.PI / 180;
const margin = 1.5;
const radius = Math.max(0.1, (maxExtent / 2) / Math.tan(fovRad / 2) * margin);
const off = { x: 0, y: 0, z: 0 };
off[axis] = sign * radius;
camera.position.x = center.x + off.x;
camera.position.y = center.y + off.y;
camera.position.z = center.z + off.z;
const target = new SPLAT.Vector3(center.x, center.y, center.z);
controls.setCameraTarget(target);
currentTarget = { x: target.x, y: target.y, z: target.z };
console.log('[GaussianViewer] view along', (sign > 0 ? '+' : '-') + axis.toUpperCase());
emitCameraState();
}
// Compatibility shim: kept so any caller passing extrinsics/intrinsics
// still works. We ignore extrinsics (the workflow always feeds identity)
// and back the FOV out of intrinsics if supplied, otherwise use currentFovDeg.
function setCameraFromExtrinsics(extrinsics, intrinsics, splat) {
let fovDeg = currentFovDeg;
if (intrinsics && Array.isArray(intrinsics) && intrinsics.length >= 2) {
const fy = intrinsics[1][1];
const cy = intrinsics[1][2];
const imageHeight = cy * 2;
fovDeg = 2 * Math.atan(imageHeight / (2 * fy)) * 180 / Math.PI;
}
frameOnBounds(splat, fovDeg);
}
// Load a PLY file from ArrayBuffer data
async function loadPLYFromData(arrayBuffer, filename, extrinsics, intrinsics) {
try {
// Clear ALL existing objects from scene
if (scene.objects && scene.objects.length > 0) {
console.log('[GaussianViewer] Clearing', scene.objects.length, 'existing objects from scene');
// Remove all objects (iterate backwards to avoid index issues)
while (scene.objects.length > 0) {
scene.removeObject(scene.objects[0]);
}
}
currentSplat = null;
if (scaleUpdateJob) {
scaleUpdateJob.cancelled = true;
scaleUpdateJob = null;
}
originalScales = null;
originalOpacities = null;
console.log('[GaussianViewer] Loading from data, size:', arrayBuffer.byteLength);
// Create a blob URL from the data
const blob = new Blob([arrayBuffer], { type: 'application/octet-stream' });
const blobUrl = URL.createObjectURL(blob);
// Load the PLY file from blob URL using PLYLoader
await SPLAT.PLYLoader.LoadAsync(blobUrl, scene);
// Clean up blob URL
URL.revokeObjectURL(blobUrl);
// Get the loaded splat (last object in scene)
if (scene.objects && scene.objects.length > 0) {
currentSplat = scene.objects[scene.objects.length - 1];
// Log bounds info for debugging
if (currentSplat.bounds) {
const center = currentSplat.bounds.center();
const size = currentSplat.bounds.size();
console.log('[GaussianViewer] Scene bounds center:', center.x, center.y, center.z);
console.log('[GaussianViewer] Scene bounds size:', size.x, size.y, size.z);
}
// Snapshot original scales for dynamic adjustment
if (currentSplat.data && currentSplat.data.scales) {
const scales = currentSplat.data.scales;
originalScales = new Float32Array(scales);
console.log('[GaussianViewer] Stored', scales.length, 'original scale entries');
} else {
console.log('[GaussianViewer] No scales data found on splat object');
console.log('[GaussianViewer] Splat properties:', Object.keys(currentSplat));
if (currentSplat.data) {
console.log('[GaussianViewer] Splat.data properties:', Object.keys(currentSplat.data));
}
originalScales = null;
}
// Snapshot original per-gaussian alpha (RGBA stride) for opacity control
if (currentSplat.data && currentSplat.data.colors) {
const colors = currentSplat.data.colors;
const vc = currentSplat.data.vertexCount || 0;
if (vc > 0 && colors.length === vc * 4) {
opacityStride = 4;
originalOpacities = new Uint8Array(vc);
for (let i = 0; i < vc; i++) originalOpacities[i] = colors[i * 4 + 3];
console.log('[GaussianViewer] Stored', vc, 'original opacity bytes (RGBA stride)');
} else {
originalOpacities = null;
console.log('[GaussianViewer] Colors stride not 4 — opacity slider disabled');
}
}
// Reset slider state for new splat and apply default scale
currentScale = DEFAULT_SCALE_MULTIPLIER;
setScaleInputs(DEFAULT_SCALE_MULTIPLIER);
if (originalScales) scheduleScaleUpdate(DEFAULT_SCALE_MULTIPLIER);
currentOpacity = 1.0;
setOpacityInputs(1.0);
}
// Always frame on bounds (overrides whatever extrinsics were sent).
if (currentSplat) {
frameOnBounds(currentSplat, currentFovDeg);
if (controls) controls.update();
}
// Show info panel
infoPanel.classList.remove('hidden');
infoContent.innerHTML = `<span style="color:#6cc;">Gaussian Splat Loaded</span><br><span style="color:#888;">${filename}</span>`;
// Apply any pending camera-state restore now that the splat is loaded.
// Falls AFTER frameOnBounds so the user's saved view overrides the default framing.
if (pendingRestore) {
applyCameraState(pendingRestore);
pendingRestore = null;
}
// Notify parent
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 PLY: ' + err.message);
window.parent.postMessage({
type: 'MESH_ERROR',
error: err.message,
timestamp: Date.now()
}, '*');
}
}
// Reset camera to initial position
function resetCamera() {
if (camera && controls) {
if (initialCameraData) {
// Set camera.position (used by OrbitControls), not camera.data.position
const pos = initialCameraData.position;
camera.position.x = pos.x;
camera.position.y = pos.y;
camera.position.z = pos.z;
// Restore focal lengths
if (initialCameraData.fx) {
camera.data.fx = initialCameraData.fx;
}
if (initialCameraData.fy) {
camera.data.fy = initialCameraData.fy;
}
// Set the target - this recalculates orbit state from current camera.position
if (initialCameraData.target) {
controls.setCameraTarget(initialCameraData.target);
currentTarget = {
x: initialCameraData.target.x,
y: initialCameraData.target.y,
z: initialCameraData.target.z,
};
}
console.log('[GaussianViewer] Reset camera to position:', pos.x, pos.y, pos.z);
console.log('[GaussianViewer] Reset camera target:', initialCameraData.target?.x, initialCameraData.target?.y, initialCameraData.target?.z);
} else {
// Default fallback
camera.position.x = 0;
camera.position.y = 0;
camera.position.z = 5;
console.log('[GaussianViewer] Reset camera to default position (0, 0, 5)');
}
controls.update();
emitCameraState();
}
}
// Take screenshot
function takeScreenshot() {
if (!renderer) return;
try {
const dataUrl = canvas.toDataURL('image/png');
window.parent.postMessage({
type: 'SCREENSHOT',
image: dataUrl,
timestamp: Date.now()
}, '*');
console.log('[GaussianViewer] Screenshot captured');
} catch (err) {
console.error('[GaussianViewer] Screenshot error:', err);
}
}
// Settings panel + slider DOM
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');
let currentScale = 1.0;
let currentOpacity = 1.0;
function setScaleInputs(v) { scaleRange.value = v; scaleInput.value = v; }
function setOpacityInputs(v) { opacityRange.value = v; opacityInput.value = v; }
function setFovInputs(v) { fovRange.value = v; fovInput.value = v; }
function finalizeScaleUpdate(job) {
if (!job || job.cancelled || scaleUpdateJob !== job) {
return;
}
if (!(currentSplat && currentSplat.data)) {
scaleUpdateJob = null;
return;
}
currentSplat.data.changed = true;
currentSplat.data.detached = false;
const renderData = renderer?._renderData || renderer?.renderData;
if (renderData?.markDirty) {
renderData.markDirty(currentSplat);
}
if (renderData) {
renderData.dataChanged = true;
}
if (currentSplat.dispatchEvent && currentSplat._changeEvent) {
currentSplat.dispatchEvent(currentSplat._changeEvent);
}
console.log('[GaussianViewer] Finished gaussian scale update');
scaleUpdateJob = null;
}
function scheduleScaleUpdate(targetScale) {
if (!(currentSplat && currentSplat.data && currentSplat.data.scales && originalScales)) {
console.log('[GaussianViewer] Cannot schedule scale update - missing data');
return;
}
// Cancel any in-flight job so we only apply the latest value
if (scaleUpdateJob) {
scaleUpdateJob.cancelled = true;
}
const job = {
targetScale,
index: 0,
cancelled: false,
splat: currentSplat,
scales: currentSplat.data.scales,
originals: originalScales
};
scaleUpdateJob = job;
const total = job.originals.length;
console.log('[GaussianViewer] Scaling gaussians in chunks. Factor:', targetScale, 'Total entries:', total);
const processChunk = () => {
if (job.cancelled || scaleUpdateJob !== job || job.splat !== currentSplat) {
return;
}
const start = job.index;
const end = Math.min(total, start + SCALE_CHUNK_SIZE);
for (let i = start; i < end; i++) {
job.scales[i] = job.originals[i] * job.targetScale;
}
job.index = end;
if (job.index < total) {
requestAnimationFrame(processChunk);
} else {
finalizeScaleUpdate(job);
}
};
requestAnimationFrame(processChunk);
}
function updateGaussianScale(newScale) {
newScale = Math.max(0.01, Math.min(100, parseFloat(newScale) || 1.0));
currentScale = newScale;
setScaleInputs(newScale);
if (currentSplat && currentSplat.data && currentSplat.data.scales && originalScales) {
scheduleScaleUpdate(newScale);
}
emitCameraState();
}
function updateOpacity(newOpacity) {
newOpacity = Math.max(0, Math.min(1, parseFloat(newOpacity) || 1.0));
currentOpacity = newOpacity;
setOpacityInputs(newOpacity);
if (currentSplat && currentSplat.data && currentSplat.data.colors && originalOpacities) {
const colors = currentSplat.data.colors;
for (let i = 0; i < originalOpacities.length; i++) {
colors[i * opacityStride + 3] = Math.round(originalOpacities[i] * newOpacity);
}
currentSplat.data.changed = true;
currentSplat.data.detached = false;
}
emitCameraState();
}
function updateFov(newFov) {
newFov = Math.max(5, Math.min(170, parseFloat(newFov) || 50));
setFovInputs(newFov);
if (currentSplat) frameOnBounds(currentSplat, newFov); // emits state itself
else { currentFovDeg = newFov; emitCameraState(); }
}
// FOV
fovRange.addEventListener('input', e => updateFov(e.target.value));
fovInput.addEventListener('change', e => updateFov(e.target.value));
// Scale
scaleRange.addEventListener('input', e => updateGaussianScale(e.target.value));
scaleInput.addEventListener('change', e => updateGaussianScale(e.target.value));
// Opacity
opacityRange.addEventListener('input', e => updateOpacity(e.target.value));
opacityInput.addEventListener('change', e => updateOpacity(e.target.value));
// Settings toggle
settingsBtn.addEventListener('click', () => settingsPanel.classList.toggle('hidden'));
// Axis-view buttons
document.querySelectorAll('.view-btn').forEach(btn => {
btn.addEventListener('click', () => {
viewAlongAxis(btn.dataset.axis, parseInt(btn.dataset.sign, 10));
});
});
// Other controls
document.getElementById('resetCamera').addEventListener('click', resetCamera);
document.getElementById('screenshot').addEventListener('click', takeScreenshot);
// Listen for messages from parent
window.addEventListener('message', (event) => {
const msg = event.data || {};
const { type, data, filename, extrinsics, intrinsics } = msg;
if (type === 'LOAD_MESH_DATA' && data) {
console.log('[GaussianViewer] Received LOAD_MESH_DATA, size:', data.byteLength);
loadPLYFromData(data, filename || 'gaussian.ply', extrinsics, intrinsics);
} else if (type === 'RESTORE_CAMERA_STATE' && msg.state) {
// Parent has a saved state for this node. Buffer it: if the
// splat is already loaded, apply now; otherwise the loader
// will pick it up after frameOnBounds.
if (currentSplat) applyCameraState(msg.state);
else pendingRestore = msg.state;
}
});
// Initialize on load
initViewer();
console.log('[GaussianViewer] Ready - waiting for LOAD_MESH message');
</script>
</body>
</html>