Files
PozzettiAndrea-ComfyUI-Geom…/javascript/viewer_gaussian.html
T
PozzettiAndrea 414f759791 Vendor frontend JS into javascript/; drop the prestartup copy dance
The viewer JS/HTML/bundles are now committed in javascript/ (declared via
pyproject [tool.comfy] web = "javascript") instead of being copied from the
shared comfy-3d-viewers pip package at prestartup. Consequences:

- requirements.txt is now just comfy-env (host env installs nothing else,
  per the never-touch-the-host-env principle)
- prestartup_script.py no longer copies viewers or web_src; it keeps only
  setup_env() and the input-asset copies
- WEB_DIRECTORY attribute removed; the pyproject declaration is the single
  source (served at /extensions/comfyui-geometrypack/, keyed by project name)
- web_src/ deleted; its 6 widgets now live in javascript/js/ as the tracked
  source, edited directly
- viewer_pbr.html hardcoded-fallback URL updated to the new mount name

Iframe URLs auto-detect the mount from import.meta.url, so the folder rename
and mount-name change are transparent at runtime.
2026-08-12 17:37:09 +02:00

959 lines
44 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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;
}
.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;
}
</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>
<div id="controlsBar">
<div class="scale-control">
<span>Scale:</span>
<input type="number" id="gaussianScaleValue" min="0.01" max="100" step="0.1" value="1.0" title="Gaussian scale multiplier">
</div>
<button id="resetCamera" title="Reset camera view">Reset View</button>
<button id="screenshot" title="Take screenshot">Screenshot</button>
<button id="copyImage" title="Copy image to clipboard">Copy Image</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');
// 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
// Default focal length
const DEFAULT_FOCAL_LENGTH = 30;
// Initialize the viewer
function initViewer() {
try {
scene = new SPLAT.Scene();
camera = new SPLAT.Camera();
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 = () => {
// Only resize renderer if resolution is NOT locked
// If locked, the canvas resolution stays fixed (e.g. at 2K),
// and CSS handles the visual fitting to container.
if (!isResolutionLocked) {
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;
// Resolution lock state
let isResolutionLocked = false;
// Scale compensation: when focal length is scaled down for canvas size,
// gaussian scales need to be multiplied by this factor to maintain correct screen-space size
let gaussianScaleCompensation = 1.0;
// Resume the animation loop (used after pausing for screenshot/clipboard)
function resumeAnimationLoop() {
const frame = () => {
controls.update();
renderer.render(scene, camera);
animationId = requestAnimationFrame(frame);
};
frame();
}
// Set camera from extrinsics and intrinsics
// extrinsics: 4x4 matrix (identity for SHARP = camera at origin looking down +Z)
// intrinsics: 3x3 matrix [[fx, 0, cx], [0, fy, cy], [0, 0, 1]]
// splat: the loaded Gaussian splat object (optional, used for bounds)
function setCameraFromExtrinsics(extrinsics, intrinsics, splat) {
if (!camera || !controls) return;
console.log('[GaussianViewer] Setting camera from extrinsics/intrinsics');
console.log('[GaussianViewer] Extrinsics:', JSON.stringify(extrinsics));
console.log('[GaussianViewer] Intrinsics:', JSON.stringify(intrinsics));
// Parse extrinsics to get camera position
// For a 4x4 extrinsics matrix [R|t], camera position in world space is -R^T * t
// But for identity matrix, camera is simply at origin
let camPosX = 0, camPosY = 0, camPosZ = 0;
if (extrinsics && Array.isArray(extrinsics) && extrinsics.length === 4) {
// Extract rotation and translation from 4x4 extrinsics
const R = [
[extrinsics[0][0], extrinsics[0][1], extrinsics[0][2]],
[extrinsics[1][0], extrinsics[1][1], extrinsics[1][2]],
[extrinsics[2][0], extrinsics[2][1], extrinsics[2][2]]
];
const t = [extrinsics[0][3], extrinsics[1][3], extrinsics[2][3]];
// Camera position = -R^T * t
camPosX = -(R[0][0] * t[0] + R[1][0] * t[1] + R[2][0] * t[2]);
camPosY = -(R[0][1] * t[0] + R[1][1] * t[1] + R[2][1] * t[2]);
camPosZ = -(R[0][2] * t[0] + R[1][2] * t[1] + R[2][2] * t[2]);
console.log('[GaussianViewer] Computed camera position from extrinsics:', camPosX, camPosY, camPosZ);
}
// Calculate FOV and set camera focal length from intrinsics
// intrinsics[0][0] = fx (focal length in pixels)
// intrinsics[1][1] = fy (focal length in pixels)
// intrinsics[1][2] = cy (principal point y = image_height / 2)
let targetZ = 2; // default
if (intrinsics && Array.isArray(intrinsics) && intrinsics.length >= 2) {
const fx = intrinsics[0][0];
const fy = intrinsics[1][1];
const cx = intrinsics[0][2];
const cy = intrinsics[1][2];
const imageWidth = cx * 2; // cx is typically image_width / 2
const imageHeight = cy * 2; // cy is typically image_height / 2
console.log('[GaussianViewer] Intrinsics: fx=', fx, 'fy=', fy, 'cx=', cx, 'cy=', cy);
console.log('[GaussianViewer] Image dimensions (from intrinsics):', imageWidth, 'x', imageHeight);
// Determine if we should lock resolution to native image size
// We lock resolution if intrinsics are provided to ensure "WYSIWYG" with high-res output
isResolutionLocked = true;
// Get current container size for scale compensation calculation
const containerWidth = canvas.clientWidth || 512;
const containerHeight = canvas.clientHeight || 512;
// Lock renderer size to NATIVE image resolution
console.log(`[GaussianViewer] Locking resolution to native: ${imageWidth}x${imageHeight}`);
renderer.setSize(imageWidth, imageHeight);
// Ensure CSS keeps it fitted to container
canvas.style.width = '100%';
canvas.style.height = '100%';
// Use NATIVE focal length (no scaling down)
camera.data.fx = fx;
camera.data.fy = fy;
// IMPORTANT: Calculate scale compensation
// gsplat.js renders points based on their world space size projected to screen space.
// When we view a 2048x2048 image in a 512x512 container, we are effectively zooming out.
// To make the points appear as "solid" as they do in the original training view,
// we need to compensate for this difference.
// Empirical observation suggests we need a stronger compensation than just the ratio.
// If user says Scale ~14 is good when ratio is ~4 (2048/512), then compensation might be squared ratio?
// Or maybe the points from this trainer are just naturally small.
// Let's stick to linear ratio for now but allow a base multiplier.
// For now, let's trust the ratio but maybe add a boost factor if needed.
// User reported needing ~14x scale. If image is 2048 and container 512, ratio is 4.
// 14 / 4 = 3.5. So maybe there's an inherent factor of 3-4 missing?
// Actually, let's just apply the ratio logic strictly first:
// If I render at 2048px width, a point of size 1 world unit covers X pixels.
// If I render at 512px width, it covers X/4 pixels.
// But visually, if I look at the 2048px image scaled down to 512px on my screen (via CSS),
// the point covers X pixels of the *buffer*, which is X/4 pixels on *screen*.
// Wait, if we render at high res, the points are physically "correct" size in pixels for that resolution.
// The issue is likely that "correct" size is often too sparse for point clouds that rely on splatting overlap.
// Let's use the ratio, but ensure we aren't double counting.
gaussianScaleCompensation = imageWidth / containerWidth;
// Add an extra boost because often raw splats are too small without the "dilation" effect of low-res rendering
// When you render low-res, rasterization snaps to pixels, effectively making small points relatively larger.
// High-res rendering loses this "free aliasing coverage".
// Let's add a heuristic boost factor.
// User said 14x was good. 14 / (2048/512) = 3.5.
// Let's try boosting the compensation significantly.
// gaussianScaleCompensation *= 3.0; // Boost factor
// Actually, let's change how we calculate this.
// If the user wants "WYSIWYG" relative to the preview size:
// We want the point to cover the same *percentage* of the screen width.
// PointRadius / ImageWidth = Constant.
// If we increase ImageWidth by factor S, PointRadius must increase by factor S.
// This is what gaussianScaleCompensation = imageWidth / containerWidth does.
// However, the user reports it's still sparse. This implies the *base* size is too small.
// Maybe the "preview" logic in other viewers implicitly boosts size?
// Or maybe the training output relies on a specific focal length?
// Let's force a minimum compensation to avoid sparsity.
// And let's default the UI scale slider to something higher if we detect high-res?
// No, better to fix the compensation calculation.
// Let's try applying a stronger multiplier.
// If resolution is locked, we are in "High Res Mode".
// Let's scale up by the ratio AND a bit more.
gaussianScaleCompensation = (imageWidth / containerWidth) * 3.0; // 3.0 is a heuristic "density boost"
console.log('[GaussianViewer] Native resolution:', imageWidth, 'x', imageHeight);
console.log('[GaussianViewer] Container size:', containerWidth, 'x', containerHeight);
console.log('[GaussianViewer] Gaussian scale compensation (with 3x boost):', gaussianScaleCompensation.toFixed(2), 'x');
// Calculate vertical FOV in radians for logging
const fovY = 2 * Math.atan(imageHeight / (2 * fy));
const fovYDeg = fovY * 180 / Math.PI;
console.log('[GaussianViewer] Calculated FOV:', fovYDeg, 'degrees');
// For SHARP, the scene is in front of the camera
// Use focal length to estimate a reasonable target distance
// The disparity range in metadata can help estimate scene depth
// For now, use a simple heuristic based on typical SHARP output
targetZ = Math.max(1, fy / imageHeight * 2);
console.log('[GaussianViewer] Target Z distance:', targetZ);
}
// Determine the target position (what the camera looks at)
// For the INITIAL view (matching original image), look STRAIGHT AHEAD along Z axis
// NOT at the scene center - that would tilt the camera
let targetX = 0, targetY = 0;
let orbitZ = targetZ; // For orbiting, use a closer point
if (splat && splat.bounds) {
const center = splat.bounds.center();
const size = splat.bounds.size();
// Use scene center Z for the view direction
targetZ = center.z;
// For orbit center, use distance to closest object + 50%
// Camera is at origin, so minZ is the distance to closest point
const minZ = center.z - size.z / 2;
orbitZ = minZ * 1.5; // 50% past the closest point
console.log('[GaussianViewer] Scene center:', center.x, -center.y, center.z);
console.log('[GaussianViewer] Closest point Z:', minZ.toFixed(1));
console.log('[GaussianViewer] Orbit center Z:', orbitZ.toFixed(1), '(closest + 50%)');
}
// SHARP uses Y-down coordinate system, so flip camera Y as well
camPosY = -camPosY;
// For SHARP: camera is at origin, scene is at positive Z
// The orbit controls work by having the camera orbit around a target point.
// To get the "original image view", we set camera at the computed position
// and look at the scene center.
const camPos = new SPLAT.Vector3(camPosX, camPosY, camPosZ);
// Use orbitZ for the target - closer to camera for better orbit feel
const targetPos = new SPLAT.Vector3(targetX, targetY, orbitZ);
// Store for reset
initialCameraData = {
position: camPos,
target: targetPos,
fx: camera.data.fx,
fy: camera.data.fy
};
// Set camera position - IMPORTANT: must set camera.position, not camera.data.position
// camera.position is used by OrbitControls
// camera.data.position is used for rendering
camera.position.x = camPosX;
camera.position.y = camPosY;
camera.position.z = camPosZ;
// Update orbit controls to orbit around the target point
// This recalculates the internal orbit state from current camera.position
controls.setCameraTarget(targetPos);
console.log('[GaussianViewer] Camera set to position:', camPosX, camPosY, camPosZ);
console.log('[GaussianViewer] Looking at target:', targetX, targetY, targetZ);
console.log('[GaussianViewer] This should match the original input image view');
}
// Load a PLY file from ArrayBuffer data
async function loadPLYFromData(arrayBuffer, filename, extrinsics, intrinsics) {
try {
// Reset lock state
isResolutionLocked = false;
// 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;
// Pre-calculate scale compensation from intrinsics BEFORE loading
// This ensures gaussian scales are correct from the start
gaussianScaleCompensation = 1.0; // Default if no intrinsics
if (intrinsics && Array.isArray(intrinsics) && intrinsics.length >= 2) {
const cx = intrinsics[0][2];
const imageWidth = cx * 2;
const containerWidth = canvas.clientWidth || 512;
// Base compensation: Ratio of Native / Display
let ratio = imageWidth / containerWidth;
// Heuristic: Add a "Density Boost" for high-res rendering.
// High-res rendering tends to make points look sparse because they don't overlap as much
// as they do in lower resolutions (due to sub-pixel vs pixel rasterization effects).
// We multiply by an extra factor to simulate the "fullness" of a lower-res view.
// Based on user feedback, a factor of ~10.0 (total) is often needed for ideal density.
const DENSITY_BOOST = 10.0;
gaussianScaleCompensation = ratio * DENSITY_BOOST;
console.log('[GaussianViewer] Pre-calculated scale compensation:', gaussianScaleCompensation.toFixed(2), 'x');
console.log('[GaussianViewer] Ratio:', ratio.toFixed(2), 'Density Boost:', DENSITY_BOOST);
console.log('[GaussianViewer] (Image:', imageWidth, 'px / Container:', containerWidth, 'px)');
}
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);
}
// Store original scales and log statistics
if (currentSplat.data && currentSplat.data.scales) {
const scales = currentSplat.data.scales;
const numGaussians = scales.length / 3;
let minScale = Infinity, maxScale = -Infinity, sumScale = 0;
// Store a copy of original scales (BEFORE compensation) for dynamic adjustment
// The stored scales include the compensation factor so user scale=1 shows correct size
originalScales = new Float32Array(scales.length);
for (let i = 0; i < scales.length; i++) {
// Apply compensation: gaussians need to be larger when focal length is scaled down
originalScales[i] = scales[i] * gaussianScaleCompensation;
}
console.log('[GaussianViewer] Stored compensated scales (factor:', gaussianScaleCompensation.toFixed(2), 'x)');
// Apply compensated scales immediately
for (let i = 0; i < scales.length; i++) {
scales[i] = originalScales[i];
}
// Mark data as changed so gsplat.js re-uploads to GPU
currentSplat.data.changed = true;
for (let i = 0; i < scales.length; i++) {
const s = scales[i];
minScale = Math.min(minScale, s);
maxScale = Math.max(maxScale, s);
sumScale += s;
}
const avgScale = sumScale / scales.length;
console.log('[GaussianViewer] === GAUSSIAN SCALE DEBUG (after compensation) ===');
console.log('[GaussianViewer] Number of Gaussians:', numGaussians);
console.log('[GaussianViewer] Compensated scale min:', minScale.toFixed(6));
console.log('[GaussianViewer] Compensated scale max:', maxScale.toFixed(6));
console.log('[GaussianViewer] Compensated scale avg:', avgScale.toFixed(6));
console.log('[GaussianViewer] =================================================');
} 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;
}
// Reset scale when loading new splat
scaleInput.value = 1.0;
currentScale = 1.0;
}
// Set camera from extrinsics and intrinsics if provided
if (extrinsics || intrinsics) {
setCameraFromExtrinsics(extrinsics, intrinsics, currentSplat);
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>`;
// 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()
}, '*');
}
}
async function loadPLYFromURL(url, filename, extrinsics, intrinsics) {
try {
// Reset lock state
isResolutionLocked = false;
// Clear ALL existing objects from scene
if (scene.objects && scene.objects.length > 0) {
console.log('[GaussianViewer] Clearing', scene.objects.length, 'existing objects from scene');
while (scene.objects.length > 0) {
scene.removeObject(scene.objects[0]);
}
}
currentSplat = null;
// Pre-calculate scale compensation from intrinsics BEFORE loading
gaussianScaleCompensation = 1.0;
if (intrinsics && Array.isArray(intrinsics) && intrinsics.length >= 2) {
const cx = intrinsics[0][2];
const imageWidth = cx * 2;
const containerWidth = canvas.clientWidth || 512;
let ratio = imageWidth / containerWidth;
const DENSITY_BOOST = 10.0;
gaussianScaleCompensation = ratio * DENSITY_BOOST;
console.log('[GaussianViewer] Pre-calculated scale compensation:', gaussianScaleCompensation.toFixed(2), 'x');
}
console.log('[GaussianViewer] Loading from URL:', url);
// Load PLY directly from URL — gsplat.js streams it
await SPLAT.PLYLoader.LoadAsync(url, scene);
// Get the loaded splat (last object in scene)
if (scene.objects && scene.objects.length > 0) {
currentSplat = scene.objects[scene.objects.length - 1];
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);
}
// Store original scales and apply compensation
if (currentSplat.data && currentSplat.data.scales) {
const scales = currentSplat.data.scales;
const numGaussians = scales.length / 3;
originalScales = new Float32Array(scales.length);
for (let i = 0; i < scales.length; i++) {
originalScales[i] = scales[i] * gaussianScaleCompensation;
}
for (let i = 0; i < scales.length; i++) {
scales[i] = originalScales[i];
}
currentSplat.data.changed = true;
console.log('[GaussianViewer] Applied scale compensation:', gaussianScaleCompensation.toFixed(2), 'x,', numGaussians, 'gaussians');
} else {
originalScales = null;
}
scaleInput.value = 1.0;
currentScale = 1.0;
}
// Set camera from extrinsics and intrinsics if provided
if (extrinsics || intrinsics) {
setCameraFromExtrinsics(extrinsics, intrinsics, currentSplat);
if (controls) controls.update();
}
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 from URL');
} 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);
}
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();
}
}
// Helper to draw black background behind transparent canvas for screenshot
// This solves the issue where transparent parts of the gaussian render appear weirdly when pasted
// or viewed in tools that don't handle premultiplied alpha correctly, or simply when the
// viewer background (CSS) was providing the black color but the canvas itself was transparent.
function getCanvasWithBlackBackground(sourceCanvas) {
const tempCanvas = document.createElement('canvas');
tempCanvas.width = sourceCanvas.width;
tempCanvas.height = sourceCanvas.height;
const ctx = tempCanvas.getContext('2d');
// Fill with black first
ctx.fillStyle = '#000000';
ctx.fillRect(0, 0, tempCanvas.width, tempCanvas.height);
// Draw original canvas on top
ctx.drawImage(sourceCanvas, 0, 0);
return tempCanvas;
}
// Take screenshot directly from canvas
// Avoiding captureStream() because it can introduce artifacts or capture inter-frame states
// Since we are now rendering at full resolution (WYSIWYG), toDataURL is the most accurate representation
async function takeScreenshot() {
if (!renderer || !renderer.canvas) return;
try {
const canvas = renderer.canvas;
const width = canvas.width;
const height = canvas.height;
// Pause the animation loop to ensure we capture a stable frame
if (animationId) cancelAnimationFrame(animationId);
// Force a single synchronous render to ensure the buffer is populated exactly as expected
if (scene && camera) {
controls.update();
renderer.render(scene, camera);
}
console.log('[GaussianViewer] Capturing screenshot via toDataURL:', width, 'x', height);
// Composite with black background to ensure consistency with preview
// The preview looks black because of CSS background, but the canvas might be transparent
const finalCanvas = getCanvasWithBlackBackground(canvas);
const dataUrl = finalCanvas.toDataURL('image/png');
window.parent.postMessage({
type: 'SCREENSHOT',
image: dataUrl,
timestamp: Date.now()
}, '*');
resumeAnimationLoop();
console.log('[GaussianViewer] Screenshot captured successfully');
} catch (err) {
console.error('[GaussianViewer] Screenshot error:', err);
resumeAnimationLoop();
}
}
// Copy image to clipboard directly from canvas
async function copyImageToClipboard() {
if (!renderer || !renderer.canvas) return;
try {
const canvas = renderer.canvas;
const width = canvas.width;
const height = canvas.height;
// Pause the animation loop
if (animationId) cancelAnimationFrame(animationId);
// Force a single synchronous render
if (scene && camera) {
controls.update();
renderer.render(scene, camera);
}
console.log('[GaussianViewer] Copying to clipboard via toBlob:', width, 'x', height);
// Composite with black background
const finalCanvas = getCanvasWithBlackBackground(canvas);
// Direct blob creation from composited canvas
const blob = await canvasToBlob(finalCanvas);
try {
await navigator.clipboard.write([
new ClipboardItem({ 'image/png': blob })
]);
console.log('[GaussianViewer] Image copied to clipboard:', width, 'x', height);
window.parent.postMessage({
type: 'COPY_IMAGE',
success: true,
timestamp: Date.now()
}, '*');
} catch (clipboardError) {
console.error('[GaussianViewer] Clipboard write failed:', clipboardError);
window.parent.postMessage({
type: 'COPY_IMAGE',
success: false,
error: 'Clipboard write failed: ' + clipboardError.message,
timestamp: Date.now()
}, '*');
}
resumeAnimationLoop();
} catch (err) {
console.error('[GaussianViewer] Copy to clipboard error:', err);
resumeAnimationLoop();
window.parent.postMessage({
type: 'COPY_IMAGE',
success: false,
error: err.message,
timestamp: Date.now()
}, '*');
}
}
// Helper function to convert canvas to blob
function canvasToBlob(canvas) {
return new Promise((resolve, reject) => {
canvas.toBlob(blob => {
if (blob) {
resolve(blob);
} else {
reject(new Error('Canvas toBlob failed'));
}
}, 'image/png');
});
}
// Gaussian scale control
const scaleInput = document.getElementById('gaussianScaleValue');
let currentScale = 1.0;
let scaleUpdatePending = false;
let lastScaleValue = 1.0;
function updateGaussianScale(newScale) {
newScale = Math.max(0.01, Math.min(100, parseFloat(newScale) || 1.0));
currentScale = newScale;
// Update input
scaleInput.value = newScale;
// Apply scale to current splat by modifying the underlying scale data
// gsplat.js applies Math.exp() during PLY loading, so data.scales contains LINEAR values
// To multiply visual size by factor f, we multiply the scale values directly
if (currentSplat && currentSplat.data && currentSplat.data.scales && originalScales) {
console.log('[GaussianViewer] Updating gaussian scales with factor:', newScale);
const scales = currentSplat.data.scales;
// Apply scale factor by multiplying (scales are in linear space)
for (let i = 0; i < scales.length; i++) {
scales[i] = originalScales[i] * newScale;
}
// Mark data as changed to trigger GPU re-upload
// gsplat.js checks data.changed and data.detached
currentSplat.data.changed = true;
currentSplat.data.detached = false;
console.log('[GaussianViewer] Set data.changed=true, data.detached=false');
// Get the render data (could be _renderData or renderData)
const renderData = renderer._renderData || renderer.renderData;
if (renderData) {
console.log('[GaussianViewer] Found renderData, keys:', Object.keys(renderData).join(', '));
// Mark the splat as dirty
if (renderData.markDirty) {
renderData.markDirty(currentSplat);
console.log('[GaussianViewer] Called markDirty()');
}
// Try forcing the flags directly
renderData.dataChanged = true;
console.log('[GaussianViewer] Set renderData.dataChanged = true');
// Check needsRebuild status
console.log('[GaussianViewer] renderData.needsRebuild:', renderData.needsRebuild);
console.log('[GaussianViewer] renderData._updating size:', renderData._updating?.size);
console.log('[GaussianViewer] renderData._dirty size:', renderData._dirty?.size);
} else {
console.log('[GaussianViewer] No renderData found on renderer');
console.log('[GaussianViewer] renderer keys:', Object.keys(renderer).join(', '));
}
// Also dispatch change event (triggers scene to pick up changes)
if (currentSplat.dispatchEvent && currentSplat._changeEvent) {
currentSplat.dispatchEvent(currentSplat._changeEvent);
console.log('[GaussianViewer] Dispatched change event');
}
// Throttled force refresh by removing and re-adding the splat to scene
// This is a workaround for gsplat.js not supporting dynamic scale updates
if (scene && scene.removeObject && scene.addObject) {
lastScaleValue = newScale; // Always update to latest value
if (!scaleUpdatePending) {
scaleUpdatePending = true;
const splatToRefresh = currentSplat; // Capture reference
// Delay the refresh to avoid flickering during slider drag
setTimeout(() => {
scaleUpdatePending = false;
// Only refresh if splat is still current
if (splatToRefresh === currentSplat) {
console.log('[GaussianViewer] Forcing refresh by re-adding splat to scene');
scene.removeObject(splatToRefresh);
scene.addObject(splatToRefresh);
console.log('[GaussianViewer] Splat re-added to scene');
} else {
console.log('[GaussianViewer] Skipping refresh, splat changed during delay');
}
}, 150); // 150ms debounce for smoother dragging
}
}
console.log('[GaussianViewer] Scale update complete. New scale[0]:', scales[0].toFixed(6),
'original[0]:', originalScales[0].toFixed(6), 'multiplier:', newScale);
} else if (currentSplat) {
console.log('[GaussianViewer] Cannot update scales - missing data or originalScales');
console.log('[GaussianViewer] currentSplat.data:', !!currentSplat.data);
console.log('[GaussianViewer] currentSplat.data.scales:', !!(currentSplat.data && currentSplat.data.scales));
console.log('[GaussianViewer] originalScales:', !!originalScales);
}
}
scaleInput.addEventListener('change', (e) => updateGaussianScale(e.target.value));
// Event listeners
document.getElementById('resetCamera').addEventListener('click', resetCamera);
document.getElementById('screenshot').addEventListener('click', takeScreenshot);
document.getElementById('copyImage').addEventListener('click', copyImageToClipboard);
// Listen for messages from parent
window.addEventListener('message', (event) => {
const { type, data, url, filename, extrinsics, intrinsics } = event.data;
if (type === 'LOAD_MESH_URL' && url) {
console.log('[GaussianViewer] Received LOAD_MESH_URL:', url);
loadPLYFromURL(url, filename || 'gaussian.ply', extrinsics, intrinsics);
} else if (type === 'LOAD_MESH_DATA' && data) {
console.log('[GaussianViewer] Received LOAD_MESH_DATA, size:', data.byteLength);
loadPLYFromData(data, filename || 'gaussian.ply', extrinsics, intrinsics);
}
});
// Initialize on load
initViewer();
console.log('[GaussianViewer] Ready - waiting for LOAD_MESH message');
</script>
</body>
</html>