From b10d8a8b0eb11b6cfe9bf3bf8eb861fd69cc1dc6 Mon Sep 17 00:00:00 2001 From: Andrea Pozzetti Date: Thu, 12 Feb 2026 21:48:40 +0100 Subject: [PATCH] pbr viewing --- nodes/visualization/preview_mesh_vtk.py | 38 +++++++++-- web/js/gaussian_preview.js | 28 ++++++++- web/viewer_gaussian.html | 83 ++++++++++++++++++------- web/viewer_pbr.html | 38 +++++++++-- web/viewer_vtk.html | 8 ++- 5 files changed, 158 insertions(+), 37 deletions(-) diff --git a/nodes/visualization/preview_mesh_vtk.py b/nodes/visualization/preview_mesh_vtk.py index 03f4a6a8..8fa26b15 100644 --- a/nodes/visualization/preview_mesh_vtk.py +++ b/nodes/visualization/preview_mesh_vtk.py @@ -41,9 +41,12 @@ class PreviewMeshVTKNode: def INPUT_TYPES(cls): return { "required": { - "trimesh": ("TRIMESH",), "mode": (["fields", "texture", "texture (PBR)"], {"default": "fields"}), }, + "optional": { + "trimesh": ("TRIMESH",), + "voxelgrid": ("VOXELGRID",), + }, } RETURN_TYPES = () @@ -51,7 +54,7 @@ class PreviewMeshVTKNode: FUNCTION = "preview_mesh_vtk" CATEGORY = "geompack/visualization" - def preview_mesh_vtk(self, trimesh, mode="fields"): + def preview_mesh_vtk(self, mode="fields", trimesh=None, voxelgrid=None): """ Export mesh and prepare for VTK.js preview. @@ -60,17 +63,26 @@ class PreviewMeshVTKNode: - texture: Textured mesh visualization with materials (GLB export) Args: - trimesh: Input trimesh_module.Trimesh object or VOXEL_GRID tensor mode: Visualization mode - "fields" or "texture" + trimesh: Input trimesh_module.Trimesh object (optional) + voxelgrid: Input trimesh.VoxelGrid object (optional) Returns: dict: UI data for frontend widget """ + # Use whichever input is provided + if trimesh is None and voxelgrid is None: + raise ValueError("Either trimesh or voxelgrid input is required") + + mesh_input = trimesh if trimesh is not None else voxelgrid + # Handle VOXEL_GRID input (trimesh.VoxelGrid from MeshToVoxel node) - if hasattr(trimesh, 'as_boxes'): # It's a trimesh.VoxelGrid - voxel_shape = trimesh.matrix.shape - trimesh = trimesh.as_boxes() + if hasattr(mesh_input, 'as_boxes'): # It's a trimesh.VoxelGrid + voxel_shape = mesh_input.matrix.shape + trimesh = mesh_input.as_boxes() print(f"[PreviewMeshVTK] Converted voxel grid {voxel_shape} to box mesh: {len(trimesh.vertices)} vertices") + else: + trimesh = mesh_input print(f"[PreviewMeshVTK] Preparing preview: {get_geometry_type(trimesh)} - {len(trimesh.vertices)} vertices, {get_face_count(trimesh)} faces") @@ -133,6 +145,20 @@ class PreviewMeshVTKNode: else: filepath = os.path.join(tempfile.gettempdir(), filename) + # Set alpha mode based on viewing mode + if mode == "texture": + # Texture mode: force opaque for solid color viewing (RGB only) + if hasattr(trimesh, 'visual') and hasattr(trimesh.visual, 'material'): + if hasattr(trimesh.visual.material, 'alphaMode'): + trimesh.visual.material.alphaMode = 'OPAQUE' + print(f"[PreviewMeshVTK] Set alphaMode to OPAQUE for texture mode") + elif mode == "texture (PBR)": + # PBR mode: use BLEND for transparency support + if hasattr(trimesh, 'visual') and hasattr(trimesh.visual, 'material'): + if hasattr(trimesh.visual.material, 'alphaMode'): + trimesh.visual.material.alphaMode = 'BLEND' + print(f"[PreviewMeshVTK] Set alphaMode to BLEND for PBR mode") + # Export mesh try: if mode in ("texture", "texture (PBR)"): diff --git a/web/js/gaussian_preview.js b/web/js/gaussian_preview.js index 646700ee..5c6bcd79 100644 --- a/web/js/gaussian_preview.js +++ b/web/js/gaussian_preview.js @@ -68,12 +68,31 @@ app.registerExtension({ setValue(v) { } }); - widget.computeSize = () => [512, 580]; + // Store reference to node for dynamic resizing + const node = this; + let currentNodeSize = [512, 580]; + + widget.computeSize = () => currentNodeSize; // Store references this.gaussianViewerIframe = iframe; this.gaussianInfoPanel = infoPanel; + // Function to resize node dynamically + this.resizeToAspectRatio = function(imageWidth, imageHeight) { + const aspectRatio = imageWidth / imageHeight; + const nodeWidth = 512; + const viewerHeight = Math.round(nodeWidth / aspectRatio); + const nodeHeight = viewerHeight + 60; // Add space for info panel + + currentNodeSize = [nodeWidth, nodeHeight]; + node.setSize(currentNodeSize); + node.setDirtyCanvas(true, true); + app.graph.setDirtyCanvas(true, true); + + console.log("[GeomPack Gaussian] Resized node to:", nodeWidth, "x", nodeHeight, "(aspect ratio:", aspectRatio.toFixed(2), ")"); + }; + // Track iframe load state let iframeLoaded = false; iframe.addEventListener('load', () => { @@ -158,6 +177,13 @@ app.registerExtension({ const extrinsics = message.extrinsics?.[0] || null; const intrinsics = message.intrinsics?.[0] || null; + // Resize node to match image aspect ratio from intrinsics + if (intrinsics && intrinsics[0] && intrinsics[1]) { + const imageWidth = intrinsics[0][2] * 2; // cx * 2 + const imageHeight = intrinsics[1][2] * 2; // cy * 2 + this.resizeToAspectRatio(imageWidth, imageHeight); + } + // Update info panel infoPanel.innerHTML = `
diff --git a/web/viewer_gaussian.html b/web/viewer_gaussian.html index aec0b3a5..6123d4a3 100644 --- a/web/viewer_gaussian.html +++ b/web/viewer_gaussian.html @@ -221,15 +221,16 @@ const canvasWidth = canvas.clientWidth || 512; const canvasHeight = canvas.clientHeight || 512; - // Scale focal lengths proportionally to canvas size - const scaledFx = fx * (canvasWidth / imageWidth); - const scaledFy = fy * (canvasHeight / imageHeight); + // Use uniform scale factor (canvas should have same aspect ratio as image) + // This keeps fx = fy for undistorted view + const scale = canvasWidth / imageWidth; + const scaledF = fx * scale; // fx and fy are equal in SHARP - camera.data.fx = scaledFx; - camera.data.fy = scaledFy; + camera.data.fx = scaledF; + camera.data.fy = scaledF; console.log('[GaussianViewer] Canvas size:', canvasWidth, 'x', canvasHeight); - console.log('[GaussianViewer] Scaled focal lengths: fx=', scaledFx, 'fy=', scaledFy); + console.log('[GaussianViewer] Uniform scale:', scale, 'scaled focal length:', scaledF); // Calculate vertical FOV in radians for logging const fovY = 2 * Math.atan(imageHeight / (2 * fy)); @@ -245,23 +246,38 @@ } // Determine the target position (what the camera looks at) - // Use scene bounds center if available, otherwise use calculated targetZ + // 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(); - targetX = center.x; - targetY = center.y; + const size = splat.bounds.size(); + + // Use scene center Z for the view direction targetZ = center.z; - console.log('[GaussianViewer] Using scene center as target:', targetX, targetY, targetZ); + + // 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); - const targetPos = new SPLAT.Vector3(targetX, targetY, targetZ); + // Use orbitZ for the target - closer to camera for better orbit feel + const targetPos = new SPLAT.Vector3(targetX, targetY, orbitZ); // Store for reset initialCameraData = { @@ -271,10 +287,15 @@ fy: camera.data.fy }; - // Set camera position - camera.data.position = camPos; + // 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); @@ -285,11 +306,15 @@ // Load a PLY file from ArrayBuffer data async function loadPLYFromData(arrayBuffer, filename, extrinsics, intrinsics) { try { - // Clear existing splat - if (currentSplat) { - scene.removeObject(currentSplat); - currentSplat = null; + // 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; console.log('[GaussianViewer] Loading from data, size:', arrayBuffer.byteLength); @@ -351,20 +376,32 @@ function resetCamera() { if (camera && controls) { if (initialCameraData) { - camera.data.position = initialCameraData.position; - if (initialCameraData.target) { - controls.setCameraTarget(initialCameraData.target); - } + // 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; } - console.log('[GaussianViewer] Reset camera to initial position'); + + // 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.data.position = new SPLAT.Vector3(0, 0, 5); + 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(); diff --git a/web/viewer_pbr.html b/web/viewer_pbr.html index 0d012852..0bd4ec0d 100644 --- a/web/viewer_pbr.html +++ b/web/viewer_pbr.html @@ -78,6 +78,7 @@ renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.0; + renderer.sortObjects = true; // Enable proper transparency sorting // Controls const controls = new OrbitControls(camera, canvas); @@ -106,18 +107,26 @@ const extPath = getExtensionPath(); const exrLoader = new EXRLoader(); + const exrPath = `${extPath}/assets/studio.exr`; + console.log('[GeomPack PBR Viewer] Loading environment map from:', exrPath); + exrLoader.load( - `${extPath}/assets/studio.exr`, + exrPath, (texture) => { texture.mapping = THREE.EquirectangularReflectionMapping; scene.environment = texture; envMapLoaded = true; - console.log('[GeomPack PBR Viewer] Environment map loaded'); + console.log('[GeomPack PBR Viewer] Environment map loaded successfully'); resolve(texture); }, - undefined, + (progress) => { + if (progress.lengthComputable) { + console.log('[GeomPack PBR Viewer] Env map loading:', Math.round(progress.loaded / progress.total * 100) + '%'); + } + }, (error) => { - console.warn('[GeomPack PBR Viewer] Could not load EXR environment, using fallback'); + console.error('[GeomPack PBR Viewer] Failed to load EXR environment:', error); + console.warn('[GeomPack PBR Viewer] Using fallback gradient environment'); // Fallback: create a simple gradient environment createFallbackEnvironment(); resolve(null); @@ -252,6 +261,20 @@ materials.forEach(mat => { if (mat.isMeshStandardMaterial || mat.isMeshPhysicalMaterial) { mat.envMapIntensity = 1.0; + + // Enable transparency support + // Check if baseColorTexture has alpha channel + const hasAlphaMap = mat.map && mat.map.format === THREE.RGBAFormat; + const hasAlphaTest = mat.alphaTest > 0; + const needsTransparency = mat.transparent || mat.opacity < 1.0 || hasAlphaMap; + + if (needsTransparency || hasAlphaMap) { + mat.transparent = true; + mat.alphaTest = 0.01; // Discard nearly-transparent pixels + mat.side = THREE.DoubleSide; // Render both sides + mat.depthWrite = true; // Keep depth writing for proper sorting + } + mat.needsUpdate = true; // Check if has textures @@ -263,9 +286,14 @@ name: mat.name, metalness: mat.metalness, roughness: mat.roughness, + opacity: mat.opacity, + transparent: mat.transparent, + alphaTest: mat.alphaTest, hasMap: !!mat.map, + mapFormat: mat.map ? mat.map.format : 'none', hasMetalnessMap: !!mat.metalnessMap, - hasRoughnessMap: !!mat.roughnessMap + hasRoughnessMap: !!mat.roughnessMap, + hasAlphaMap: hasAlphaMap }); } }); diff --git a/web/viewer_vtk.html b/web/viewer_vtk.html index 4e990eb4..305a3006 100644 --- a/web/viewer_vtk.html +++ b/web/viewer_vtk.html @@ -285,8 +285,12 @@ } else { // For meshes, use normals filter for proper shading const normalsFilter = vtkPolyDataNormals.newInstance(); - normalsFilter.setInputConnection(currentReader.getOutputPort()); - mapper.setInputConnection(normalsFilter.getOutputPort()); + normalsFilter.setInputData(polydata); + normalsFilter.update(); + // Use the filtered polydata so scalars are preserved + const filteredPolyData = normalsFilter.getOutputData(); + mapper.setInputData(filteredPolyData); + currentPolyData = filteredPolyData; // Update reference for field visualization } mapper.update();