799 lines
31 KiB
HTML
799 lines
31 KiB
HTML
<!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>
|