Merge main (fullscreen spectate + playcanvas update) into dev

Take main's versions of conflicting dual viewer files as base.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
PozzettiAndrea
2026-06-07 09:00:00 +02:00
co-authored by Claude Opus 4.6
8 changed files with 3892 additions and 2944 deletions
+3
View File
@@ -11,6 +11,7 @@ from .preview_gaussian import PreviewGaussians
from .preview_gaussian_spectate import PreviewGaussianSpectate
from .preview_gaussian_dual import PreviewGaussianDual
from .preview_gaussian_camera import PreviewGaussianCamera
from .preview_gaussian_dual import PreviewGaussianDual
from .merge_gaussians import GaussianMerge
from .load_ply import LoadPLY
from .load_ply_output import LoadPLYOutput
@@ -36,6 +37,7 @@ NODE_CLASS_MAPPINGS = {
"GaussianExport": GaussianExport,
"TransformGaussian": TransformGaussian,
"GaussiansFromPointCloud": GaussiansFromPointCloud,
"PreviewGaussianDual": PreviewGaussianDual,
}
NODE_DISPLAY_NAME_MAPPINGS = {
@@ -50,4 +52,5 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"GaussianExport": "Gaussian Export",
"TransformGaussian": "Transform Gaussian",
"GaussiansFromPointCloud": "Gaussians From Point Cloud",
"PreviewGaussianDual": "Preview Gaussian Dual",
}
+10 -17
View File
@@ -67,22 +67,16 @@ class PreviewGaussians:
"default": 50.0, "min": 5.0, "max": 170.0, "step": 1.0,
"tooltip": "Vertical field of view in degrees",
}),
"image_width": ("INT", {
"default": 512, "min": 64, "max": 4096, "step": 8,
}),
"image_height": ("INT", {
"default": 512, "min": 64, "max": 4096, "step": 8,
}),
"renderer": (["spark", "playcanvas"], {
"default": "spark",
"renderer": (["playcanvas", "spark"], {
"default": "playcanvas",
"tooltip": (
"playcanvas — PlayCanvas Engine v2.19 GSplat renderer. "
"WebGPU when available, WebGL2 fallback. Good "
"performance on large scenes (5M+ splats). "
"\n"
"spark — Three.js + WebGL2. Best SH3 fidelity, "
"auto-detects all formats (PLY, compressed.ply, SPZ, "
"KSPLAT, SOG, SPLAT). "
"\n"
"playcanvas — WebGPU path (currently falls back to "
"spark; real adapter pending). Will win on large "
"scenes (5M+ splats) when implemented."
"KSPLAT, SOG, SPLAT)."
),
}),
"transport_format": (["ply", "spz"], {
@@ -107,7 +101,7 @@ class PreviewGaussians:
FUNCTION = "preview"
CATEGORY = "viewer"
def preview(self, ply_path, fov_degrees, image_width, image_height, renderer, transport_format="ply"):
def preview(self, ply_path, fov_degrees, renderer, transport_format="ply"):
if not ply_path:
return {"ui": {"error": ["No PLY path provided"]}}
if not os.path.exists(ply_path):
@@ -117,18 +111,17 @@ class PreviewGaussians:
file_size_mb = round(os.path.getsize(ply_path) / (1024 * 1024), 2)
num_gaussians = _count_gaussians(ply_path)
intrinsics = get_default_intrinsics(image_width, image_height, fov_degrees)
extrinsics = get_default_extrinsics()
return {"ui": {
"ply_file": [filename],
"filename": [filename],
"ply_type": [folder_kind], # "input" | "output" — JS passes to /view?type=
"ply_type": [folder_kind],
"ply_subfolder": [subfolder],
"file_size_mb": [file_size_mb],
"num_gaussians": [num_gaussians],
"extrinsics": [extrinsics],
"intrinsics": [intrinsics],
"intrinsics": [None],
"fov_degrees": [fov_degrees],
"renderer": [renderer],
"transport_format": [transport_format],
+36 -50
View File
@@ -1,13 +1,10 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Dual Gaussian splat viewer — side-by-side or slider comparison."""
"""PreviewGaussianDual — side-by-side or slider comparison of two gaussian splats."""
import os
from .common import (
get_default_extrinsics,
get_default_intrinsics,
)
from .common import get_default_extrinsics
from .preview_gaussian import _resolve_for_view, _count_gaussians
@@ -29,31 +26,25 @@ class PreviewGaussianDual:
"layout": (["side_by_side", "slider"], {
"default": "side_by_side",
"tooltip": (
"side_by_side — two synchronized viewports next to each other. "
"\n"
"slider — single viewport with a draggable divider revealing "
"one splat on each side."
"side_by_side — two synchronized viewports. "
"slider — overlaid with a draggable divider."
),
}),
"fov_degrees": ("FLOAT", {
"default": 50.0, "min": 5.0, "max": 170.0, "step": 1.0,
"tooltip": "Vertical field of view in degrees",
}),
"image_width": ("INT", {
"default": 512, "min": 64, "max": 4096, "step": 8,
}),
"image_height": ("INT", {
"default": 512, "min": 64, "max": 4096, "step": 8,
}),
"renderer": (["spark", "playcanvas"], {
"default": "spark",
"renderer": (["playcanvas", "spark"], {
"default": "playcanvas",
"tooltip": (
"playcanvas — PlayCanvas Engine v2.19 GSplat. "
"spark — Three.js + WebGL2, all formats."
),
}),
"transport_format": (["ply", "spz"], {
"default": "ply",
"tooltip": (
"ply — lossless float32. "
"\n"
"spz — ~9x smaller, server transcodes once and caches."
"ply — lossless, larger. "
"spz — server transcodes to SPZ v2, ~9x smaller."
),
}),
},
@@ -65,34 +56,29 @@ class PreviewGaussianDual:
CATEGORY = "viewer"
def preview(self, ply_path_1, ply_path_2, layout, fov_degrees,
image_width, image_height, renderer, transport_format="ply"):
results = {}
for idx, path in enumerate([ply_path_1, ply_path_2], start=1):
suffix = str(idx)
if not path:
return {"ui": {"error": [f"No PLY path provided for input {idx}"]}}
if not os.path.exists(path):
return {"ui": {"error": [f"File not found (input {idx}): {path}"]}}
renderer, transport_format="ply"):
for i, p in enumerate([ply_path_1, ply_path_2], 1):
if not p:
return {"ui": {"error": [f"No PLY path provided for input {i}"]}}
if not os.path.exists(p):
return {"ui": {"error": [f"File not found (input {i}): {p}"]}}
filename, subfolder, folder_kind = _resolve_for_view(path)
file_size_mb = round(os.path.getsize(path) / (1024 * 1024), 2)
num_gaussians = _count_gaussians(path)
fn1, sub1, kind1 = _resolve_for_view(ply_path_1)
fn2, sub2, kind2 = _resolve_for_view(ply_path_2)
results[f"ply_file_{suffix}"] = [filename]
results[f"filename_{suffix}"] = [filename]
results[f"ply_type_{suffix}"] = [folder_kind]
results[f"ply_subfolder_{suffix}"] = [subfolder]
results[f"file_size_mb_{suffix}"] = [file_size_mb]
results[f"num_gaussians_{suffix}"] = [num_gaussians]
intrinsics = get_default_intrinsics(image_width, image_height, fov_degrees)
extrinsics = get_default_extrinsics()
results["layout"] = [layout]
results["extrinsics"] = [extrinsics]
results["intrinsics"] = [intrinsics]
results["fov_degrees"] = [fov_degrees]
results["renderer"] = [renderer]
results["transport_format"] = [transport_format]
return {"ui": results}
return {"ui": {
"ply_file_1": [fn1],
"ply_type_1": [kind1],
"ply_subfolder_1": [sub1],
"file_size_mb_1": [round(os.path.getsize(ply_path_1) / (1024 * 1024), 2)],
"num_gaussians_1": [_count_gaussians(ply_path_1)],
"ply_file_2": [fn2],
"ply_type_2": [kind2],
"ply_subfolder_2": [sub2],
"file_size_mb_2": [round(os.path.getsize(ply_path_2) / (1024 * 1024), 2)],
"num_gaussians_2": [_count_gaussians(ply_path_2)],
"layout": [layout],
"fov_degrees": [fov_degrees],
"renderer": [renderer],
"transport_format": [transport_format],
}}
+7 -14
View File
@@ -48,12 +48,6 @@ class PreviewGaussianSpectate:
"from PreviewGaussians' 50."
),
}),
"image_width": ("INT", {
"default": 768, "min": 64, "max": 4096, "step": 8,
}),
"image_height": ("INT", {
"default": 512, "min": 64, "max": 4096, "step": 8,
}),
"move_speed": ("FLOAT", {
"default": 2.0, "min": 0.05, "max": 50.0, "step": 0.05,
"tooltip": (
@@ -62,12 +56,12 @@ class PreviewGaussianSpectate:
"scroll the mouse wheel. Shift = 3x boost."
),
}),
"renderer": (["spark", "playcanvas"], {
"default": "spark",
"renderer": (["playcanvas", "spark"], {
"default": "playcanvas",
"tooltip": (
"spark — Three.js + WebGL2, all formats. "
"playcanvas — WebGPU adapter (falls back to spark "
"for now)."
"playcanvas — PlayCanvas Engine v2.19 GSplat. "
"WebGPU when available, WebGL2 fallback. "
"spark — Three.js + WebGL2, all formats."
),
}),
"transport_format": (["ply", "spz"], {
@@ -86,7 +80,7 @@ class PreviewGaussianSpectate:
FUNCTION = "preview"
CATEGORY = "viewer"
def preview(self, ply_path, fov_degrees, image_width, image_height,
def preview(self, ply_path, fov_degrees,
move_speed, renderer, transport_format="ply"):
if not ply_path:
return {"ui": {"error": ["No PLY path provided"]}}
@@ -97,7 +91,6 @@ class PreviewGaussianSpectate:
file_size_mb = round(os.path.getsize(ply_path) / (1024 * 1024), 2)
num_gaussians = _count_gaussians(ply_path)
intrinsics = get_default_intrinsics(image_width, image_height, fov_degrees)
extrinsics = get_default_extrinsics()
return {"ui": {
@@ -108,7 +101,7 @@ class PreviewGaussianSpectate:
"file_size_mb": [file_size_mb],
"num_gaussians": [num_gaussians],
"extrinsics": [extrinsics],
"intrinsics": [intrinsics],
"intrinsics": [None],
"fov_degrees": [fov_degrees],
"renderer": [renderer],
"transport_format": [transport_format],
+171 -3
View File
@@ -542,7 +542,7 @@ app.registerExtension({
// immediately + wrap each widget's callback to keep the
// mirror up to date.
const TRACKED = new Set([
"fov_degrees", "image_width", "image_height",
"fov_degrees",
"renderer", "transport_format",
]);
this.properties = this.properties || {};
@@ -829,11 +829,15 @@ app.registerExtension({
const plyType = message.ply_type?.[0] || "output";
const plySubfolder = message.ply_subfolder?.[0] || "";
// Resize node to match image aspect ratio from intrinsics
// Resize node to match image aspect ratio from intrinsics.
// When width/height are 0 (auto), keep the current node
// size and let the viewer fill whatever space is available.
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);
if (imageWidth > 0 && imageHeight > 0) {
this.resizeToAspectRatio(imageWidth, imageHeight);
}
}
// Spectate mode: forward the move_speed slider to the
@@ -988,5 +992,169 @@ app.registerExtension({
return r;
};
}
// ==============================================================
// Preview Gaussian Dual — side-by-side / slider comparison
// ==============================================================
if (nodeData.name === "PreviewGaussianDual") {
console.log("[GaussianPack] Registering Preview Gaussian Dual node");
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
const node = this;
const container = document.createElement("div");
container.style.cssText = "width:100%;height:100%;display:flex;flex-direction:column;background:#1a1a1a;overflow:hidden;position:relative;";
// Loading bar
const loadBar = document.createElement("div");
loadBar.style.cssText = "position:absolute;top:0;left:0;right:0;height:4px;background:rgba(0,0,0,0.35);z-index:100;transition:opacity 0.3s;pointer-events:none;opacity:0;";
const loadFill = document.createElement("div");
loadFill.style.cssText = "height:100%;width:0%;background:linear-gradient(90deg,#3a8,#6c6);transition:width 0.15s;";
loadBar.appendChild(loadFill);
const iframe = document.createElement("iframe");
iframe.style.cssText = "width:100%;flex:1 1 0;min-height:0;border:none;background:#1a1a1a;";
const infoPanel = document.createElement("div");
infoPanel.style.cssText = "background:#1a1a1a;border-top:1px solid #444;padding:6px 12px;font:10px monospace;color:#ccc;line-height:1.3;flex-shrink:0;overflow:hidden;";
infoPanel.innerHTML = '<span style="color:#888;">Dual view info will appear after execution</span>';
container.appendChild(iframe);
container.appendChild(infoPanel);
container.appendChild(loadBar);
const widget = this.addDOMWidget("preview_gaussian_dual", "GAUSSIAN_DUAL_PREVIEW", container, { serialize: false });
let currentNodeSize = [768, 500];
widget.computeSize = () => currentNodeSize;
this.gaussianDualIframe = iframe;
this.gaussianDualInfoPanel = infoPanel;
this.setSize([768, 500]);
let iframeLoaded = false;
iframe.addEventListener('load', () => { iframeLoaded = true; });
// Camera state persistence
this.properties = this.properties || {};
window.addEventListener('message', (event) => {
if (event.source !== iframe.contentWindow) return;
if (event.data?.type === "CAMERA_STATE" && event.data.state) {
node.properties["Camera Config"] = {
cameraType: "perspective",
state: JSON.stringify(event.data.state),
};
}
if (event.data?.type === "MESH_LOADED") {
loadBar.style.opacity = "0";
const saved = node.properties["Camera Config"];
if (saved?.state && iframe.contentWindow) {
try {
iframe.contentWindow.postMessage({
type: "RESTORE_CAMERA_STATE",
state: JSON.parse(saved.state),
}, "*");
} catch(_) {}
}
}
if (event.data?.type === "MESH_ERROR") {
loadFill.style.background = "#c44";
loadFill.style.width = "100%";
}
});
const onExecuted = this.onExecuted;
this.onExecuted = function(message) {
onExecuted?.apply(this, arguments);
if (message?.error?.[0]) {
infoPanel.innerHTML = `<div style="color:#ff6b6b;">Error: ${message.error[0]}</div>`;
return;
}
if (!message?.ply_file_1?.[0] || !message?.ply_file_2?.[0]) return;
const layout = message.layout?.[0] || "side_by_side";
const renderer = message.renderer?.[0] || "playcanvas";
const transport = message.transport_format?.[0] || "ply";
const fov = message.fov_degrees?.[0] || 50;
// Set iframe src with layout param
iframe.src = `/extensions/${EXTENSION_FOLDER}/viewer_gaussian_dual.html?layout=${layout}&v=${Date.now()}`;
// Resize node for dual view
currentNodeSize = layout === "side_by_side" ? [768, 500] : [512, 580];
node.setSize(currentNodeSize);
node.setDirtyCanvas(true, true);
// Build URLs for both PLYs
function buildUrl(filename, type, subfolder) {
const qsType = encodeURIComponent(type);
const qsSub = encodeURIComponent(subfolder);
if (transport === "spz") {
return `/gaussianpack/spz?filename=${encodeURIComponent(filename)}&type=${qsType}&subfolder=${qsSub}`;
}
return `/view?filename=${encodeURIComponent(filename)}&type=${qsType}&subfolder=${qsSub}`;
}
const url1 = buildUrl(message.ply_file_1[0], message.ply_type_1?.[0] || "output", message.ply_subfolder_1?.[0] || "");
const url2 = buildUrl(message.ply_file_2[0], message.ply_type_2?.[0] || "output", message.ply_subfolder_2?.[0] || "");
const fn1 = transport === "spz" ? message.ply_file_1[0].replace(/\.ply$/i, ".spz") : message.ply_file_1[0];
const fn2 = transport === "spz" ? message.ply_file_2[0].replace(/\.ply$/i, ".spz") : message.ply_file_2[0];
// Info panel
const n1 = message.num_gaussians_1?.[0] || 0;
const n2 = message.num_gaussians_2?.[0] || 0;
const s1 = message.file_size_mb_1?.[0] || '?';
const s2 = message.file_size_mb_2?.[0] || '?';
infoPanel.innerHTML = `
<div style="display:flex;gap:16px;">
<div><span style="color:#888;">G1:</span> <span style="color:#6cc;">${message.ply_file_1[0]}</span> (${s1} MB, ${n1.toLocaleString()} splats)</div>
<div><span style="color:#888;">G2:</span> <span style="color:#6cc;">${message.ply_file_2[0]}</span> (${s2} MB, ${n2.toLocaleString()} splats)</div>
</div>`;
// Fetch both PLYs and send to iframe
loadFill.style.background = "linear-gradient(90deg,#3a8,#6c6)";
loadFill.style.width = "0%";
loadBar.style.opacity = "1";
const fetchAndSend = async () => {
if (!iframe.contentWindow) { setTimeout(fetchAndSend, 300); return; }
try {
loadFill.style.width = "10%";
const [resp1, resp2] = await Promise.all([fetch(url1), fetch(url2)]);
if (!resp1.ok) throw new Error(`G1 fetch failed: ${resp1.status}`);
if (!resp2.ok) throw new Error(`G2 fetch failed: ${resp2.status}`);
loadFill.style.width = "50%";
const [ab1, ab2] = await Promise.all([resp1.arrayBuffer(), resp2.arrayBuffer()]);
loadFill.style.width = "90%";
iframe.contentWindow.postMessage({
type: "LOAD_DUAL_GAUSSIAN",
data1: ab1, data2: ab2,
filename1: fn1, filename2: fn2,
renderer: renderer,
fov: fov,
timestamp: Date.now(),
}, "*", [ab1, ab2]);
loadFill.style.width = "100%";
} catch (err) {
infoPanel.innerHTML = `<div style="color:#ff6b6b;">Error: ${err.message}</div>`;
loadFill.style.background = "#c44";
loadFill.style.width = "100%";
}
};
if (iframeLoaded) {
// iframe was already loaded from a previous execution — reload it
iframeLoaded = false;
iframe.addEventListener('load', () => { iframeLoaded = true; fetchAndSend(); }, { once: true });
} else {
iframe.addEventListener('load', () => { iframeLoaded = true; fetchAndSend(); }, { once: true });
}
};
return r;
};
}
}
});
+2837 -2041
View File
File diff suppressed because one or more lines are too long
+386 -25
View File
@@ -209,6 +209,7 @@
</span>
<button id="resetCamera" title="Reset camera view">Reset View</button>
<button id="screenshot" title="Take screenshot">Screenshot</button>
<button id="fullscreenBtn" title="Toggle fullscreen">Fullscreen</button>
<!-- spectate-mode UI; shown only when ?mode=spectate -->
<span id="spectateBar" style="display:none; margin-left: 10px; align-items: center; gap: 6px;">
<label for="spectateSpeed" style="color:#aaa; font: 10px monospace;">Speed</label>
@@ -321,7 +322,8 @@
_onKeyDown(e) {
this._keys.add(e.code);
// suppress browser scroll on movement keys
if (["Space", "KeyW", "KeyA", "KeyS", "KeyD", "KeyC"].includes(e.code)) {
if (["Space", "KeyW", "KeyA", "KeyS", "KeyD", "KeyC",
"ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.code)) {
e.preventDefault();
}
}
@@ -360,6 +362,13 @@
const dt = Math.min(0.1, (now - this._lastTime) / 1000);
this._lastTime = now;
// Arrow keys: turn camera (yaw/pitch)
const turnSpeed = Math.PI / 2; // radians per second (~90°/s)
if (this._keys.has("ArrowLeft")) this._yaw += turnSpeed * dt;
if (this._keys.has("ArrowRight")) this._yaw -= turnSpeed * dt;
if (this._keys.has("ArrowUp")) this._pitch = Math.min(Math.PI * 0.5 - 1e-4, this._pitch + turnSpeed * dt);
if (this._keys.has("ArrowDown")) this._pitch = Math.max(-Math.PI * 0.5 + 1e-4, this._pitch - turnSpeed * dt);
// apply yaw/pitch to camera
this._scratchQ.setFromEuler(
this._scratchEuler.set(this._pitch, this._yaw, 0, "YXZ"),
@@ -516,8 +525,13 @@
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(50, W / Math.max(1, H), 0.05, 1000);
this.camera.position.set(0, 0, -3);
this.camera.lookAt(0, 0, 0);
if (VIEWER_MODE === "spectate") {
this.camera.position.set(0, 0, 0);
this.camera.lookAt(0, 0, 1);
} else {
this.camera.position.set(0, 0, -3);
this.camera.lookAt(0, 0, 0);
}
if (VIEWER_MODE === "spectate") {
// Fly-cam: WASD + Space/C + drag-to-look. Initial position
@@ -658,34 +672,365 @@
}
// ============================================================
// Adapter: PlayCanvas Engine — WebGPU (TODO real impl)
// Adapter: PlayCanvas Engine v2.19+ — WebGPU GSplat
//
// Loads playcanvas.min.js as a classic <script> (attaches to
// window.pc). Creates a pc.Application on the shared canvas,
// adds a camera entity with orbit-style mouse controls, and
// loads gaussian splats via pc.Asset with file.contents.
// ============================================================
class PlayCanvasAdapter {
constructor(canvas) {
this.canvas = canvas;
this._fallback = null;
this._app = null;
this._cameraEntity = null;
this._splatEntity = null;
this._splatAsset = null;
this._target = { x: 0, y: 0, z: 0 };
this._frameCount = 0;
this._isDragging = false;
this._lastMouse = { x: 0, y: 0 };
this._orbitDist = 3;
this._yaw = 0;
this._pitch = 0;
// Spectate (fly-cam) state
this._keys = new Set();
this._moveSpeed = SPECTATE_INITIAL_SPEED;
this._lastTime = performance.now();
this._isSpectate = (VIEWER_MODE === "spectate");
this.controls = {
target: this._target,
update: () => this._tickControls(),
handleResize() {},
setSpeed: (s) => { this._moveSpeed = Math.max(0.05, Math.min(50, s)); },
};
// Stubs for compatibility with ThreeBaseAdapter consumers
this.scene = null;
this.camera = null;
this.renderer = null;
this.splatMesh = null;
}
kind() { return "playcanvas-fallback"; }
kind() { return "playcanvas"; }
async init() {
console.warn("[PlayCanvas] adapter not yet implemented; falling back to Spark.");
this._fallback = new SparkAdapter(this.canvas);
this._fallback.init();
// Load PlayCanvas engine (non-module, attaches to window.pc)
await loadScript('./js/playcanvas.min.js');
const pc = window.pc;
if (!pc) throw new Error("PlayCanvas engine failed to load");
console.log("[PlayCanvas] engine version:", pc.version);
const app = new pc.Application(this.canvas, {
graphicsDeviceOptions: {
antialias: false,
alpha: false,
preserveDrawingBuffer: true,
},
});
app.setCanvasFillMode(pc.FILLMODE_NONE);
app.setCanvasResolution(pc.RESOLUTION_AUTO);
app.start();
this._app = app;
// Force initial canvas size to match container
const initW = this.canvas.clientWidth || 512;
const initH = this.canvas.clientHeight || 512;
app.resizeCanvas(initW, initH);
this.canvas.width = initW * (window.devicePixelRatio || 1);
this.canvas.height = initH * (window.devicePixelRatio || 1);
// Camera
const cam = new pc.Entity("Camera");
cam.addComponent("camera", {
nearClip: 0.05,
farClip: 1000,
fov: 50,
clearColor: new pc.Color(0.102, 0.102, 0.102, 1), // #1a1a1a
});
if (this._isSpectate) {
cam.setPosition(0, 0, 0);
cam.setEulerAngles(0, 0, 0);
} else {
cam.setPosition(0, 0, -3);
cam.lookAt(0, 0, 0);
}
app.root.addChild(cam);
this._cameraEntity = cam;
// Resize — sync canvas pixel size + PlayCanvas internal resolution
// to match the CSS layout size (which tracks the ComfyUI node/iframe).
const ro = new ResizeObserver(() => {
const w = this.canvas.clientWidth || 1;
const h = this.canvas.clientHeight || 1;
const dpr = window.devicePixelRatio || 1;
this.canvas.width = Math.round(w * dpr);
this.canvas.height = Math.round(h * dpr);
app.resizeCanvas(w, h);
if (cam.camera) {
cam.camera.aspectRatio = w / h;
}
});
ro.observe(this.canvas);
// --- Input handling (orbit OR spectate) ---
this.canvas.addEventListener("pointerdown", (e) => {
this._isDragging = true;
this._lastMouse = { x: e.clientX, y: e.clientY };
this.canvas.setPointerCapture(e.pointerId);
this.canvas.style.cursor = this._isSpectate ? "grabbing" : "default";
});
this.canvas.addEventListener("pointermove", (e) => {
if (!this._isDragging) return;
const dx = e.clientX - this._lastMouse.x;
const dy = e.clientY - this._lastMouse.y;
this._lastMouse = { x: e.clientX, y: e.clientY };
if (this._isSpectate) {
// Spectate: mouse-look (yaw + pitch)
this._yaw -= dx * 0.15;
this._pitch = Math.max(-89, Math.min(89, this._pitch - dy * 0.15));
} else if (e.buttons === 1) {
// Orbit: left drag
this._yaw -= dx * 0.3;
this._pitch = Math.max(-89, Math.min(89, this._pitch - dy * 0.3));
this._updateOrbitCamera();
} else if (e.buttons === 2 || e.buttons === 4) {
// Orbit: right/middle drag -> pan
const panScale = this._orbitDist * 0.002;
const camRight = new pc.Vec3();
const camUp = new pc.Vec3();
cam.getWorldTransform().getX(camRight);
cam.getWorldTransform().getY(camUp);
this._target.x -= (camRight.x * dx - camUp.x * dy) * panScale;
this._target.y -= (camRight.y * dx - camUp.y * dy) * panScale;
this._target.z -= (camRight.z * dx - camUp.z * dy) * panScale;
this._updateOrbitCamera();
}
});
this.canvas.addEventListener("pointerup", (e) => {
this._isDragging = false;
this.canvas.releasePointerCapture(e.pointerId);
this.canvas.style.cursor = this._isSpectate ? "grab" : "default";
});
this.canvas.addEventListener("wheel", (e) => {
if (this._isSpectate) {
// Spectate: scroll = adjust speed
const factor = e.deltaY > 0 ? 0.9 : 1.111;
this._moveSpeed = Math.max(0.05, Math.min(50, this._moveSpeed * factor));
try {
this.canvas.dispatchEvent(new CustomEvent("spectatespeedchange",
{ detail: { speed: this._moveSpeed }}));
} catch (_) {}
} else {
// Orbit: scroll = zoom
this._orbitDist *= (1 + e.deltaY * 0.001);
this._orbitDist = Math.max(0.01, Math.min(1000, this._orbitDist));
this._updateOrbitCamera();
}
e.preventDefault();
}, { passive: false });
this.canvas.addEventListener("contextmenu", (e) => e.preventDefault());
// Keyboard for spectate
window.addEventListener("keydown", (e) => {
this._keys.add(e.code);
if (this._isSpectate && ["Space","KeyW","KeyA","KeyS","KeyD","KeyC",
"ArrowUp","ArrowDown","ArrowLeft","ArrowRight"].includes(e.code)) {
e.preventDefault();
}
});
window.addEventListener("keyup", (e) => this._keys.delete(e.code));
window.addEventListener("blur", () => this._keys.clear());
if (!this._isSpectate) this._updateOrbitCamera();
this.canvas.style.cursor = this._isSpectate ? "grab" : "default";
// Per-frame update hook for spectate fly-cam movement
if (this._isSpectate) {
app.on("update", () => this._tickControls());
}
console.log(`[PlayCanvas] adapter initialized (mode=${this._isSpectate ? "spectate" : "orbit"})`);
}
_updateOrbitCamera() {
const yawRad = this._yaw * Math.PI / 180;
const pitchRad = this._pitch * Math.PI / 180;
const d = this._orbitDist;
const x = this._target.x + d * Math.cos(pitchRad) * Math.sin(yawRad);
const y = this._target.y + d * Math.sin(pitchRad);
const z = this._target.z + d * Math.cos(pitchRad) * Math.cos(yawRad);
this._cameraEntity.setPosition(x, y, z);
this._cameraEntity.lookAt(this._target.x, this._target.y, this._target.z);
}
_tickControls() {
if (!this._isSpectate || !this._cameraEntity) return;
const now = performance.now();
const dt = Math.min(0.1, (now - this._lastTime) / 1000);
this._lastTime = now;
// Arrow keys: turn camera (yaw/pitch)
const turnSpeed = 90; // degrees per second
if (this._keys.has("ArrowLeft")) this._yaw += turnSpeed * dt;
if (this._keys.has("ArrowRight")) this._yaw -= turnSpeed * dt;
if (this._keys.has("ArrowUp")) this._pitch = Math.min(89, this._pitch + turnSpeed * dt);
if (this._keys.has("ArrowDown")) this._pitch = Math.max(-89, this._pitch - turnSpeed * dt);
// Apply yaw/pitch as Euler rotation
const cam = this._cameraEntity;
cam.setEulerAngles(this._pitch, this._yaw, 0);
// Camera-local basis vectors from the transform
const fwd = new window.pc.Vec3();
const right = new window.pc.Vec3();
cam.getWorldTransform().getZ(fwd); // PlayCanvas camera looks down -Z, getZ = forward
cam.getWorldTransform().getX(right);
fwd.mulScalar(-1); // getZ points backward in PC convention
// WASD: move camera
let mx = 0, my = 0, mz = 0;
if (this._keys.has("KeyW")) { mx += fwd.x; my += fwd.y; mz += fwd.z; }
if (this._keys.has("KeyS")) { mx -= fwd.x; my -= fwd.y; mz -= fwd.z; }
if (this._keys.has("KeyD")) { mx += right.x; my += right.y; mz += right.z; }
if (this._keys.has("KeyA")) { mx -= right.x; my -= right.y; mz -= right.z; }
if (this._keys.has("Space")) { my += 1; }
if (this._keys.has("KeyC")) { my -= 1; }
const lenSq = mx*mx + my*my + mz*mz;
if (lenSq > 0) {
const boost = (this._keys.has("ShiftLeft") || this._keys.has("ShiftRight")) ? 3 : 1;
const s = this._moveSpeed * boost * dt / Math.sqrt(lenSq);
const pos = cam.getPosition();
cam.setPosition(pos.x + mx*s, pos.y + my*s, pos.z + mz*s);
}
}
async loadSplat(blobUrl, filename) {
const pc = window.pc;
// Remove existing splat
if (this._splatEntity) {
this._splatEntity.destroy();
this._splatEntity = null;
}
if (this._splatAsset) {
this._app.assets.remove(this._splatAsset);
this._splatAsset = null;
}
console.log(`[PlayCanvas] loading splat: ${filename}`);
const t0 = performance.now();
// Use loadFromUrlAndFilename with the blob URL — PlayCanvas
// fetches it internally and the PLY parser gets a proper
// Response object with a readable body stream.
const asset = await new Promise((resolve, reject) => {
this._app.assets.loadFromUrlAndFilename(
blobUrl, filename, 'gsplat',
(err, asset) => {
if (err) reject(new Error(String(err)));
else resolve(asset);
},
);
});
const entity = new pc.Entity('Splat');
entity.addComponent('gsplat', { asset: asset });
// No euler flip — match Spark's convention (no transform on load).
this._app.root.addChild(entity);
this._splatEntity = entity;
this._splatAsset = asset;
this.splatMesh = { numSplats: 0 };
try {
const res = asset.resource;
if (res && res.numSplats) this.splatMesh.numSplats = res.numSplats;
} catch (_) {}
console.log(`[PlayCanvas] loaded in ${(performance.now() - t0).toFixed(0)} ms`);
}
getBounds() {
if (!this._splatEntity) return null;
try {
// Try multiple paths to get bounds from PlayCanvas GSplat
let aabb = null;
const gsplat = this._splatEntity.gsplat;
if (gsplat) {
// v2.19: instance.meshInstance.aabb
aabb = gsplat.instance?.meshInstance?.aabb;
// Fallback: customAabb
if (!aabb) aabb = gsplat.customAabb;
// Fallback: resource.aabb
if (!aabb && this._splatAsset?.resource) {
aabb = this._splatAsset.resource.aabb;
}
}
// Last resort: compute from entity's rendered bounding box
if (!aabb) {
const renders = this._splatEntity.findComponents("gsplat");
for (const r of renders) {
if (r.instance?.meshInstance?.aabb) {
aabb = r.instance.meshInstance.aabb;
break;
}
}
}
if (!aabb) {
console.warn("[PlayCanvas] getBounds: no AABB found, using fallback");
return { center: { x: 0, y: 0, z: 0 }, size: { x: 2, y: 2, z: 2 } };
}
return {
center: { x: aabb.center.x, y: aabb.center.y, z: aabb.center.z },
size: { x: aabb.halfExtents.x * 2, y: aabb.halfExtents.y * 2, z: aabb.halfExtents.z * 2 },
};
} catch (e) {
console.warn("[PlayCanvas] getBounds error:", e);
return { center: { x: 0, y: 0, z: 0 }, size: { x: 2, y: 2, z: 2 } };
}
}
getCameraPosition() {
const p = this._cameraEntity.getPosition();
return { x: p.x, y: p.y, z: p.z };
}
setCameraPosition(x, y, z) {
// Reverse-engineer orbit params from position + target
const dx = x - this._target.x;
const dy = y - this._target.y;
const dz = z - this._target.z;
this._orbitDist = Math.sqrt(dx*dx + dy*dy + dz*dz);
this._pitch = Math.asin(dy / Math.max(this._orbitDist, 1e-6)) * 180 / Math.PI;
this._yaw = Math.atan2(dx, dz) * 180 / Math.PI;
this._updateOrbitCamera();
}
getCameraTarget() {
return { x: this._target.x, y: this._target.y, z: this._target.z };
}
setCameraTarget(x, y, z) {
this._target.x = x;
this._target.y = y;
this._target.z = z;
this._updateOrbitCamera();
}
setFov(deg) {
if (this._cameraEntity) this._cameraEntity.camera.fov = deg;
}
setScaleMultiplier(f) {
if (this._splatEntity) {
const s = f / 20.0;
this._splatEntity.setLocalScale(s, s, s);
}
}
setOpacityMultiplier(_f) {
// PlayCanvas GSplat doesn't expose a per-entity opacity multiplier.
// Scene-wide alpha clip is available but not equivalent.
}
screenshot() {
if (this._app) this._app.renderNextFrame = true;
return this.canvas.toDataURL("image/png");
}
async loadSplat(u, f) { return this._fallback.loadSplat(u, f); }
getBounds() { return this._fallback.getBounds(); }
getCameraPosition() { return this._fallback.getCameraPosition(); }
setCameraPosition(x,y,z) { return this._fallback.setCameraPosition(x,y,z); }
getCameraTarget() { return this._fallback.getCameraTarget(); }
setCameraTarget(x,y,z){ return this._fallback.setCameraTarget(x,y,z); }
setFov(d) { return this._fallback.setFov(d); }
setScaleMultiplier(f) { return this._fallback.setScaleMultiplier(f); }
setOpacityMultiplier(f){ return this._fallback.setOpacityMultiplier(f); }
screenshot() { return this._fallback.screenshot(); }
get scene() { return this._fallback?.scene; }
get camera() { return this._fallback?.camera; }
get renderer() { return this._fallback?.renderer; }
get splatMesh() { return this._fallback?.splatMesh; }
get _frameCount() { return this._fallback?._frameCount; }
}
async function bootRenderer(kind) {
@@ -861,7 +1206,16 @@
}
fovDeg = Math.max(20, Math.min(120, fovDeg));
setFovInputs(fovDeg);
frameOnBounds(fovDeg);
if (VIEWER_MODE === "spectate") {
// Spectate: start at world origin looking forward
adapter.setFov(fovDeg);
adapter.setCameraPosition(0, 0, 0);
adapter.setCameraTarget(0, 0, 1);
currentFovDeg = fovDeg;
initialPose = { pos: [0, 0, 0], target: [0, 0, 1], fov: fovDeg };
} else {
frameOnBounds(fovDeg);
}
adapter.setScaleMultiplier(currentScale);
adapter.setOpacityMultiplier(currentOpacity);
@@ -908,6 +1262,13 @@
});
document.getElementById('resetCamera').addEventListener('click', resetCamera);
document.getElementById('fullscreenBtn').addEventListener('click', () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(() => {});
} else {
document.exitFullscreen().catch(() => {});
}
});
document.getElementById('screenshot').addEventListener('click', () => {
if (!adapterReady) return;
try {
File diff suppressed because it is too large Load Diff