ComfyUI auto-imports every web/**/*.js into the shared main page; the four big bundles (three, vtk-gltf, viewer-bundle-vtk, gsplat) only ever run inside the viewer iframes but were getting pulled into the main document too, where they wrote window.THREE / window.vtk / globalThis.* and could collide with any other 3D pack. The **/*.js glob only matches .js, so renaming to .mjs drops them from the auto-import while the iframes still load them via classic <script src> (mime resolves to text/javascript). window.* pollution from these bundles is gone from the main realm.
959 lines
44 KiB
HTML
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.mjs"></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>
|