diff --git a/pyproject.toml b/pyproject.toml
index 9fc5d35..bfa7ca9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,37 +1,16 @@
[project]
name = "ComfyUI-TurntableGSViewer"
description = "Tiny Gaussian Splat turntable viewer node for ComfyUI."
-version = "0.1.0"
+version = "0.0.1"
license = {file = "LICENSE"}
-# classifiers = [
-# # For OS-independent nodes (works on all operating systems)
-# "Operating System :: OS Independent",
-#
-# # OR for OS-specific nodes, specify the supported systems:
-# "Operating System :: Microsoft :: Windows", # Windows specific
-# "Operating System :: POSIX :: Linux", # Linux specific
-# "Operating System :: MacOS", # macOS specific
-#
-# # GPU Accelerator support. Pick the ones that are supported by your extension.
-# "Environment :: GPU :: NVIDIA CUDA", # NVIDIA CUDA support
-# "Environment :: GPU :: AMD ROCm", # AMD ROCm support
-# "Environment :: GPU :: Intel Arc", # Intel Arc support
-# "Environment :: NPU :: Huawei Ascend", # Huawei Ascend support
-# "Environment :: GPU :: Apple Metal", # Apple Metal support
-# ]
-
-dependencies = ["numpy>=1.23", "plyfile>=1.0.3"]
[project.urls]
Repository = "https://github.com/PozzettiAndrea/ComfyUI-TurntableGSViewer"
-# Used by Comfy Registry https://registry.comfy.org
Documentation = "https://github.com/PozzettiAndrea/ComfyUI-TurntableGSViewer"
-"Bug Tracker" = "https://github.com/PozzettiAndrea/ComfyUI-TurntableGSViewer/issues"
[tool.comfy]
PublisherId = "pozzettiandrea"
DisplayName = "ComfyUI-TurntableGSViewer"
Icon = ""
includes = []
-# "requires-comfyui" = ">=1.0.0" # ComfyUI version compatibility
diff --git a/web/viewer_gaussian.html b/web/viewer_gaussian.html
index 5306510..92f005e 100644
--- a/web/viewer_gaussian.html
+++ b/web/viewer_gaussian.html
@@ -239,6 +239,9 @@
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;
@@ -304,6 +307,65 @@
// 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
@@ -333,6 +395,7 @@
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 };
@@ -340,6 +403,7 @@
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.
@@ -362,7 +426,9 @@
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
@@ -469,6 +535,13 @@
infoPanel.classList.remove('hidden');
infoContent.innerHTML = `Gaussian Splat Loaded
${filename}`;
+ // 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',
@@ -511,6 +584,11 @@
// 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);
@@ -523,6 +601,7 @@
console.log('[GaussianViewer] Reset camera to default position (0, 0, 5)');
}
controls.update();
+ emitCameraState();
}
}
@@ -644,26 +723,29 @@
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)) return;
- const colors = currentSplat.data.colors;
- for (let i = 0; i < originalOpacities.length; i++) {
- colors[i * opacityStride + 3] = Math.round(originalOpacities[i] * 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;
}
- 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);
- else currentFovDeg = newFov;
+ if (currentSplat) frameOnBounds(currentSplat, newFov); // emits state itself
+ else { currentFovDeg = newFov; emitCameraState(); }
}
// FOV
@@ -692,11 +774,18 @@
// Listen for messages from parent
window.addEventListener('message', (event) => {
- const { type, data, filename, extrinsics, intrinsics } = event.data;
+ 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;
}
});