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:
@@ -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
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
}}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Vendored
+2837
-2041
File diff suppressed because one or more lines are too long
+386
-25
@@ -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 {
|
||||
|
||||
+442
-794
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user