Vendor frontend JS into javascript/; drop the prestartup copy dance

The viewer JS/HTML/bundles are now committed in javascript/ (declared via
pyproject [tool.comfy] web = "javascript") instead of being copied from the
shared comfy-3d-viewers pip package at prestartup. Consequences:

- requirements.txt is now just comfy-env (host env installs nothing else,
  per the never-touch-the-host-env principle)
- prestartup_script.py no longer copies viewers or web_src; it keeps only
  setup_env() and the input-asset copies
- WEB_DIRECTORY attribute removed; the pyproject declaration is the single
  source (served at /extensions/comfyui-geometrypack/, keyed by project name)
- web_src/ deleted; its 6 widgets now live in javascript/js/ as the tracked
  source, edited directly
- viewer_pbr.html hardcoded-fallback URL updated to the new mount name

Iframe URLs auto-detect the mount from import.meta.url, so the folder rename
and mount-name change are transparent at runtime.
This commit is contained in:
PozzettiAndrea
2026-08-12 17:37:09 +02:00
parent 541b4e5b88
commit 414f759791
81 changed files with 65291 additions and 35 deletions
+258
View File
@@ -0,0 +1,258 @@
/**
* BVH Viewer Widget - Interactive BVH skeleton animation viewer
* Displays skeletal animations in BVH format with Three.js
*/
import { app } from "../../../../scripts/app.js";
console.log("[BVHViewer] Loading BVH Viewer extension");
// Node types that use this viewer
const BVH_VIEWER_NODES = [
{
nodeName: "BVHViewer",
extensionName: "comfy3d.bvhviewer",
logPrefix: "[BVHViewer]"
}
];
function detectExtensionFolder() {
try {
if (typeof import.meta !== 'undefined' && import.meta.url) {
const match = import.meta.url.match(/\/extensions\/([^\/]+)\//);
if (match) return match[1];
}
const scripts = document.getElementsByTagName('script');
for (let i = scripts.length - 1; i >= 0; i--) {
const src = scripts[i].src;
if (src) {
const match = src.match(/\/extensions\/([^\/]+)\//);
if (match) return match[1];
}
}
} catch (e) {
console.warn('[BVHViewer] Could not detect extension folder:', e);
}
return null;
}
function getViewerUrl(extensionFolder) {
return `/extensions/${extensionFolder}/viewer_bvh.html?v=` + Date.now();
}
function createBVHViewerExtension(config) {
const { extensionName, nodeName, logPrefix } = config;
console.log(`${logPrefix} Loading BVH viewer extension...`);
const extensionFolder = detectExtensionFolder();
if (!extensionFolder) {
console.error(`${logPrefix} Could not detect extension folder`);
return;
}
app.registerExtension({
name: extensionName,
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name !== nodeName) return;
console.log(`${logPrefix} Registering ${nodeName} node`);
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const result = onNodeCreated?.apply(this, arguments);
// Create container
const container = document.createElement("div");
container.style.cssText = "position: relative; width: 100%; background: #222;";
// Create iframe for Three.js viewer
const iframe = document.createElement("iframe");
iframe.style.cssText = "width: 100%; height: 600px; border: none; display: block; background: #1a1a1a;";
iframe.src = getViewerUrl(extensionFolder);
container.appendChild(iframe);
// Controls bar
const controlsBar = document.createElement("div");
controlsBar.style.cssText = "display: flex; flex-wrap: wrap; gap: 10px; padding: 10px; background: #252525; align-items: center; border-top: 1px solid #333;";
// Play/Pause button
const playButton = document.createElement("button");
playButton.textContent = "\u25B6";
playButton.style.cssText = "width: 30px; height: 30px; border: none; border-radius: 4px; background: #4a9eff; color: white; font-size: 14px; cursor: pointer; flex-shrink: 0;";
playButton.disabled = true;
controlsBar.appendChild(playButton);
// Frame slider
const frameSlider = document.createElement("input");
frameSlider.type = "range";
frameSlider.min = 0;
frameSlider.max = 100;
frameSlider.value = 0;
frameSlider.disabled = true;
frameSlider.style.cssText = "flex-grow: 1; height: 6px; min-width: 100px;";
controlsBar.appendChild(frameSlider);
// Frame counter
const frameCounter = document.createElement("div");
frameCounter.style.cssText = "padding: 4px 8px; background: rgba(0,0,0,0.3); color: #aaa; border-radius: 3px; font-size: 11px; font-family: monospace; min-width: 80px; text-align: center;";
frameCounter.textContent = "0 / 0";
controlsBar.appendChild(frameCounter);
// Separator
const sep = document.createElement("div");
sep.style.cssText = "width: 1px; height: 20px; background: #444; margin: 0 5px;";
controlsBar.appendChild(sep);
// Speed control container
const speedContainer = document.createElement("div");
speedContainer.style.cssText = "display: flex; align-items: center; gap: 5px;";
const speedLabel = document.createElement("span");
speedLabel.textContent = "Spd:";
speedLabel.style.cssText = "color: #aaa; font-size: 11px;";
speedContainer.appendChild(speedLabel);
const speedSlider = document.createElement("input");
speedSlider.type = "range";
speedSlider.min = 0.1;
speedSlider.max = 2.0;
speedSlider.step = 0.1;
speedSlider.value = 1.0;
speedSlider.style.cssText = "width: 60px; height: 4px;";
speedContainer.appendChild(speedSlider);
const speedValue = document.createElement("span");
speedValue.textContent = "1.0x";
speedValue.style.cssText = "color: #fff; font-size: 11px; min-width: 30px;";
speedContainer.appendChild(speedValue);
controlsBar.appendChild(speedContainer);
container.appendChild(controlsBar);
// State
this.bvhViewerState = {
iframe: iframe,
container: container,
playButton: playButton,
frameSlider: frameSlider,
frameCounter: frameCounter,
speedSlider: speedSlider,
speedValue: speedValue,
isPlaying: false,
currentFrame: 0,
totalFrames: 0,
bvhData: null,
viewerReady: false
};
// Add DOM widget
this.addDOMWidget("bvh_viewer", "customIframe", container);
// Play button handler
playButton.onclick = () => {
const state = this.bvhViewerState;
state.isPlaying = !state.isPlaying;
playButton.textContent = state.isPlaying ? "\u23F8" : "\u25B6";
iframe.contentWindow.postMessage({
type: state.isPlaying ? 'play' : 'pause'
}, '*');
};
// Frame slider handler
frameSlider.oninput = (e) => {
const frame = parseInt(e.target.value);
this.bvhViewerState.currentFrame = frame;
iframe.contentWindow.postMessage({ type: 'setFrame', frame: frame }, '*');
};
// Speed slider handler
speedSlider.oninput = (e) => {
const speed = parseFloat(e.target.value);
speedValue.textContent = speed.toFixed(1) + 'x';
iframe.contentWindow.postMessage({ type: 'setSpeed', speed: speed }, '*');
};
// Listen for messages from iframe
const messageHandler = (event) => {
if (event.source !== iframe.contentWindow) return;
const data = event.data;
const state = this.bvhViewerState;
if (data.type === 'VIEWER_READY') {
state.viewerReady = true;
if (state.bvhData) {
this.loadBVHData(state.bvhData.bvhContent, state.bvhData.bvhInfo);
}
} else if (data.type === 'playing') {
state.isPlaying = true;
playButton.textContent = "\u23F8";
} else if (data.type === 'paused') {
state.isPlaying = false;
playButton.textContent = "\u25B6";
} else if (data.type === 'frameChanged') {
state.currentFrame = data.frame;
state.totalFrames = data.totalFrames;
frameSlider.value = data.frame;
frameCounter.textContent = `${data.frame} / ${data.totalFrames}`;
} else if (data.type === 'looped') {
state.currentFrame = 0;
frameSlider.value = 0;
}
};
window.addEventListener('message', messageHandler);
// Cleanup on removal
const originalOnRemoved = this.onRemoved;
this.onRemoved = function() {
window.removeEventListener('message', messageHandler);
if (originalOnRemoved) originalOnRemoved.apply(this, arguments);
};
// Handle data from backend
this.onExecuted = (message) => {
if (message?.bvh_content) {
const bvhContent = message.bvh_content[0];
const bvhInfo = message.bvh_info ? message.bvh_info[0] : {};
this.bvhViewerState.bvhData = { bvhContent, bvhInfo };
this.bvhViewerState.totalFrames = bvhInfo.num_frames || 0;
playButton.disabled = false;
frameSlider.disabled = false;
frameSlider.max = this.bvhViewerState.totalFrames - 1;
frameCounter.textContent = `0 / ${this.bvhViewerState.totalFrames}`;
if (this.bvhViewerState.viewerReady) {
this.loadBVHData(bvhContent, bvhInfo);
}
}
};
// Method to load BVH data into viewer
this.loadBVHData = function(bvhContent, bvhInfo) {
if (iframe.contentWindow) {
iframe.contentWindow.postMessage({
type: 'loadBVH',
bvhContent: bvhContent,
bvhInfo: bvhInfo
}, '*');
}
};
this.setSize([Math.max(400, this.size[0] || 400), 720]);
return result;
};
}
});
console.log(`${logPrefix} Extension registered: ${extensionName}`);
}
// Auto-register all known BVH viewer node types
BVH_VIEWER_NODES.forEach(config => createBVHViewerExtension(config));
export { createBVHViewerExtension, detectExtensionFolder };
+219
View File
@@ -0,0 +1,219 @@
/**
* ComfyUI Compare Skeletons Widget
*
* Widget for UniRigCompareSkeletons node - displays two FBX skeletons side-by-side
* with synced camera rotation and cross-bone highlighting.
*/
import { app } from "../../../../scripts/app.js";
import { detectExtensionFolder, getViewerUrl } from "./mesh_preview_fbx.js";
console.log("[UniRig Compare] Loading compare skeleton widget...");
const extensionFolder = detectExtensionFolder();
if (!extensionFolder) {
console.error("[UniRig Compare] Could not detect extension folder");
}
console.log("[UniRig Compare] Detected extension folder:", extensionFolder);
app.registerExtension({
name: "unirig.compareskeletons",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "CompareSkeletons" || nodeData.name === "UniRigCompareSkeletons") {
console.log("[UniRig Compare] Registering Compare Skeletons node");
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
console.log("[UniRig Compare] Node created, adding compare viewer widget");
// Create iframe for comparison viewer
const iframe = document.createElement("iframe");
iframe.style.width = "100%";
iframe.style.flex = "1 1 0";
iframe.style.minHeight = "0";
iframe.style.border = "none";
iframe.style.backgroundColor = "#2a2a2a";
// Load viewer from file URL
const viewerUrl = getViewerUrl(extensionFolder, "viewer_fbx_compare.html");
iframe.src = viewerUrl;
console.log("[UniRig Compare] Setting iframe src to:", viewerUrl);
iframe.onload = () => {
console.log("[UniRig Compare] Iframe loaded successfully");
};
iframe.onerror = (e) => {
console.error("[UniRig Compare] Iframe failed to load:", e);
};
// Add widget
const widget = this.addDOMWidget("preview", "FBX_COMPARE_PREVIEW", iframe, {
getValue() { return ""; },
setValue(v) { }
});
console.log("[UniRig Compare] Widget created:", widget);
// Set widget size - wider for side-by-side view
widget.computeSize = function(width) {
const w = width || 800;
const h = w * 0.75; // Wider aspect ratio for dual view
return [w, h];
};
widget.element = iframe;
// Store iframe reference
this.compareViewerIframe = iframe;
this.compareViewerReady = false;
// Listen for ready message from iframe
const onMessage = (event) => {
if (event.data && event.data.type === 'VIEWER_READY') {
console.log("[UniRig Compare] Viewer iframe is ready!");
this.compareViewerReady = true;
}
};
window.addEventListener('message', onMessage.bind(this));
const notifyIframeResize = () => {
if (iframe.contentWindow) {
const rect = iframe.getBoundingClientRect();
iframe.contentWindow.postMessage({
type: 'RESIZE',
width: rect.width,
height: rect.height
}, '*');
}
};
this.onResize = function(size) {
const isVueNodes = iframe.closest('[data-node-id]') !== null ||
document.querySelector('.vue-graph-canvas') !== null;
if (!isVueNodes && size && size[1]) {
const nodeHeight = size[1];
const headerHeight = 70;
const availableHeight = Math.max(200, nodeHeight - headerHeight);
iframe.style.height = availableHeight + 'px';
}
requestAnimationFrame(() => {
notifyIframeResize();
});
};
let resizeTimeout = null;
let lastSize = { width: 0, height: 0 };
const resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0];
const newWidth = entry.contentRect.width;
const newHeight = entry.contentRect.height;
if (Math.abs(newWidth - lastSize.width) < 1 && Math.abs(newHeight - lastSize.height) < 1) {
return;
}
lastSize = { width: newWidth, height: newHeight };
if (resizeTimeout) {
clearTimeout(resizeTimeout);
}
resizeTimeout = setTimeout(() => {
notifyIframeResize();
}, 50);
});
resizeObserver.observe(iframe);
const originalOnRemoved = this.onRemoved;
this.onRemoved = function() {
resizeObserver.disconnect();
if (resizeTimeout) {
clearTimeout(resizeTimeout);
}
window.removeEventListener('message', onMessage);
if (originalOnRemoved) {
originalOnRemoved.apply(this, arguments);
}
};
// Set initial node size (wider for dual view)
this.setSize([800, 600]);
// Handle execution
const onExecuted = this.onExecuted;
this.onExecuted = function(message) {
console.log("[UniRig Compare] onExecuted called with message:", message);
onExecuted?.apply(this, arguments);
// The message contains both FBX file paths
const leftFile = message?.fbx_file_left?.[0];
const rightFile = message?.fbx_file_right?.[0];
if (leftFile || rightFile) {
console.log("[UniRig Compare] Loading FBX files - Left:", leftFile, "Right:", rightFile);
// Build file paths
const buildFilepath = (filename) => {
if (!filename) return null;
if (!filename.includes('/') && !filename.includes('\\')) {
return `${window.location.origin}/view?filename=${encodeURIComponent(filename)}&type=output&subfolder=`;
} else {
const basename = filename.split(/[/\\]/).pop();
return `${window.location.origin}/view?filename=${encodeURIComponent(basename)}&type=output&subfolder=`;
}
};
const filepathLeft = buildFilepath(leftFile);
const filepathRight = buildFilepath(rightFile);
// Send message to iframe
const sendMessage = () => {
if (iframe.contentWindow) {
console.log("[UniRig Compare] Sending postMessage to iframe");
iframe.contentWindow.postMessage({
type: "LOAD_FBX_COMPARE",
filepathLeft: filepathLeft,
filepathRight: filepathRight,
timestamp: Date.now()
}, "*");
} else {
console.error("[UniRig Compare] Iframe contentWindow not available");
}
};
// Wait for iframe to be ready, or use timeout as fallback
if (this.compareViewerReady) {
sendMessage();
} else {
const checkReady = setInterval(() => {
if (this.compareViewerReady) {
clearInterval(checkReady);
sendMessage();
}
}, 50);
// Fallback timeout after 2 seconds
setTimeout(() => {
clearInterval(checkReady);
if (!this.compareViewerReady) {
console.warn("[UniRig Compare] Iframe not ready after 2s, sending anyway");
sendMessage();
}
}, 2000);
}
} else {
console.log("[UniRig Compare] No FBX files in message data. Keys:", Object.keys(message || {}));
}
};
return r;
};
}
}
});
console.log("[UniRig Compare] Compare skeleton widget registered");
+161
View File
@@ -0,0 +1,161 @@
/**
* Compare SMPL to BVH Viewer Widget
* Side-by-side comparison of SMPL mesh and BVH skeleton using split view
*/
import { app } from "../../../../scripts/app.js";
console.log("[CompareSMPLBVH] Loading Compare SMPL BVH extension");
// Node types that use this viewer
const COMPARE_VIEWER_NODES = [
{
nodeName: "CompareSMPLtoBVH",
extensionName: "comfy3d.comparesmplbvh",
logPrefix: "[CompareSMPLBVH]",
smplMeshApiPath: "/motioncapture/smpl_mesh"
}
];
function detectExtensionFolder() {
try {
if (typeof import.meta !== 'undefined' && import.meta.url) {
const match = import.meta.url.match(/\/extensions\/([^\/]+)\//);
if (match) return match[1];
}
const scripts = document.getElementsByTagName('script');
for (let i = scripts.length - 1; i >= 0; i--) {
const src = scripts[i].src;
if (src) {
const match = src.match(/\/extensions\/([^\/]+)\//);
if (match) return match[1];
}
}
} catch (e) {
console.warn('[CompareSMPLBVH] Could not detect extension folder:', e);
}
return null;
}
function getViewerUrl(extensionFolder) {
return `/extensions/${extensionFolder}/viewer_compare_smpl_bvh.html?v=` + Date.now();
}
function createCompareViewerExtension(config) {
const { extensionName, nodeName, logPrefix, smplMeshApiPath } = config;
console.log(`${logPrefix} Loading Compare SMPL BVH extension...`);
const extensionFolder = detectExtensionFolder();
if (!extensionFolder) {
console.error(`${logPrefix} Could not detect extension folder`);
return;
}
app.registerExtension({
name: extensionName,
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name !== nodeName) return;
console.log(`${logPrefix} Registering ${nodeName} node`);
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const result = onNodeCreated?.apply(this, arguments);
const container = document.createElement("div");
container.style.width = "100%";
container.style.height = "100%";
container.style.background = "#222";
container.style.display = "flex";
container.style.flexDirection = "column";
const iframe = document.createElement("iframe");
iframe.style.width = "100%";
iframe.style.height = "100%";
iframe.style.border = "none";
iframe.style.flexGrow = "1";
iframe.src = getViewerUrl(extensionFolder);
container.appendChild(iframe);
// Store references
this.compareViewerIframe = iframe;
this.compareViewerReady = false;
this.compareDataToLoad = null;
// Add widget with computeSize for automatic resizing
const widget = this.addDOMWidget("compare_viewer", "iframe", container);
widget.computeSize = (w) => [w, 500];
// Set an initial size for the node
this.setSize([this.size[0], 500 + 30]);
// Listen for viewer ready message
const messageHandler = (event) => {
if (event.source !== iframe.contentWindow) return;
if (event.data.type === 'VIEWER_READY') {
this.compareViewerReady = true;
if (this.compareDataToLoad) {
this.loadCompareData(this.compareDataToLoad);
}
}
};
window.addEventListener('message', messageHandler);
// Cleanup on removal
const originalOnRemoved = this.onRemoved;
this.onRemoved = function() {
window.removeEventListener('message', messageHandler);
if (originalOnRemoved) originalOnRemoved.apply(this, arguments);
};
// Method to load compare data
this.loadCompareData = function(data) {
if (!iframe.contentWindow) {
this.compareDataToLoad = data;
return;
}
if (!this.compareViewerReady) {
this.compareDataToLoad = data;
return;
}
iframe.contentWindow.postMessage({
type: 'loadData',
smplFilename: data.smplFilename,
bvhContent: data.bvhContent,
smplMeshApiPath: smplMeshApiPath
}, '*');
this.compareDataToLoad = null;
};
this.onExecuted = (msg) => {
if (msg?.smpl_mesh_filename && msg?.bvh_content) {
const data = {
smplFilename: msg.smpl_mesh_filename[0],
bvhContent: msg.bvh_content[0]
};
if (this.compareViewerReady) {
this.loadCompareData(data);
} else {
this.compareDataToLoad = data;
}
}
};
return result;
};
}
});
console.log(`${logPrefix} Extension registered: ${extensionName}`);
}
// Auto-register all known compare viewer node types
COMPARE_VIEWER_NODES.forEach(config => createCompareViewerExtension(config));
export { createCompareViewerExtension, detectExtensionFolder };
+10
View File
@@ -0,0 +1,10 @@
import { createFBXPreviewExtension } from "./mesh_preview_fbx.js";
// Register UniRigDebugSkeleton with the debug viewer
createFBXPreviewExtension({
nodeName: "UniRigDebugSkeleton",
extensionName: "unirig.debugskeleton",
logPrefix: "[UniRig Debug]",
fbxExportApiPath: "/unirig/export_posed_fbx",
viewerFile: "viewer_fbx_debug.html"
});
+315
View File
@@ -0,0 +1,315 @@
/**
* FBX Animation Viewer Widget - Interactive animation playback with Three.js
* Controls animation playback, speed, looping, and skeleton visibility
*/
import { app } from "../../../../scripts/app.js";
console.log("[FBXAnimationViewer] Loading FBX Animation Viewer extension");
// Node types that use this viewer
const FBX_ANIMATION_NODES = [
{
nodeName: "FBXAnimationViewer",
extensionName: "comfy3d.fbxanimationviewer",
logPrefix: "[FBXAnimationViewer]"
}
];
function detectExtensionFolder() {
try {
if (typeof import.meta !== 'undefined' && import.meta.url) {
const match = import.meta.url.match(/\/extensions\/([^\/]+)\//);
if (match) return match[1];
}
const scripts = document.getElementsByTagName('script');
for (let i = scripts.length - 1; i >= 0; i--) {
const src = scripts[i].src;
if (src) {
const match = src.match(/\/extensions\/([^\/]+)\//);
if (match) return match[1];
}
}
} catch (e) {
console.warn('[FBXAnimationViewer] Could not detect extension folder:', e);
}
return null;
}
function getViewerUrl(extensionFolder) {
return `/extensions/${extensionFolder}/viewer_fbx_animation.html?v=` + Date.now();
}
function createFBXAnimationViewerExtension(config) {
const { extensionName, nodeName, logPrefix } = config;
console.log(`${logPrefix} Loading FBX Animation Viewer extension...`);
const extensionFolder = detectExtensionFolder();
if (!extensionFolder) {
console.error(`${logPrefix} Could not detect extension folder`);
return;
}
app.registerExtension({
name: extensionName,
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name !== nodeName) return;
console.log(`${logPrefix} Registering ${nodeName} node`);
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const result = onNodeCreated?.apply(this, arguments);
// Main Container
const mainContainer = document.createElement("div");
mainContainer.style.cssText = "width: 100%; height: 100%; display: flex; flex-direction: column; overflow: hidden; background: #1a1a1a; border: 1px solid #444; box-sizing: border-box;";
// Viewer Area (Iframe)
const viewerArea = document.createElement("div");
viewerArea.style.cssText = "position: relative; flex-grow: 1; min-height: 200px; overflow: hidden;";
const iframe = document.createElement("iframe");
iframe.style.cssText = "display: block; width: 100%; height: 100%; border: none;";
iframe.src = getViewerUrl(extensionFolder);
viewerArea.appendChild(iframe);
mainContainer.appendChild(viewerArea);
// Controls Area
const controlsContainer = document.createElement("div");
controlsContainer.style.cssText = "flex-shrink: 0; background: #2a2a2a; border-top: 1px solid #444; padding: 10px; box-sizing: border-box; color: white; font-size: 12px; font-family: Arial, sans-serif;";
controlsContainer.innerHTML = `
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 10px;">
<button id="playPauseBtn" style="padding: 6px; background: #444; color: white; border: none; border-radius: 4px; cursor: pointer;">\u25B6 Play</button>
<button id="resetBtn" style="padding: 6px; background: #444; color: white; border: none; border-radius: 4px; cursor: pointer;">\u27F2 Reset</button>
</div>
<div style="margin-bottom: 8px;">
<input type="range" id="timeline" min="0" max="100" value="0" style="width: 100%; display: block; margin-bottom: 2px;" disabled>
<div style="display: flex; justify-content: space-between; font-size: 10px; color: #aaa;">
<span><span id="currentFrame">0</span> / <span id="totalFrames">0</span></span>
<span id="currentTime">0.00s</span>
</div>
</div>
<div style="display: flex; gap: 8px; margin-bottom: 8px;">
<select id="animationSelect" style="flex-grow: 1; padding: 4px; background: #333; color: white; border: 1px solid #555; border-radius: 4px;" disabled></select>
<select id="speedControl" style="width: 60px; padding: 4px; background: #333; color: white; border: 1px solid #555; border-radius: 4px;" disabled>
<option value="0.25">0.25x</option>
<option value="0.5">0.5x</option>
<option value="1" selected>1x</option>
<option value="2">2x</option>
</select>
</div>
<div style="display: flex; flex-wrap: wrap; gap: 8px; font-size: 11px;">
<label><input type="checkbox" id="loop" checked disabled> Loop</label>
<label><input type="checkbox" id="showSkeleton" checked> Bones</label>
<label><input type="checkbox" id="showMesh" checked> Mesh</label>
<label><input type="checkbox" id="xraySkeleton"> X-Ray</label>
</div>
<button id="resetCamera" style="width: 100%; padding: 8px; background: #444; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; margin-top: 8px;">Reset Camera</button>
`;
mainContainer.appendChild(controlsContainer);
// Add Single Widget
const widget = this.addDOMWidget("fbx_viewer_unified", "main", mainContainer, {
serialize: false,
hideOnZoom: false
});
widget.computeSize = (width) => [width, 500];
// Get control elements
const playPauseBtn = controlsContainer.querySelector('#playPauseBtn');
const resetBtn = controlsContainer.querySelector('#resetBtn');
const timeline = controlsContainer.querySelector('#timeline');
const speedControl = controlsContainer.querySelector('#speedControl');
const loopCheckbox = controlsContainer.querySelector('#loop');
const animationSelect = controlsContainer.querySelector('#animationSelect');
const showSkeleton = controlsContainer.querySelector('#showSkeleton');
const showMesh = controlsContainer.querySelector('#showMesh');
const xraySkeleton = controlsContainer.querySelector('#xraySkeleton');
const resetCamera = controlsContainer.querySelector('#resetCamera');
const currentTimeEl = controlsContainer.querySelector('#currentTime');
const currentFrameEl = controlsContainer.querySelector('#currentFrame');
const totalFramesEl = controlsContainer.querySelector('#totalFrames');
// Store references
this.animationViewerIframe = iframe;
this.animationViewerReady = false;
this.animationControls = {
playPauseBtn, resetBtn, timeline, speedControl, loopCheckbox, animationSelect,
showSkeleton, showMesh, xraySkeleton, resetCamera,
currentTimeEl, currentFrameEl, totalFramesEl
};
// Wire up controls
playPauseBtn.addEventListener('click', () => {
iframe.contentWindow.postMessage({ type: 'PLAY_PAUSE' }, '*');
});
resetBtn.addEventListener('click', () => {
iframe.contentWindow.postMessage({ type: 'RESET' }, '*');
});
timeline.addEventListener('input', (e) => {
const progress = parseFloat(e.target.value) / 100;
iframe.contentWindow.postMessage({ type: 'SET_TIMELINE', progress }, '*');
});
speedControl.addEventListener('change', (e) => {
iframe.contentWindow.postMessage({ type: 'SET_SPEED', speed: parseFloat(e.target.value) }, '*');
});
loopCheckbox.addEventListener('change', (e) => {
iframe.contentWindow.postMessage({ type: 'SET_LOOP', loop: e.target.checked }, '*');
});
animationSelect.addEventListener('change', (e) => {
iframe.contentWindow.postMessage({ type: 'CHANGE_ANIMATION', index: parseInt(e.target.value) }, '*');
});
showSkeleton.addEventListener('change', (e) => {
iframe.contentWindow.postMessage({ type: 'TOGGLE_SKELETON', visible: e.target.checked }, '*');
});
showMesh.addEventListener('change', (e) => {
iframe.contentWindow.postMessage({ type: 'TOGGLE_MESH', visible: e.target.checked }, '*');
});
xraySkeleton.addEventListener('change', (e) => {
iframe.contentWindow.postMessage({ type: 'TOGGLE_XRAY', xray: e.target.checked }, '*');
});
resetCamera.addEventListener('click', () => {
iframe.contentWindow.postMessage({ type: 'RESET_CAMERA' }, '*');
});
// Listen for messages from iframe
const messageHandler = (event) => {
if (event.source !== iframe.contentWindow) return;
const { type, ...data } = event.data;
switch(type) {
case 'VIEWER_READY':
this.animationViewerReady = true;
if (this.fbxPathToLoad) {
this.loadAnimationInViewer(this.fbxPathToLoad);
}
break;
case 'ANIMATIONS_LOADED':
animationSelect.innerHTML = '';
data.animations.forEach(anim => {
const option = document.createElement('option');
option.value = anim.index;
option.textContent = anim.name;
animationSelect.appendChild(option);
});
playPauseBtn.disabled = false;
resetBtn.disabled = false;
timeline.disabled = false;
speedControl.disabled = false;
loopCheckbox.disabled = false;
animationSelect.disabled = data.animations.length <= 1;
break;
case 'TIME_UPDATE':
if (currentTimeEl) currentTimeEl.textContent = data.time.toFixed(2) + 's';
if (currentFrameEl) currentFrameEl.textContent = data.frame;
if (totalFramesEl) totalFramesEl.textContent = data.totalFrames;
timeline.value = data.progress;
break;
case 'PLAY_STATE_CHANGED':
playPauseBtn.textContent = data.isPlaying ? '\u23F8 Pause' : '\u25B6 Play';
break;
case 'ANIMATION_CHANGED':
const fps = 30;
if (totalFramesEl) totalFramesEl.textContent = Math.floor(data.duration * fps);
playPauseBtn.textContent = '\u23F8 Pause';
break;
case 'NO_ANIMATIONS':
playPauseBtn.textContent = 'No Animation';
playPauseBtn.disabled = true;
break;
}
};
window.addEventListener('message', messageHandler);
// Cleanup on removal
const originalOnRemoved = this.onRemoved;
this.onRemoved = function() {
window.removeEventListener('message', messageHandler);
if (originalOnRemoved) originalOnRemoved.apply(this, arguments);
};
// Set initial node size
const nodeWidth = Math.max(512, this.size[0] || 512);
this.setSize([nodeWidth, 500]);
return result;
};
// Add method to load FBX
nodeType.prototype.loadAnimationInViewer = function(fbxPath) {
if (!this.animationViewerIframe || !this.animationViewerIframe.contentWindow) {
this.fbxPathToLoad = fbxPath;
return;
}
if (!this.animationViewerReady) {
this.fbxPathToLoad = fbxPath;
return;
}
let relativePath = fbxPath;
if (fbxPath.includes('/output/')) {
relativePath = fbxPath.split('/output/')[1];
} else if (fbxPath.includes('/input/')) {
relativePath = fbxPath.split('/input/')[1];
} else {
relativePath = fbxPath.split('/').pop();
}
const viewPath = window.location.origin + "/view?filename=" + encodeURIComponent(relativePath);
this.animationViewerIframe.contentWindow.postMessage({
type: 'LOAD_FBX',
path: viewPath
}, '*');
this.fbxPathToLoad = null;
};
// Override onExecuted to load FBX
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function(message) {
if (onExecuted) onExecuted.apply(this, arguments);
if (message?.fbx_path?.[0]) {
const fbxPath = message.fbx_path[0];
this.loadAnimationInViewer(fbxPath);
}
};
}
});
console.log(`${logPrefix} Extension registered: ${extensionName}`);
}
// Auto-register all known FBX animation viewer node types
FBX_ANIMATION_NODES.forEach(config => createFBXAnimationViewerExtension(config));
export { createFBXAnimationViewerExtension, detectExtensionFolder };
+249
View File
@@ -0,0 +1,249 @@
/**
* ComfyUI GeomPack - Gaussian Splat Preview Widget
* Interactive 3D Gaussian Splatting viewer using gsplat.js
*/
import { app } from "../../../scripts/app.js";
// Auto-detect extension folder name (handles ComfyUI-GeometryPack or comfyui-geometrypack)
const EXTENSION_FOLDER = (() => {
const url = import.meta.url;
const match = url.match(/\/extensions\/([^/]+)\//);
return match ? match[1] : "ComfyUI-GeometryPack";
})();
console.log("[GeomPack Gaussian] Loading extension...");
app.registerExtension({
name: "geompack.gaussianpreview",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "GeomPackPreviewGaussian") {
console.log("[GeomPack Gaussian] Registering Preview Gaussian node");
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
// Create container for viewer + info panel
const container = document.createElement("div");
container.style.width = "100%";
container.style.height = "100%";
container.style.display = "flex";
container.style.flexDirection = "column";
container.style.backgroundColor = "#1a1a1a";
container.style.overflow = "hidden";
// Create iframe for gsplat.js viewer
const iframe = document.createElement("iframe");
iframe.style.width = "100%";
iframe.style.flex = "1 1 0";
iframe.style.minHeight = "0";
iframe.style.border = "none";
iframe.style.backgroundColor = "#1a1a1a";
// Point to gsplat.js HTML viewer (with cache buster)
iframe.src = `/extensions/${EXTENSION_FOLDER}/viewer_gaussian.html?v=` + Date.now();
// Create info panel
const infoPanel = document.createElement("div");
infoPanel.style.backgroundColor = "#1a1a1a";
infoPanel.style.borderTop = "1px solid #444";
infoPanel.style.padding = "6px 12px";
infoPanel.style.fontSize = "10px";
infoPanel.style.fontFamily = "monospace";
infoPanel.style.color = "#ccc";
infoPanel.style.lineHeight = "1.3";
infoPanel.style.flexShrink = "0";
infoPanel.style.overflow = "hidden";
infoPanel.innerHTML = '<span style="color: #888;">Gaussian splat info will appear here after execution</span>';
// Add iframe and info panel to container
container.appendChild(iframe);
container.appendChild(infoPanel);
// Add widget with required options
const widget = this.addDOMWidget("preview_gaussian", "GAUSSIAN_PREVIEW", container, {
getValue() { return ""; },
setValue(v) { }
});
// 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', () => {
iframeLoaded = true;
});
// Listen for messages from iframe
window.addEventListener('message', async (event) => {
// Handle screenshot messages
if (event.data.type === 'SCREENSHOT' && event.data.image) {
try {
// Convert base64 data URL to blob
const base64Data = event.data.image.split(',')[1];
const byteString = atob(base64Data);
const arrayBuffer = new ArrayBuffer(byteString.length);
const uint8Array = new Uint8Array(arrayBuffer);
for (let i = 0; i < byteString.length; i++) {
uint8Array[i] = byteString.charCodeAt(i);
}
const blob = new Blob([uint8Array], { type: 'image/png' });
// Generate filename with timestamp
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `gaussian-screenshot-${timestamp}.png`;
// Create FormData for upload
const formData = new FormData();
formData.append('image', blob, filename);
formData.append('type', 'output');
formData.append('subfolder', '');
// Upload to ComfyUI backend
const response = await fetch('/upload/image', {
method: 'POST',
body: formData
});
if (response.ok) {
const result = await response.json();
console.log('[GeomPack Gaussian] Screenshot saved:', result.name);
} else {
throw new Error(`Upload failed: ${response.status}`);
}
} catch (error) {
console.error('[GeomPack Gaussian] Error saving screenshot:', error);
}
}
// Handle copy image to clipboard messages
else if (event.data.type === 'COPY_IMAGE' && event.data.success) {
console.log('[GeomPack Gaussian] Image copied to clipboard successfully');
}
else if (event.data.type === 'COPY_IMAGE' && !event.data.success) {
console.error('[GeomPack Gaussian] Failed to copy image to clipboard:', event.data.error);
}
// Handle error messages from iframe
else if (event.data.type === 'MESH_ERROR' && event.data.error) {
console.error('[GeomPack Gaussian] Error from viewer:', event.data.error);
if (infoPanel) {
infoPanel.innerHTML = `<div style="color: #ff6b6b;">Error: ${event.data.error}</div>`;
}
}
});
// Set initial node size
this.setSize([512, 580]);
// Handle execution
const onExecuted = this.onExecuted;
this.onExecuted = function(message) {
console.log("[GeomPack Gaussian] onExecuted called with:", message);
onExecuted?.apply(this, arguments);
// Check for errors
if (message?.error && message.error[0]) {
infoPanel.innerHTML = `<div style="color: #ff6b6b;">Error: ${message.error[0]}</div>`;
return;
}
// The message IS the UI data (not message.ui)
if (message?.ply_file && message.ply_file[0]) {
const filename = message.ply_file[0];
const displayName = message.filename?.[0] || filename;
const fileSizeMb = message.file_size_mb?.[0] || 'N/A';
// Extract camera parameters if provided
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 = `
<div style="display: grid; grid-template-columns: auto 1fr; gap: 2px 8px;">
<span style="color: #888;">File:</span>
<span style="color: #6cc;">${displayName}</span>
<span style="color: #888;">Size:</span>
<span>${fileSizeMb} MB</span>
</div>
`;
// ComfyUI serves files via /view API — parse type/subfolder/filename
// Handles both absolute (/home/.../output/sub/file) and relative (output/sub/file) paths
let filepath;
const normalized = filename.replace(/\\/g, '/');
const pathMatch = normalized.match(/(?:^|\/)(output|input|temp)\/(.+)$/);
if (pathMatch) {
const [, type, relPath] = pathMatch;
const parts = relPath.split('/');
const fname = parts.pop();
const subfolder = parts.join('/');
filepath = `/view?filename=${encodeURIComponent(fname)}&type=${type}&subfolder=${encodeURIComponent(subfolder)}`;
} else {
// Fallback: use just the basename
const basename = normalized.split('/').pop();
filepath = `/view?filename=${encodeURIComponent(basename)}&type=output&subfolder=`;
}
// Send URL to iframe — gsplat.js fetches directly with streaming
const sendUrl = () => {
if (!iframe.contentWindow) {
console.error("[GeomPack Gaussian] Iframe contentWindow not available");
return;
}
iframe.contentWindow.postMessage({
type: "LOAD_MESH_URL",
url: filepath,
filename: filename,
extrinsics: extrinsics,
intrinsics: intrinsics,
timestamp: Date.now()
}, "*");
};
if (iframeLoaded) {
sendUrl();
} else {
setTimeout(sendUrl, 500);
}
}
};
return r;
};
}
}
});
File diff suppressed because one or more lines are too long
+112
View File
@@ -0,0 +1,112 @@
// Load Mesh Batch: folder-path autocomplete (type to navigate directories),
// inspired by VHS "Load Video (Path)" but using GeometryPack's own /gpack/getpath
// route and restricted to FOLDERS. Uses a CUSTOM floating dropdown (native
// <datalist> is unreliable inside ComfyUI's transformed DOM widgets).
import { app } from "../../../scripts/app.js";
const TAG = "[MeshBatchPath]";
console.log(`${TAG} script loaded`);
// "output/meshes/fo" -> ["output/meshes/", "fo"] (dir, remainder)
function pathStem(value) {
const v = String(value || "");
const i = v.lastIndexOf("/");
return i < 0 ? ["", v] : [v.slice(0, i + 1), v.slice(i + 1)];
}
app.registerExtension({
name: "geompack.loadmeshbatch.path",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "GeomPackLoadMeshBatch") return;
console.log(`${TAG} registering for GeomPackLoadMeshBatch`);
const onCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = onCreated?.apply(this, arguments);
const node = this;
// Replace the plain folder_path string widget with a DOM input of the
// same name (so it still serializes as folder_path).
const idx = (node.widgets || []).findIndex((w) => w.name === "folder_path");
const initial = idx >= 0 ? (node.widgets[idx].value ?? "3d") : "3d";
if (idx >= 0) node.widgets.splice(idx, 1);
const wrap = document.createElement("div");
wrap.style.cssText = "width:100%;box-sizing:border-box;padding:0 6px;";
const input = document.createElement("input");
input.type = "text";
input.value = initial;
input.placeholder = "folder path — type to autocomplete (input/… output/… or absolute)";
input.style.cssText = "width:100%;box-sizing:border-box;background:#222;color:#ddd;" +
"border:1px solid #444;border-radius:4px;padding:3px 6px;font:12px monospace;";
wrap.appendChild(input);
const widget = node.addDOMWidget("folder_path", "gpack_path", wrap, {
getValue() { return input.value; },
setValue(v) { input.value = v ?? ""; },
serialize: true,
});
widget.computeSize = (w) => [w, 30];
// Floating dropdown attached to body (avoids canvas clipping/transform).
const menu = document.createElement("div");
menu.style.cssText = "position:fixed;z-index:10000;max-height:240px;overflow-y:auto;" +
"background:#1e1e1e;border:1px solid #555;border-radius:4px;" +
"box-shadow:0 4px 14px rgba(0,0,0,0.55);font:12px monospace;display:none;";
document.body.appendChild(menu);
let items = [];
let lastDir = null;
const hide = () => { menu.style.display = "none"; };
const place = () => {
const b = input.getBoundingClientRect();
menu.style.left = b.left + "px";
menu.style.top = (b.bottom + 2) + "px";
menu.style.minWidth = b.width + "px";
};
const render = (rem) => {
const remL = rem.toLowerCase();
const [dir] = pathStem(input.value);
const matches = items.filter(
(it) => it.endsWith("/") && it.toLowerCase().startsWith(remL));
menu.innerHTML = "";
if (!matches.length) { hide(); return; }
for (const it of matches) {
const row = document.createElement("div");
row.textContent = it;
row.style.cssText = "padding:4px 9px;color:#ddd;cursor:pointer;white-space:nowrap;";
row.addEventListener("mouseenter", () => { row.style.background = "#37506b"; });
row.addEventListener("mouseleave", () => { row.style.background = ""; });
row.addEventListener("mousedown", (e) => {
e.preventDefault(); // keep input focused
input.value = dir + it; // descend into the folder
fetchDir(true); // list its contents
});
menu.appendChild(row);
}
place();
menu.style.display = "block";
};
const fetchDir = async (force) => {
const [dir, rem] = pathStem(input.value);
if (!force && dir === lastDir) { render(rem); return; }
lastDir = dir;
try {
const resp = await fetch(`/gpack/getpath?path=${encodeURIComponent(dir || ".")}`);
items = resp.ok ? await resp.json() : [];
} catch (e) { items = []; }
render(rem);
};
input.addEventListener("input", () => fetchDir(false));
input.addEventListener("focus", () => { lastDir = null; fetchDir(true); });
input.addEventListener("blur", () => setTimeout(hide, 200));
input.addEventListener("keydown", (e) => { if (e.key === "Escape") hide(); });
const origRemoved = node.onRemoved;
node.onRemoved = function () { menu.remove(); return origRemoved?.apply(this, arguments); };
return r;
};
},
});
+207
View File
@@ -0,0 +1,207 @@
// Load Mesh: drag-n-drop / upload (with progress bar) + inline "View 3D",
// for GeomPackLoadMesh. Upload mirrors ComfyUI's official Load Image
// (POST /upload/image, subfolder=3d) but via XHR so we get upload progress.
import { app } from "../../../scripts/app.js";
const TAG = "[LoadMeshUpload]";
console.log(`${TAG} script loaded`);
const EXTENSION_FOLDER = (() => {
const m = import.meta.url.match(/\/extensions\/([^/]+)\//);
return m ? m[1] : "ComfyUI-GeometryPack";
})();
const EXTS = [".obj", ".ply", ".stl", ".off", ".gltf", ".glb", ".fbx", ".dae", ".3ds", ".vtp"];
const ACCEPT = EXTS.join(",");
const isMesh = (name) => EXTS.some((x) => name.toLowerCase().endsWith(x));
// XHR upload so we can report upload progress (fetch can't). Returns combo value.
function uploadMesh(file, onProgress) {
return new Promise((resolve, reject) => {
const body = new FormData();
body.append("image", file, file.name);
body.append("subfolder", "3d");
body.append("type", "input");
body.append("overwrite", "true");
const xhr = new XMLHttpRequest();
xhr.open("POST", "/upload/image");
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && onProgress) onProgress(e.loaded / e.total, e.loaded, e.total);
};
xhr.onload = () => {
if (xhr.status === 200) {
try {
const d = JSON.parse(xhr.responseText);
const sub = (d.subfolder || "").replace(/\\/g, "/");
resolve(sub ? `${sub}/${d.name}` : d.name);
} catch (e) { reject(e); }
} else {
reject(new Error(`${xhr.status} ${xhr.responseText}`));
}
};
xhr.onerror = () => reject(new Error("network error"));
if (onProgress) onProgress(0, 0, file.size);
xhr.send(body);
});
}
function viewUrlFor(value) {
const parts = String(value).replace(/\\/g, "/").split("/");
const fname = parts.pop();
return `/view?filename=${encodeURIComponent(fname)}&type=input&subfolder=${encodeURIComponent(parts.join("/"))}`;
}
function fileWidget(node) {
return node.widgets?.find((x) => x.name === "file_path");
}
function selectValue(node, val) {
const w = fileWidget(node);
if (!w) { console.warn(`${TAG} no file_path widget`); return; }
w.options = w.options || {};
w.options.values = w.options.values || [];
if (!w.options.values.includes(val)) w.options.values.push(val);
w.value = val;
try { w.callback?.(val); } catch (e) { /* noop */ }
node.setDirtyCanvas(true, true);
}
app.registerExtension({
name: "geompack.loadmesh.upload",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "GeomPackLoadMesh") return;
console.log(`${TAG} registering for GeomPackLoadMesh`);
const onCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = onCreated?.apply(this, arguments);
const node = this;
// --- progress bar (DOM widget; collapses to 0 height when idle) ---
//
// IMPORTANT: ComfyUI's DOM-widget host <div> ("dom-widget size-full",
// position:fixed) is positioned/sized by its OWN reactive layer
// (Vue), driven by widget.isVisible()/widget.hidden -- it does NOT
// look at this element's own inline CSS (wrap.style.display) at all.
// Toggling only wrap.style.display leaves the *host* div fully
// "visible" from ComfyUI's point of view, at which point its height
// falls back to the "size-full" CSS class default (100% of the
// *viewport*, since it's position:fixed) with pointer-events:auto --
// an invisible, click-eating overlay stretching far below the node,
// permanently, regardless of whether anything is actually showing.
// Confirmed empirically (Playwright): computeSize()'s return value
// is not what drives this host div's real layout height either.
// The only thing that correctly collapses the host div (display:none
// AND pointer-events:none together) is the widget's own `hidden`
// flag, so that's the actual on/off switch -- wrap's inline style
// just controls the *content* once the host div is shown.
const wrap = document.createElement("div");
wrap.style.cssText = "width:100%;padding:0 6px;box-sizing:border-box;";
const label = document.createElement("div");
label.style.cssText = "font:10px monospace;color:#bbb;margin-bottom:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;";
const track = document.createElement("div");
track.style.cssText = "width:100%;height:6px;background:rgba(255,255,255,0.18);border-radius:3px;overflow:hidden;";
const bar = document.createElement("div");
bar.style.cssText = "width:0%;height:100%;background:#00c8ff;transition:width 0.1s linear;";
track.appendChild(bar); wrap.appendChild(label); wrap.appendChild(track);
const progWidget = node.addDOMWidget("upload_progress", "div", wrap, {
getValue() { return ""; }, setValue() { },
});
progWidget.computeSize = (w) => [w, 26];
progWidget.hidden = true; // starts idle -- see note above
const showProgress = (name, frac) => {
progWidget.hidden = false;
const pct = Math.max(0, Math.min(100, Math.round((frac || 0) * 100)));
label.textContent = `⬆ ${name} — ${pct}%`;
bar.style.width = pct + "%";
node.setDirtyCanvas(true, true);
};
const hideProgress = () => { progWidget.hidden = true; node.setDirtyCanvas(true, true); };
async function uploadList(files) {
const meshes = [...files].filter((f) => isMesh(f.name));
if (!meshes.length) { console.warn(`${TAG} no mesh files in selection/drop`); return false; }
for (const f of meshes) {
try {
showProgress(f.name, 0);
const val = await uploadMesh(f, (frac) => showProgress(f.name, frac));
selectValue(node, val);
console.log(`${TAG} uploaded -> ${val}`);
} catch (e) {
console.error(`${TAG} upload failed for ${f.name}`, e);
alert("Mesh upload failed: " + e.message);
} finally {
hideProgress();
}
}
return true;
}
node._gpUploadList = uploadList;
// hidden file picker + upload button
const input = document.createElement("input");
input.type = "file"; input.accept = ACCEPT; input.multiple = true; input.style.display = "none";
input.addEventListener("change", async () => { await uploadList(input.files); input.value = ""; });
document.body.appendChild(input);
node._gpFileInput = input; // removed in onRemoved below
node.addWidget("button", "⬆ upload / drop mesh", null, () => { console.log(`${TAG} upload button clicked`); input.click(); });
// inline View 3D
let iframe = null, viewWidget = null, savedSize = null;
const collapse = () => {
if (viewWidget && node.widgets) {
const i = node.widgets.indexOf(viewWidget); if (i >= 0) node.widgets.splice(i, 1);
}
if (iframe) iframe.remove(); iframe = null; viewWidget = null;
// Restore the pre-expand size instead of leaving the +380px
// stuck on the node -- otherwise every open/close cycle
// permanently grows the node's bounding box downward, which
// ends up silently overlapping (and blocking clicks on)
// whatever node sits below it on the canvas.
if (savedSize) { node.setSize(savedSize); savedSize = null; }
node.setDirtyCanvas(true, true);
};
const sendMesh = () => {
const w = fileWidget(node);
if (iframe?.contentWindow && w?.value)
iframe.contentWindow.postMessage({ type: "LOAD_MESH", filepath: viewUrlFor(w.value), timestamp: Date.now() }, "*");
};
node.addWidget("button", "👁 view 3d", null, () => {
if (iframe) { collapse(); return; }
const w = fileWidget(node);
if (!w || !w.value) { alert("Pick or upload a mesh first."); return; }
savedSize = [...node.size];
iframe = document.createElement("iframe");
iframe.style.cssText = "width:100%;height:100%;border:none;background:#2a2a2a;";
// VTK viewer: robust OBJ/PLY/STL/VTP reader (+ the download progress bar),
// unlike the Three.js viewer.html whose OBJLoader chokes on some OBJs.
iframe.src = `/extensions/${EXTENSION_FOLDER}/viewer_vtk.html?v=` + Date.now();
viewWidget = node.addDOMWidget("loadmesh_view3d", "MESH_PREVIEW", iframe, { getValue() { return ""; }, setValue() { } });
viewWidget.computeSize = (width) => [width || 360, width || 360];
iframe.addEventListener("load", () => setTimeout(sendMesh, 150));
setTimeout(sendMesh, 600);
node.setSize([Math.max(node.size[0], 380), savedSize[1] + 380]); node.setDirtyCanvas(true, true);
});
return r;
};
const onRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function () {
this._gpFileInput?.remove();
return onRemoved?.apply(this, arguments);
};
const onDragOver = nodeType.prototype.onDragOver;
nodeType.prototype.onDragOver = function (e) {
if ([...(e?.dataTransfer?.items || [])].some((it) => it.kind === "file")) return true;
return onDragOver?.apply(this, arguments) ?? false;
};
const onDragDrop = nodeType.prototype.onDragDrop;
nodeType.prototype.onDragDrop = async function (e) {
console.log(`${TAG} drop; files=${e?.dataTransfer?.files?.length}`);
if (this._gpUploadList && await this._gpUploadList(e?.dataTransfer?.files || [])) return true;
return onDragDrop?.apply(this, arguments) ?? false;
};
},
});
+130
View File
@@ -0,0 +1,130 @@
/**
* ComfyUI GeomPack - 3D Mesh Preview Widget
* Minimal implementation based on ComfyUI-3D-Pack pattern
*/
import { app } from "../../../scripts/app.js";
// Auto-detect extension folder name (handles ComfyUI-GeometryPack or comfyui-geometrypack)
const EXTENSION_FOLDER = (() => {
const url = import.meta.url;
const match = url.match(/\/extensions\/([^/]+)\//);
return match ? match[1] : "ComfyUI-GeometryPack";
})();
console.log("[GeomPack] Loading mesh preview extension...");
app.registerExtension({
name: "geompack.meshpreview",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "GeomPackPreviewMesh") {
console.log("[GeomPack] Registering Preview Mesh node");
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
console.log("[GeomPack DEBUG] this.widgets before:", this.widgets);
console.log("[GeomPack DEBUG] nodeData:", nodeData);
console.log("[GeomPack] Node created, adding widget");
// Create iframe for 3D viewer
const iframe = document.createElement("iframe");
iframe.style.width = "100%";
iframe.style.height = "100%";
iframe.style.border = "none";
iframe.style.backgroundColor = "#2a2a2a";
iframe.style.aspectRatio = "1";
// Point to our HTML viewer (with cache buster)
iframe.src = `/extensions/${EXTENSION_FOLDER}/viewer.html?v=` + Date.now();
// Add widget with required options
console.log("[GeomPack DEBUG] About to call addDOMWidget");
console.log("[GeomPack DEBUG] typeof this.addDOMWidget:", typeof this.addDOMWidget);
const widget = this.addDOMWidget("preview", "MESH_PREVIEW", iframe, {
getValue() { return ""; },
setValue(v) { }
});
console.log("[GeomPack DEBUG] Widget created:", widget);
console.log("[GeomPack DEBUG] Widget properties:", Object.keys(widget || {}));
console.log("[GeomPack DEBUG] Widget.id:", widget?.id);
console.log("[GeomPack DEBUG] this.widgets after addDOMWidget:", this.widgets);
// Set widget size - computeSize returns [width, height]
widget.computeSize = function(width) {
console.log("[GeomPack DEBUG] computeSize called with width:", width);
const size = [width || 512, width || 512];
console.log("[GeomPack DEBUG] computeSize returning:", size);
return size;
};
// Also try setting element size directly
widget.element = iframe;
console.log("[GeomPack DEBUG] Widget computeSize set");
console.log("[GeomPack DEBUG] Iframe dimensions:", iframe.style.width, iframe.style.height);
// Store iframe reference
this.meshViewerIframe = iframe;
// Set initial node size to be square
this.setSize([512, 512]);
console.log("[GeomPack DEBUG] Node size set to [512, 512]");
// Handle execution
const onExecuted = this.onExecuted;
this.onExecuted = function(message) {
console.log("[GeomPack] onExecuted called with message:", message);
onExecuted?.apply(this, arguments);
// The message IS the UI data (not message.ui)
if (message?.mesh_file && message.mesh_file[0]) {
const filename = message.mesh_file[0];
console.log(`[GeomPack] Loading mesh: ${filename}`);
// ComfyUI serves files via /view API — parse type/subfolder/filename
// Handles both absolute (/home/.../output/sub/file) and relative (output/sub/file) paths
let filepath;
const normalized = filename.replace(/\\/g, '/');
const pathMatch = normalized.match(/(?:^|\/)(output|input|temp)\/(.+)$/);
if (pathMatch) {
const [, type, relPath] = pathMatch;
const parts = relPath.split('/');
const fname = parts.pop();
const subfolder = parts.join('/');
filepath = `/view?filename=${encodeURIComponent(fname)}&type=${type}&subfolder=${encodeURIComponent(subfolder)}`;
} else {
// Fallback: use just the basename
const basename = normalized.split('/').pop();
filepath = `/view?filename=${encodeURIComponent(basename)}&type=output&subfolder=`;
}
// Send message to iframe (with delay to ensure iframe is loaded)
setTimeout(() => {
if (iframe.contentWindow) {
console.log(`[GeomPack] Sending postMessage to iframe: ${filepath}`);
iframe.contentWindow.postMessage({
type: "LOAD_MESH",
filepath: filepath,
timestamp: Date.now()
}, "*");
} else {
console.error("[GeomPack] Iframe contentWindow not available");
}
}, 100);
} else {
console.log("[GeomPack] No mesh_file in message data. Keys:", Object.keys(message || {}));
}
};
return r;
};
}
}
});
console.log("[GeomPack] Mesh preview extension registered");
+191
View File
@@ -0,0 +1,191 @@
/**
* ComfyUI GeomPack - Dual Mesh Preview Widget
* Unified viewer for side-by-side and overlay dual mesh visualization
* with full field visualization support
*/
import { app } from "../../../scripts/app.js";
import { EXTENSION_FOLDER, getViewerUrl } from "./utils/extensionFolder.js";
import { createContainer, createIframe, createInfoPanel } from "./utils/uiComponents.js";
import { buildDualMeshInfoHTML, formatExtents } from "./utils/formatting.js";
import { createViewerManager, createErrorHandler, buildViewUrl, createLoadDualMeshMessage } from "./utils/postMessage.js";
app.registerExtension({
name: "geompack.meshpreview.dual",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "GeomPackPreviewMeshDual") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
// Viewer state persisted via DOM widget serialization
const viewerState = { show_edges: false, camera_state: "", selected_field: "", selected_channel: "magnitude", selected_colormap: "erdc_rainbow_bright" };
// Create container for viewer + info panel
const container = createContainer();
// Create iframe for VTK.js viewer
const iframe = createIframe(getViewerUrl("viewer_dual"), { minHeight: "550px" });
// Create mesh info panel
const infoPanel = createInfoPanel("Mesh info will appear here after execution");
// Add iframe and info panel to container
container.appendChild(iframe);
container.appendChild(infoPanel);
// Add widget
const widget = this.addDOMWidget("preview_dual", "MESH_PREVIEW_DUAL", container, {
getValue() { return JSON.stringify(viewerState); },
setValue(v) {
try { Object.assign(viewerState, JSON.parse(v)); } catch(e) {}
}
});
widget.computeSize = () => [768, 680];
// Store references
this.meshViewerIframeDual = iframe;
this.meshInfoPanelDual = infoPanel;
this.setSize(this.computeSize());
// Bidirectional sync: viewer → node widgets (viewerState + real widgets like opacity)
const node = this;
window.addEventListener('message', (event) => {
// Without this check, every open viewer instance's listener
// fires for every iframe's messages, not just its own.
if (event.source !== iframe.contentWindow) return;
if (event.data.type === 'WIDGET_UPDATE') {
const { widget: name, value } = event.data;
if (name in viewerState) viewerState[name] = value;
const w = node.widgets?.find(w => w.name === name);
if (w) w.value = value;
}
});
// Create viewer manager
const viewerManager = createViewerManager(iframe, "[GeomPack Dual]");
// Listen for error messages
window.addEventListener('message', createErrorHandler(infoPanel, "[GeomPack Dual]"));
// Set initial node size
this.setSize([768, 680]);
// Handle execution
const onExecuted = this.onExecuted;
this.onExecuted = function(message) {
onExecuted?.apply(this, arguments);
if (!message?.layout) {
return;
}
const layout = message.layout[0];
const mode = message.mode?.[0] || "fields";
// Determine viewer type and name
let viewerType, viewerName;
if (layout === 'slider') {
viewerType = "slider";
viewerName = "viewer_dual_slider";
} else if (mode === "texture") {
viewerType = "texture";
viewerName = "viewer_dual_textured";
} else {
viewerType = "fields";
viewerName = "viewer_dual";
}
let postMessageData;
if (layout === 'side_by_side' || layout === 'slider') {
// Side-by-side mode
if (!message?.mesh_1_file || !message?.mesh_2_file) {
return;
}
// Build info HTML using utility
const infoHTML = buildDualMeshInfoHTML({
mode: mode,
layout: layout,
mesh1: {
vertices: message.vertex_count_1?.[0] || 'N/A',
faces: message.face_count_1?.[0] || 'N/A',
extents: message.extents_1?.[0] || [],
isWatertight: message.is_watertight_1?.[0],
hasTexture: message.has_texture_1?.[0]
},
mesh2: {
vertices: message.vertex_count_2?.[0] || 'N/A',
faces: message.face_count_2?.[0] || 'N/A',
extents: message.extents_2?.[0] || [],
isWatertight: message.is_watertight_2?.[0],
hasTexture: message.has_texture_2?.[0]
},
commonFields: message.common_fields?.[0] || []
});
infoPanel.innerHTML = infoHTML;
postMessageData = createLoadDualMeshMessage({
layout: layout,
mesh1Filepath: buildViewUrl(message.mesh_1_file[0]),
mesh2Filepath: buildViewUrl(message.mesh_2_file[0]),
opacity1: message.opacity_1?.[0] || 1.0,
opacity2: message.opacity_2?.[0] || 1.0,
showEdges: viewerState.show_edges,
cameraState: viewerState.camera_state,
selectedField: viewerState.selected_field,
selectedChannel: viewerState.selected_channel,
selectedColormap: viewerState.selected_colormap,
});
} else {
// Overlay mode
if (!message?.mesh_file) {
return;
}
// Build info HTML using utility
const infoHTML = buildDualMeshInfoHTML({
mode: mode,
layout: "overlay",
mesh1: {
vertices: message.vertex_count_1?.[0] || 'N/A',
faces: message.face_count_1?.[0] || 'N/A',
hasTexture: message.has_texture_1?.[0]
},
mesh2: {
vertices: message.vertex_count_2?.[0] || 'N/A',
faces: message.face_count_2?.[0] || 'N/A',
hasTexture: message.has_texture_2?.[0]
},
commonFields: message.common_fields?.[0] || []
});
infoPanel.innerHTML = infoHTML;
postMessageData = createLoadDualMeshMessage({
layout: layout,
meshFilepath: buildViewUrl(message.mesh_file[0]),
opacity1: message.opacity_1?.[0] || 1.0,
opacity2: message.opacity_2?.[0] || 1.0,
showEdges: viewerState.show_edges,
cameraState: viewerState.camera_state,
selectedField: viewerState.selected_field,
selectedChannel: viewerState.selected_channel,
selectedColormap: viewerState.selected_colormap,
});
}
// Switch viewer if needed and send message
viewerManager.switchViewer(viewerType, getViewerUrl(viewerName), postMessageData);
};
return r;
};
}
}
});
+325
View File
@@ -0,0 +1,325 @@
/**
* ComfyUI FBX Rigged Mesh Preview Widget
*
* Generic widget for FBX preview nodes. Loads viewer from file (not inline).
* Used by UniRig, SAM3DBody, and other extensions that preview FBX files.
*
* This file auto-registers for all known FBX preview node types when loaded.
* Extensions copy this file from the comfy-3d-viewers package at startup.
*/
import { app } from "../../../../scripts/app.js";
// Auto-register for all known FBX preview node types
const FBX_PREVIEW_NODES = [
{
nodeName: "UniRigPreviewRiggedMesh",
extensionName: "unirig.fbxpreview",
logPrefix: "[UniRig]",
fbxExportApiPath: "/unirig/export_posed_fbx"
},
{
nodeName: "SAM3DBodyPreviewRiggedMesh",
extensionName: "sam3dbody.meshpreview",
logPrefix: "[SAM3DBody]"
// Note: SAM3DBody doesn't have FBX export API
},
{
nodeName: "MocapPreviewRiggedMesh",
extensionName: "motioncapture.meshpreview",
logPrefix: "[MotionCapture]"
},
{
nodeName: "FBXPreview",
extensionName: "motioncapture.fbxpreview.legacy",
logPrefix: "[MotionCapture]"
}
];
/**
* Detect the extension folder name from the current script URL
*/
function detectExtensionFolder() {
try {
// Try to detect from import.meta.url if available
if (typeof import.meta !== 'undefined' && import.meta.url) {
const match = import.meta.url.match(/\/extensions\/([^\/]+)\//);
if (match) {
return match[1];
}
}
// Fallback: look at currently loading script
const scripts = document.getElementsByTagName('script');
for (let i = scripts.length - 1; i >= 0; i--) {
const src = scripts[i].src;
if (src) {
const match = src.match(/\/extensions\/([^\/]+)\//);
if (match) {
return match[1];
}
}
}
} catch (e) {
console.warn('[FBX Preview] Could not detect extension folder:', e);
}
return null;
}
/**
* Get the viewer URL for the FBX viewer HTML file
* @param {string} extensionFolder - The extension folder name
* @returns {string} The URL to the viewer HTML
*/
function getViewerUrl(extensionFolder, viewerFile = "viewer_fbx.html") {
// Add cache-busting timestamp
return `/extensions/${extensionFolder}/${viewerFile}?v=` + Date.now();
}
/**
* Create and register an FBX preview extension
* @param {Object} config - Configuration object
* @param {string} config.extensionName - Unique extension name (e.g., "unirig.fbxpreview")
* @param {string} config.nodeName - Node type name to hook (e.g., "UniRigPreviewRiggedMesh")
* @param {string} config.logPrefix - Log prefix for console messages (e.g., "[UniRig]")
* @param {string} [config.extensionFolder] - Override extension folder detection
* @param {string} [config.fbxExportApiPath] - API path for FBX export (e.g., "/unirig/export_posed_fbx")
*/
export function createFBXPreviewExtension(config) {
const {
extensionName,
nodeName,
logPrefix,
extensionFolder: configExtensionFolder,
fbxExportApiPath
} = config;
console.log(`${logPrefix} Loading FBX mesh preview extension...`);
// Detect extension folder if not provided
const extensionFolder = configExtensionFolder || detectExtensionFolder();
if (!extensionFolder) {
console.error(`${logPrefix} Could not detect extension folder`);
return;
}
console.log(`${logPrefix} Detected extension folder: ${extensionFolder}`);
app.registerExtension({
name: extensionName,
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === nodeName) {
console.log(`${logPrefix} Registering Preview Rigged Mesh node`);
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
console.log(`${logPrefix} Node created, adding FBX viewer widget`);
// Create iframe for FBX viewer
const iframe = document.createElement("iframe");
iframe.style.width = "100%";
iframe.style.flex = "1 1 0";
iframe.style.minHeight = "0";
iframe.style.border = "none";
iframe.style.backgroundColor = "#2a2a2a";
// Load viewer from file URL (not inline)
const viewerUrl = getViewerUrl(extensionFolder);
iframe.src = viewerUrl;
console.log(`${logPrefix} Setting iframe src to: ${viewerUrl}`);
// Add load event listener
iframe.onload = () => {
console.log(`${logPrefix} Iframe loaded successfully`);
};
iframe.onerror = (e) => {
console.error(`${logPrefix} Iframe failed to load:`, e);
};
// Add widget
const widget = this.addDOMWidget("preview", "FBX_PREVIEW", iframe, {
getValue() { return ""; },
setValue(v) { }
});
console.log(`${logPrefix} Widget created:`, widget);
// Set widget size - allow flexible height
widget.computeSize = function(width) {
const w = width || 512;
const h = w * 1.5; // Taller than wide to accommodate controls
return [w, h];
};
widget.element = iframe;
// Store iframe reference
this.fbxViewerIframe = iframe;
this.fbxViewerReady = false;
// Listen for ready message from iframe
const onMessage = (event) => {
if (event.data && event.data.type === 'VIEWER_READY') {
console.log(`${logPrefix} Viewer iframe is ready!`);
this.fbxViewerReady = true;
}
};
window.addEventListener('message', onMessage.bind(this));
const notifyIframeResize = () => {
if (iframe.contentWindow) {
const rect = iframe.getBoundingClientRect();
iframe.contentWindow.postMessage({
type: 'RESIZE',
width: rect.width,
height: rect.height
}, '*');
}
};
this.onResize = function(size) {
const isVueNodes = iframe.closest('[data-node-id]') !== null ||
document.querySelector('.vue-graph-canvas') !== null;
if (!isVueNodes && size && size[1]) {
const nodeHeight = size[1];
const headerHeight = 70;
const availableHeight = Math.max(200, nodeHeight - headerHeight);
iframe.style.height = availableHeight + 'px';
}
requestAnimationFrame(() => {
notifyIframeResize();
});
};
let resizeTimeout = null;
let lastSize = { width: 0, height: 0 };
const resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0];
const newWidth = entry.contentRect.width;
const newHeight = entry.contentRect.height;
if (Math.abs(newWidth - lastSize.width) < 1 && Math.abs(newHeight - lastSize.height) < 1) {
return;
}
lastSize = { width: newWidth, height: newHeight };
if (resizeTimeout) {
clearTimeout(resizeTimeout);
}
resizeTimeout = setTimeout(() => {
notifyIframeResize();
}, 50);
});
resizeObserver.observe(iframe);
const originalOnRemoved = this.onRemoved;
this.onRemoved = function() {
resizeObserver.disconnect();
if (resizeTimeout) {
clearTimeout(resizeTimeout);
}
window.removeEventListener('message', onMessage);
if (originalOnRemoved) {
originalOnRemoved.apply(this, arguments);
}
};
// Set initial node size (taller to accommodate controls)
this.setSize([512, 768]);
// Handle execution
const onExecuted = this.onExecuted;
this.onExecuted = function(message) {
console.log(`${logPrefix} onExecuted called with message:`, message);
onExecuted?.apply(this, arguments);
// The message contains the FBX file path
if (message?.fbx_file && message.fbx_file[0]) {
const filename = message.fbx_file[0];
console.log(`${logPrefix} Loading FBX: ${filename}`);
// Try different path formats based on filename
let filepath;
let folderType = 'output'; // default
// Detect folder type from path
if (filename.includes('/input/') || filename.includes('\\input\\')) {
folderType = 'input';
}
// Extract basename if full path provided
const basename = filename.includes('/') || filename.includes('\\')
? filename.split(/[/\\]/).pop()
: filename;
filepath = `${window.location.origin}/view?filename=${encodeURIComponent(basename)}&type=${folderType}&subfolder=`;
// Send message to iframe
const sendMessage = () => {
if (iframe.contentWindow) {
console.log(`${logPrefix} Sending postMessage to iframe: ${filepath}`);
const messageData = {
type: "LOAD_FBX",
filepath: filepath,
timestamp: Date.now()
};
// Include custom FBX export API path if configured
if (fbxExportApiPath) {
messageData.fbxExportApiPath = fbxExportApiPath;
}
iframe.contentWindow.postMessage(messageData, "*");
} else {
console.error(`${logPrefix} Iframe contentWindow not available`);
}
};
// Wait for iframe to be ready, or use timeout as fallback
if (this.fbxViewerReady) {
sendMessage();
} else {
const checkReady = setInterval(() => {
if (this.fbxViewerReady) {
clearInterval(checkReady);
sendMessage();
}
}, 50);
// Fallback timeout after 2 seconds
setTimeout(() => {
clearInterval(checkReady);
if (!this.fbxViewerReady) {
console.warn(`${logPrefix} Iframe not ready after 2s, sending anyway`);
sendMessage();
}
}, 2000);
}
} else {
console.log(`${logPrefix} No fbx_file in message data. Keys:`, Object.keys(message || {}));
}
};
return r;
};
}
}
});
console.log(`${logPrefix} FBX mesh preview extension registered`);
}
// Export detectExtensionFolder for use by other modules
export { detectExtensionFolder, getViewerUrl };
// Self-executing: register all known FBX preview node types
console.log("[FBX Preview] Auto-registering for all known FBX preview node types...");
FBX_PREVIEW_NODES.forEach(config => {
try {
createFBXPreviewExtension(config);
} catch (e) {
// Extension already registered by another custom node copy - skip
}
});
+200
View File
@@ -0,0 +1,200 @@
/**
* ComfyUI GeomPack - Multi Mesh Preview Widget
* Grid viewer for 1-4 meshes with synchronized cameras
*/
import { app } from "../../../scripts/app.js";
// Auto-detect extension folder name
const EXTENSION_FOLDER = (() => {
const url = import.meta.url;
const match = url.match(/\/extensions\/([^/]+)\//);
return match ? match[1] : "ComfyUI-GeometryPack";
})();
console.log('[GeomPack Multi JS] Loading mesh_preview_multi.js extension');
app.registerExtension({
name: "geompack.meshpreview.multi",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "GeomPackPreviewMeshMulti") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
// Viewer state persisted via DOM widget serialization
const viewerState = { layout: "wipe", show_edges: false, camera_state: "", selected_field: "", selected_channel: "magnitude", selected_colormap: "erdc_rainbow_bright" };
const viewerUrl = () => (viewerState.layout === "overlay" ? "viewer_multi.html" : "viewer_multi_slider.html");
console.log('[GeomPack Multi JS] Creating PreviewMeshMulti node widget');
// Create container for viewer + info panel
const container = document.createElement("div");
container.style.width = "100%";
container.style.height = "100%";
container.style.display = "flex";
container.style.flexDirection = "column";
container.style.backgroundColor = "#2a2a2a";
// Create iframe for VTK.js viewer
const iframe = document.createElement("iframe");
iframe.style.width = "100%";
iframe.style.flex = "1";
iframe.style.minHeight = "450px";
iframe.style.border = "none";
iframe.style.backgroundColor = "#2a2a2a";
iframe.src = `/extensions/${EXTENSION_FOLDER}/${viewerUrl()}?v=` + Date.now();
// Layout toggle bar (Wipe = N-1 draggable dividers, fixed order; Overlay = stacked)
const bar = document.createElement("div");
bar.style.cssText = "background:#1a1a1a;border-bottom:1px solid #444;padding:4px 8px;display:flex;gap:8px;align-items:center;font:11px monospace;color:#ccc;flex-shrink:0;";
const layoutSel = document.createElement("select");
layoutSel.style.cssText = "background:#333;color:#ccc;border:1px solid #555;border-radius:3px;font:11px monospace;padding:2px 6px;";
layoutSel.innerHTML = '<option value="wipe">Wipe (sliders)</option><option value="overlay">Overlay</option>';
bar.appendChild(Object.assign(document.createElement("span"), { textContent: "Layout:" }));
bar.appendChild(layoutSel);
// Create mesh info panel
const infoPanel = document.createElement("div");
infoPanel.style.backgroundColor = "#1a1a1a";
infoPanel.style.borderTop = "1px solid #444";
infoPanel.style.padding = "6px 12px";
infoPanel.style.fontSize = "10px";
infoPanel.style.fontFamily = "monospace";
infoPanel.style.color = "#ccc";
infoPanel.style.lineHeight = "1.3";
infoPanel.style.flexShrink = "0";
infoPanel.style.overflow = "hidden";
infoPanel.innerHTML = '<span style="color: #888;">Mesh info will appear here after execution</span>';
container.appendChild(bar);
container.appendChild(iframe);
container.appendChild(infoPanel);
// Add widget
const widget = this.addDOMWidget("preview_multi", "MESH_PREVIEW_MULTI", container, {
getValue() { return JSON.stringify(viewerState); },
setValue(v) {
try { Object.assign(viewerState, JSON.parse(v)); } catch(e) {}
}
});
widget.computeSize = () => [768, 580];
// Store references
this.meshViewerIframeMulti = iframe;
this.meshInfoPanelMulti = infoPanel;
this.setSize(this.computeSize());
// Bidirectional sync: viewer → node widgets (viewerState + real widgets)
const node = this;
window.addEventListener('message', (event) => {
// Without this check, every open viewer instance's listener
// fires for every iframe's messages, not just its own.
if (event.source !== iframe.contentWindow) return;
if (event.data.type === 'WIDGET_UPDATE') {
const { widget: name, value } = event.data;
if (name in viewerState) viewerState[name] = value;
const w = node.widgets?.find(w => w.name === name);
if (w) w.value = value;
}
});
// Track iframe load state + last loaded meshes (so a layout switch re-sends)
let iframeLoaded = false;
let lastLoad = null; // { numMeshes, filepaths }
const buildAndSend = () => {
if (!lastLoad || !iframe.contentWindow) return;
let msg;
if (viewerState.layout === "overlay") {
msg = { type: 'LOAD_MULTI_MESH', numMeshes: lastLoad.numMeshes, meshFiles: lastLoad.filepaths,
timestamp: Date.now(), showEdges: viewerState.show_edges, cameraState: viewerState.camera_state,
selectedField: viewerState.selected_field, selectedChannel: viewerState.selected_channel,
selectedColormap: viewerState.selected_colormap };
} else {
msg = { type: 'LOAD_MULTI_SLIDER', mesh_files: lastLoad.filepaths, timestamp: Date.now(),
show_edges: viewerState.show_edges, camera_state: viewerState.camera_state };
}
iframe.contentWindow.postMessage(msg, "*");
};
iframe.addEventListener('load', () => { iframeLoaded = true; buildAndSend(); });
// Layout switch: swap the viewer iframe; buildAndSend fires on its load
layoutSel.value = viewerState.layout;
layoutSel.addEventListener('change', () => {
viewerState.layout = layoutSel.value;
iframeLoaded = false;
iframe.src = `/extensions/${EXTENSION_FOLDER}/${viewerUrl()}?v=` + Date.now();
});
// Set initial node size
this.setSize([768, 580]);
// Handle execution
const onExecuted = this.onExecuted;
this.onExecuted = function(message) {
onExecuted?.apply(this, arguments);
if (!message?.num_meshes) {
return;
}
const numMeshes = message.num_meshes[0];
const meshFiles = message.mesh_files[0];
const vertexCounts = message.vertex_counts[0];
const faceCounts = message.face_counts[0];
const gridCols = message.grid_cols[0];
const gridRows = message.grid_rows[0];
console.log(`[GeomPack Multi] onExecuted: ${numMeshes} meshes, grid ${gridCols}x${gridRows}`);
// Per-mesh info, one column per mesh (matches the single Preview Mesh panel)
const wt = message.is_watertight_list?.[0] || [];
const avg = message.avg_edge_lengths?.[0] || [];
const bnds = message.bounds_list?.[0] || [];
const exts = message.extents_list?.[0] || [];
const fields = message.field_names_list?.[0] || null;
const num = (v) => (v == null ? '—' : Number(v).toLocaleString());
const sig = (v) => (v == null ? '—' : Number(v).toLocaleString(undefined, { maximumSignificantDigits: 4 }));
const ext = (e) => (e ? e.map((x) => Number(x).toFixed(2)).join(' × ') : '—');
const bnd = (b) => (b
? `<span style="font-size:9px;color:#aaa;">[${b[0].map((x) => Number(x).toFixed(1)).join(', ')}]<br>→ [${b[1].map((x) => Number(x).toFixed(1)).join(', ')}]</span>`
: '—');
let infoHTML = `<div style="display: grid; grid-template-columns: auto repeat(${numMeshes}, 1fr); gap: 2px 12px; font: 11px monospace;">`;
infoHTML += `<span style="color: #888;"></span>`;
for (let i = 0; i < numMeshes; i++) {
infoHTML += `<span style="color: #999; font-weight: bold; border-bottom: 1px solid #333;">Mesh ${i + 1}</span>`;
}
const row = (label, valFn) => {
infoHTML += `<span style="color: #888;">${label}</span>`;
for (let i = 0; i < numMeshes; i++) infoHTML += `<span>${valFn(i)}</span>`;
};
row('Vertices:', (i) => num(vertexCounts[i]));
row('Faces:', (i) => num(faceCounts[i]));
row('Watertight:', (i) => { const w = wt[i]; return `<span style="color:${w ? '#7c7' : '#c77'};">${w ? 'Yes' : 'No'}</span>`; });
row('Avg edge:', (i) => sig(avg[i]));
row('Extents:', (i) => ext(exts[i]));
row('Bounds:', (i) => bnd(bnds[i]));
if (fields) {
row('Fields:', (i) => {
const f = fields[i] || [];
return f.length ? `<span style="font-size:9px;color:#9bd;">${f.join(', ')}</span>` : '<span style="color:#666;">—</span>';
});
}
infoHTML += '</div>';
infoPanel.innerHTML = infoHTML;
// Prepare file paths + store for (re)send (also used when layout is switched)
const filepaths = meshFiles.map(f => `/view?filename=${encodeURIComponent(f)}&type=output&subfolder=`);
lastLoad = { numMeshes, filepaths };
buildAndSend();
};
return r;
};
}
}
});
+119
View File
@@ -0,0 +1,119 @@
/**
* ComfyUI GeomPack - UV Mesh Preview Widget
* Synchronized 3D mesh and 2D UV layout viewer with interactive picking
*/
import { app } from "../../../scripts/app.js";
// Auto-detect extension folder name (handles ComfyUI-GeometryPack or comfyui-geometrypack)
const EXTENSION_FOLDER = (() => {
const url = import.meta.url;
const match = url.match(/\/extensions\/([^/]+)\//);
return match ? match[1] : "ComfyUI-GeometryPack";
})();
console.log("[GeomPack] Loading UV mesh preview extension...");
app.registerExtension({
name: "geompack.meshpreviewuv",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "GeomPackPreviewMeshUV") {
console.log("[GeomPack] Registering Preview Mesh UV node");
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
console.log("[GeomPack UV] Node created, adding widget");
// Create iframe for UV viewer
const iframe = document.createElement("iframe");
iframe.style.width = "100%";
iframe.style.height = "100%";
iframe.style.border = "none";
iframe.style.backgroundColor = "#2a2a2a";
iframe.style.aspectRatio = "2"; // Wide aspect for split view
// Point to our UV viewer HTML
iframe.src = `/extensions/${EXTENSION_FOLDER}/viewer_uv.html?v=` + Date.now();
// Add widget
const widget = this.addDOMWidget("uv_preview", "MESH_UV_PREVIEW", iframe, {
getValue() { return ""; },
setValue(v) { }
});
// Set widget size - wider for split view
widget.computeSize = function(width) {
const w = width || 600;
const h = w / 2; // 2:1 aspect ratio
return [w, h];
};
widget.element = iframe;
// Store iframe reference
this.uvViewerIframe = iframe;
// Set initial node size (wide for split view)
this.setSize([600, 350]);
// Handle execution
const onExecuted = this.onExecuted;
this.onExecuted = function(message) {
console.log("[GeomPack UV] onExecuted called with message:", message);
onExecuted?.apply(this, arguments);
if (message?.mesh_file && message.mesh_file[0]) {
const meshFilename = message.mesh_file[0];
const uvDataFilename = message.uv_data_file ? message.uv_data_file[0] : null;
const showChecker = message.show_checker ? message.show_checker[0] : false;
const showWireframe = message.show_wireframe ? message.show_wireframe[0] : true;
console.log(`[GeomPack UV] Loading mesh: ${meshFilename}`);
console.log(`[GeomPack UV] UV data: ${uvDataFilename}`);
console.log(`[GeomPack UV] Checker: ${showChecker}, Wireframe: ${showWireframe}`);
// Build file URLs
const meshPath = `/view?filename=${encodeURIComponent(meshFilename)}&type=output&subfolder=`;
const uvDataPath = uvDataFilename ?
`/view?filename=${encodeURIComponent(uvDataFilename)}&type=output&subfolder=` : null;
// Send message to iframe
setTimeout(() => {
if (iframe.contentWindow) {
console.log(`[GeomPack UV] Sending postMessage to iframe`);
iframe.contentWindow.postMessage({
type: "LOAD_MESH_UV",
meshPath: meshPath,
uvDataPath: uvDataPath,
checker: showChecker,
wireframe: showWireframe,
timestamp: Date.now()
}, "*");
} else {
console.error("[GeomPack UV] Iframe contentWindow not available");
}
}, 100);
// Update node title with UV info
if (message.has_uvs && message.has_uvs[0]) {
const coverage = message.uv_coverage ? message.uv_coverage[0].toFixed(3) : "?";
const inUnit = message.uv_in_unit_square ? message.uv_in_unit_square[0] : false;
console.log(`[GeomPack UV] UV Coverage: ${coverage}, In Unit Square: ${inUnit}`);
} else {
console.warn("[GeomPack UV] No UV data found on mesh");
}
} else {
console.log("[GeomPack UV] No mesh_file in message data. Keys:", Object.keys(message || {}));
}
};
return r;
};
}
}
});
console.log("[GeomPack] UV mesh preview extension registered");
+160
View File
@@ -0,0 +1,160 @@
/**
* ComfyUI GeomPack - VTK.js Mesh Preview Widget
* Scientific visualization with VTK.js
*/
import { app } from "../../../scripts/app.js";
import { EXTENSION_FOLDER, getViewerUrl } from "./utils/extensionFolder.js";
import { createContainer, createIframe, createInfoPanel, showPanelError } from "./utils/uiComponents.js";
import { buildMeshInfoHTML } from "./utils/formatting.js";
import { createScreenshotHandler } from "./utils/screenshot.js";
import { createViewerManager, createErrorHandler, buildViewUrl } from "./utils/postMessage.js";
app.registerExtension({
name: "geompack.meshpreview.vtk",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "GeomPackPreviewMeshVTK") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
// Viewer state persisted via DOM widget serialization
const viewerState = { show_edges: false, camera_state: "", selected_field: "", selected_channel: "magnitude", selected_colormap: "erdc_rainbow_bright" };
// Create container for viewer + info panel
const container = createContainer();
// Create iframe for VTK.js viewer
const iframe = createIframe(getViewerUrl("viewer_vtk"));
// Create mesh info panel
const infoPanel = createInfoPanel("Mesh info will appear here after execution");
// Add iframe and info panel to container
container.appendChild(iframe);
container.appendChild(infoPanel);
// Add widget
const widget = this.addDOMWidget("preview_vtk", "MESH_PREVIEW_VTK", container, {
getValue() { return JSON.stringify(viewerState); },
setValue(v) {
try { Object.assign(viewerState, JSON.parse(v)); } catch(e) {}
},
// Low constant minimum: the DOM widget fills the node's height and
// the node resizes freely down to ~120px. A CONSTANT can't feed back
// the way a node.size-derived computeSize did (which grew forever),
// so do NOT re-add a computeSize override here.
getMinHeight: () => 120,
});
// Store references
this.meshViewerIframeVTK = iframe;
this.meshInfoPanelVTK = infoPanel;
this.setSize([512, 640]);
// Bidirectional sync: viewer → node widgets (viewerState + real widgets)
const node = this;
window.addEventListener('message', (event) => {
// Without this check, every open viewer instance's listener
// fires for every iframe's messages, not just its own.
if (event.source !== iframe.contentWindow) return;
if (event.data.type === 'WIDGET_UPDATE') {
const { widget: name, value } = event.data;
if (name in viewerState) viewerState[name] = value;
const w = node.widgets?.find(w => w.name === name);
if (w) w.value = value;
} else if (event.data.type === 'TOGGLE_INFO') {
// viewer's minimise arrow also hides/shows this node's info panel
infoPanel.style.display = event.data.collapsed ? 'none' : '';
}
});
// Create viewer manager for handling viewer switching
const viewerManager = createViewerManager(iframe, "[GeomPack VTK]");
// Listen for screenshot and error messages
window.addEventListener('message', createScreenshotHandler('vtk-screenshot'));
window.addEventListener('message', createErrorHandler(infoPanel, "[GeomPack VTK]"));
// Set initial node size
this.setSize([512, 640]);
// Handle execution
const onExecuted = this.onExecuted;
this.onExecuted = function(message) {
onExecuted?.apply(this, arguments);
if (message?.mesh_file && message.mesh_file[0]) {
const filename = message.mesh_file[0];
const viewerType = message.viewer_type?.[0] || "fields";
const mode = message.mode?.[0] || "fields";
// Determine which viewer HTML to use
let viewerName;
if (viewerType === "pbr") {
viewerName = "viewer_pbr";
} else if (viewerType === "texture") {
viewerName = "viewer_vtk_textured";
} else {
viewerName = "viewer_vtk";
}
// Build info HTML using utility
const infoHTML = buildMeshInfoHTML({
mode: mode,
vertices: message.vertex_count?.[0] || 'N/A',
faces: message.face_count?.[0] || 'N/A',
boundsMin: message.bounds_min?.[0] || [],
boundsMax: message.bounds_max?.[0] || [],
extents: message.extents?.[0] || [],
avgEdge: message.avg_edge_length?.[0],
isWatertight: message.is_watertight?.[0],
fieldNames: message.field_names?.[0] || [],
fieldRanges: message.field_ranges?.[0] || [],
hasTexture: message.has_texture?.[0],
hasVertexColors: message.has_vertex_colors?.[0],
visualKind: message.visual_kind?.[0]
});
infoPanel.innerHTML = infoHTML;
// Click a field's min/max value to fly the camera to that element.
const minPos = message.field_min_pos?.[0] || [];
const maxPos = message.field_max_pos?.[0] || [];
infoPanel.querySelectorAll('.field-extreme').forEach((el) => {
el.addEventListener('click', () => {
const i = parseInt(el.dataset.fidx, 10);
const pos = (el.dataset.which === 'min' ? minPos : maxPos)[i];
if (pos && pos.length === 3) {
viewerManager.sendMessage({ type: 'FOCUS_ON_POINT', point: pos, timestamp: Date.now() });
}
});
});
// Build file path and message
const filepath = buildViewUrl(filename);
const messageData = {
type: "LOAD_MESH",
filepath: filepath,
timestamp: Date.now(),
showEdges: viewerState.show_edges,
cameraState: viewerState.camera_state,
selectedField: viewerState.selected_field,
selectedChannel: viewerState.selected_channel,
selectedColormap: viewerState.selected_colormap,
};
// Switch viewer if needed and send message
viewerManager.switchViewer(viewerType, getViewerUrl(viewerName), messageData);
}
};
return r;
};
}
}
});
+463
View File
@@ -0,0 +1,463 @@
/**
* ComfyUI GeomPack - VTK.js Batch Mesh Preview Widget
* Scientific visualization with VTK.js and batch navigation
*/
import { app } from "../../../scripts/app.js";
// Auto-detect extension folder name (handles ComfyUI-GeometryPack or comfyui-geometrypack)
const EXTENSION_FOLDER = (() => {
const url = import.meta.url;
const match = url.match(/\/extensions\/([^/]+)\//);
return match ? match[1] : "ComfyUI-GeometryPack";
})();
app.registerExtension({
name: "geompack.meshpreview.vtk.batch",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "GeomPackPreviewMeshVTKBatch") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
// Viewer state persisted via DOM widget serialization
const viewerState = { show_edges: false, camera_state: "", selected_field: "", selected_channel: "magnitude", selected_colormap: "erdc_rainbow_bright" };
// Create container for viewer + navigation + info panel
const container = document.createElement("div");
container.style.width = "100%";
container.style.height = "100%";
container.style.display = "flex";
container.style.flexDirection = "column";
container.style.backgroundColor = "#2a2a2a";
container.style.overflow = "hidden";
// Create iframe for VTK.js viewer
const iframe = document.createElement("iframe");
iframe.style.width = "100%";
iframe.style.flex = "1 1 0";
iframe.style.minHeight = "0";
iframe.style.border = "none";
iframe.style.backgroundColor = "#2a2a2a";
// Point to VTK.js HTML viewer (with cache buster)
// Note: viewer will be dynamically switched based on mode in onExecuted
// Use unified v2 viewer with modular architecture
iframe.src = `/extensions/${EXTENSION_FOLDER}/viewer_vtk.html?v=` + Date.now();
// Track current viewer type to avoid unnecessary reloads
let currentViewerType = "fields";
// Create navigation bar for batch controls
const navBar = document.createElement("div");
navBar.style.backgroundColor = "#1a1a1a";
navBar.style.borderTop = "1px solid #444";
navBar.style.padding = "8px 12px";
navBar.style.display = "flex";
navBar.style.alignItems = "center";
navBar.style.justifyContent = "center";
navBar.style.gap = "12px";
navBar.style.fontSize = "12px";
navBar.style.color = "#ccc";
navBar.style.flexShrink = "0";
// Previous button
const prevButton = document.createElement("button");
prevButton.textContent = "< Previous";
prevButton.style.padding = "4px 12px";
prevButton.style.cursor = "pointer";
prevButton.style.backgroundColor = "#333";
prevButton.style.color = "#ccc";
prevButton.style.border = "1px solid #555";
prevButton.style.borderRadius = "3px";
prevButton.style.fontSize = "11px";
// Index display
const indexLabel = document.createElement("span");
indexLabel.textContent = "1 / 1";
indexLabel.style.minWidth = "60px";
indexLabel.style.textAlign = "center";
indexLabel.style.fontFamily = "monospace";
indexLabel.style.fontWeight = "bold";
// Next button
const nextButton = document.createElement("button");
nextButton.textContent = "Next >";
nextButton.style.padding = "4px 12px";
nextButton.style.cursor = "pointer";
nextButton.style.backgroundColor = "#333";
nextButton.style.color = "#ccc";
nextButton.style.border = "1px solid #555";
nextButton.style.borderRadius = "3px";
nextButton.style.fontSize = "11px";
// Assemble navigation bar
navBar.appendChild(prevButton);
navBar.appendChild(indexLabel);
navBar.appendChild(nextButton);
// Create mesh info panel
const infoPanel = document.createElement("div");
infoPanel.style.backgroundColor = "#1a1a1a";
infoPanel.style.borderTop = "1px solid #444";
infoPanel.style.padding = "6px 12px";
infoPanel.style.fontSize = "10px";
infoPanel.style.fontFamily = "monospace";
infoPanel.style.color = "#ccc";
infoPanel.style.lineHeight = "1.3";
infoPanel.style.flexShrink = "0";
infoPanel.style.overflow = "hidden";
infoPanel.innerHTML = '<span style="color: #888;">Mesh info will appear here after execution</span>';
// Add iframe, navigation bar, and info panel to container
container.appendChild(iframe);
container.appendChild(navBar);
container.appendChild(infoPanel);
// Add widget with required options
const widget = this.addDOMWidget("preview_vtk_batch", "MESH_PREVIEW_VTK_BATCH", container, {
getValue() { return JSON.stringify(viewerState); },
setValue(v) {
try { Object.assign(viewerState, JSON.parse(v)); } catch(e) {}
}
});
widget.computeSize = () => [512, 680]; // Increased height for viewer + navigation + info panel
// Store iframe and info panel references
this.meshViewerIframeVTKBatch = iframe;
this.meshInfoPanelVTKBatch = infoPanel;
this.meshNavBarVTKBatch = navBar;
// Bidirectional sync: viewer → node widgets (viewerState + real widgets)
const node = this;
window.addEventListener('message', (event) => {
// Without this check, every open GeomPackPreviewMeshVTKBatch
// instance's listener fires for every iframe's messages, not
// just its own -- e.g. toggling "show edges" in one viewer
// updates every other open viewer's state too.
if (event.source !== iframe.contentWindow) return;
if (event.data.type === 'WIDGET_UPDATE') {
const { widget: name, value } = event.data;
if (name in viewerState) viewerState[name] = value;
const w = node.widgets?.find(w => w.name === name);
if (w) w.value = value;
}
});
// Track iframe load state
let iframeLoaded = false;
iframe.addEventListener('load', () => {
iframeLoaded = true;
});
// Find the index widget (created by ComfyUI from INPUT_TYPES)
const indexWidget = this.widgets.find(w => w.name === "index");
// Track batch state
let currentBatchSize = 1;
let currentIndex = 0;
// Add callback to auto-execute when index changes manually
if (indexWidget) {
const originalCallback = indexWidget.callback;
indexWidget.callback = function(value) {
// Call original callback if it exists
const result = originalCallback?.apply(this, arguments);
// Update tracked index
currentIndex = value;
// Trigger re-execution (same as button clicks)
app.queuePrompt();
return result;
};
}
// Button click handlers
prevButton.addEventListener("click", () => {
if (indexWidget && currentIndex > 0) {
indexWidget.value = currentIndex - 1;
// Trigger node queue/re-execution
app.queuePrompt();
}
});
nextButton.addEventListener("click", () => {
if (indexWidget && currentIndex < currentBatchSize - 1) {
indexWidget.value = currentIndex + 1;
// Trigger node queue/re-execution
app.queuePrompt();
}
});
// Update button states
const updateNavigationButtons = () => {
prevButton.disabled = currentIndex === 0;
nextButton.disabled = currentIndex >= currentBatchSize - 1;
// Style disabled buttons
if (prevButton.disabled) {
prevButton.style.opacity = "0.4";
prevButton.style.cursor = "not-allowed";
} else {
prevButton.style.opacity = "1";
prevButton.style.cursor = "pointer";
}
if (nextButton.disabled) {
nextButton.style.opacity = "0.4";
nextButton.style.cursor = "not-allowed";
} else {
nextButton.style.opacity = "1";
nextButton.style.cursor = "pointer";
}
};
// Listen for messages from iframe
window.addEventListener('message', async (event) => {
if (event.source !== iframe.contentWindow) return;
// Handle screenshot messages
if (event.data.type === 'SCREENSHOT' && event.data.image) {
try {
// Convert base64 data URL to blob
const base64Data = event.data.image.split(',')[1];
const byteString = atob(base64Data);
const arrayBuffer = new ArrayBuffer(byteString.length);
const uint8Array = new Uint8Array(arrayBuffer);
for (let i = 0; i < byteString.length; i++) {
uint8Array[i] = byteString.charCodeAt(i);
}
const blob = new Blob([uint8Array], { type: 'image/png' });
// Generate filename with timestamp
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `vtk-screenshot-${timestamp}.png`;
// Create FormData for upload
const formData = new FormData();
formData.append('image', blob, filename);
formData.append('type', 'output'); // Save to output directory
formData.append('subfolder', ''); // Root of output folder
// Upload to ComfyUI backend
const response = await fetch('/upload/image', {
method: 'POST',
body: formData
});
if (response.ok) {
const result = await response.json();
} else {
throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
}
} catch (error) {
console.error('[GeomPack VTK Batch] Error saving screenshot:', error);
}
}
// Handle error messages from iframe
else if (event.data.type === 'MESH_ERROR' && event.data.error) {
console.error('[GeomPack VTK Batch] Error from viewer:', event.data.error);
if (infoPanel) {
infoPanel.innerHTML = `<div style="color: #ff6b6b; padding: 8px;">Error: ${event.data.error}</div>`;
}
}
});
// Set initial node size (increased for info panel + navigation)
this.setSize([512, 680]);
// Handle execution
const onExecuted = this.onExecuted;
this.onExecuted = function(message) {
onExecuted?.apply(this, arguments);
// The message IS the UI data (not message.ui)
if (message?.mesh_file && message.mesh_file[0]) {
const filename = message.mesh_file[0];
const viewerType = message.viewer_type?.[0] || "fields";
const mode = message.mode?.[0] || "fields";
// Get batch metadata
currentBatchSize = message.batch_size?.[0] || 1;
currentIndex = message.current_index?.[0] || 0;
// Update navigation controls
indexLabel.textContent = `${currentIndex + 1} / ${currentBatchSize}`;
updateNavigationButtons();
// Dynamically update index widget max based on actual batch size
if (indexWidget) {
indexWidget.options.max = currentBatchSize - 1;
// Clamp current value if out of range
if (indexWidget.value >= currentBatchSize) {
indexWidget.value = currentBatchSize - 1;
}
}
// Determine which viewer HTML to use (unified v2 viewers)
const viewerUrl = viewerType === "texture"
? `/extensions/${EXTENSION_FOLDER}/viewer_vtk_textured.html`
: `/extensions/${EXTENSION_FOLDER}/viewer_vtk.html`;
// Update mesh info panel with metadata
const vertices = message.vertex_count?.[0] || 'N/A';
const faces = message.face_count?.[0] || 'N/A';
const boundsMin = message.bounds_min?.[0] || [];
const boundsMax = message.bounds_max?.[0] || [];
const extents = message.extents?.[0] || [];
// Format bounds
let boundsStr = 'N/A';
if (boundsMin.length === 3 && boundsMax.length === 3) {
boundsStr = `[${boundsMin.map(v => v.toFixed(2)).join(', ')}] to [${boundsMax.map(v => v.toFixed(2)).join(', ')}]`;
}
// Format extents
let extentsStr = 'N/A';
if (extents.length === 3) {
extentsStr = `${extents.map(v => v.toFixed(2)).join(' x ')}`;
}
// Build info HTML
const modeLabel = mode.charAt(0).toUpperCase() + mode.slice(1);
const modeColor = mode === "texture" ? '#c8c' : '#6cc';
let infoHTML = `
<div style="display: grid; grid-template-columns: auto 1fr; gap: 2px 8px;">
<span style="color: #888;">Batch:</span>
<span style="color: #8c8; font-weight: bold;">${currentIndex + 1} / ${currentBatchSize}</span>
<span style="color: #888;">Mode:</span>
<span style="color: ${modeColor}; font-weight: bold;">${modeLabel}</span>
<span style="color: #888;">Vertices:</span>
<span>${vertices.toLocaleString()}</span>
<span style="color: #888;">Faces:</span>
<span>${faces.toLocaleString()}</span>
<span style="color: #888;">Bounds:</span>
<span style="font-size: 9px;">${boundsStr}</span>
<span style="color: #888;">Extents:</span>
<span>${extentsStr}</span>
`;
// Add watertight status (always shown)
if (message.is_watertight !== undefined) {
const watertight = message.is_watertight[0] ? 'Yes' : 'No';
const color = message.is_watertight[0] ? '#6c6' : '#c66';
infoHTML += `
<span style="color: #888;">Watertight:</span>
<span style="color: ${color};">${watertight}</span>
`;
}
// Add mode-specific info
if (viewerType === "texture") {
// Texture mode info
if (message.visual_kind !== undefined) {
const visualKind = message.visual_kind[0] || 'none';
infoHTML += `
<span style="color: #888;">Visual Kind:</span>
<span>${visualKind}</span>
`;
}
if (message.has_texture !== undefined) {
const hasTexture = message.has_texture[0] ? 'Yes' : 'No';
const texColor = message.has_texture[0] ? '#c8c' : '#888';
infoHTML += `
<span style="color: #888;">Textures:</span>
<span style="color: ${texColor};">${hasTexture}</span>
`;
}
if (message.has_vertex_colors !== undefined) {
const hasColors = message.has_vertex_colors[0] ? 'Yes' : 'No';
infoHTML += `
<span style="color: #888;">Vertex Colors:</span>
<span>${hasColors}</span>
`;
}
} else {
// Fields mode info
if (message.field_names && message.field_names[0]?.length > 0) {
const fields = message.field_names[0].join(', ');
infoHTML += `
<span style="color: #888;">Fields:</span>
<span style="font-size: 9px; color: #6cc;">${fields}</span>
`;
} else {
infoHTML += `
<span style="color: #888;">Fields:</span>
<span style="color: #888;">None</span>
`;
}
}
infoHTML += '</div>';
infoPanel.innerHTML = infoHTML;
// ComfyUI serves output files via /view API endpoint
const filepath = `/view?filename=${encodeURIComponent(filename)}&type=output&subfolder=`;
// Function to send message
const sendMessage = () => {
if (iframe.contentWindow) {
iframe.contentWindow.postMessage({
type: "LOAD_MESH",
filepath: filepath,
timestamp: Date.now(),
showEdges: viewerState.show_edges,
cameraState: viewerState.camera_state,
selectedField: viewerState.selected_field,
selectedChannel: viewerState.selected_channel,
selectedColormap: viewerState.selected_colormap,
}, "*");
} else {
console.error("[GeomPack VTK Batch] Iframe contentWindow not available");
}
};
// Reload iframe if viewer type changed
if (viewerType !== currentViewerType) {
currentViewerType = viewerType;
iframeLoaded = false;
// Set up one-time load listener before changing src
const onViewerLoaded = () => {
iframeLoaded = true;
sendMessage();
};
iframe.addEventListener('load', onViewerLoaded, { once: true });
// Change iframe src to trigger reload
iframe.src = viewerUrl + "?v=" + Date.now();
} else {
// No viewer change needed, send message immediately or after short delay
if (iframeLoaded) {
sendMessage();
} else {
setTimeout(sendMessage, 500);
}
}
} else {
}
};
return r;
};
}
}
});
+261
View File
@@ -0,0 +1,261 @@
/**
* ComfyUI GeomPack - VTK.js Textured Mesh Preview Widget
* Supports GLB/GLTF with textures and PBR materials via VTK.js GLTFImporter
*/
import { app } from "../../../scripts/app.js";
// Auto-detect extension folder name (handles ComfyUI-GeometryPack or comfyui-geometrypack)
const EXTENSION_FOLDER = (() => {
const url = import.meta.url;
const match = url.match(/\/extensions\/([^/]+)\//);
return match ? match[1] : "ComfyUI-GeometryPack";
})();
console.log("[GeomPack] Loading VTK.js textured mesh preview extension...");
app.registerExtension({
name: "geompack.meshpreview.vtk.textured",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "GeomPackPreviewMeshVTKWithTexture") {
console.log("[GeomPack] Registering Preview Mesh (VTK with Textures) node");
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
// Create container for viewer + info panel
const container = document.createElement("div");
container.style.width = "100%";
container.style.height = "100%";
container.style.display = "flex";
container.style.flexDirection = "column";
container.style.backgroundColor = "#2a2a2a";
container.style.overflow = "hidden";
// Create iframe for VTK.js textured viewer
const iframe = document.createElement("iframe");
iframe.style.width = "100%";
iframe.style.flex = "1 1 0";
iframe.style.minHeight = "0";
iframe.style.border = "none";
iframe.style.backgroundColor = "#2a2a2a";
// Point to VTK.js textured HTML viewer (with cache buster)
// Use unified v2 viewer with modular architecture
iframe.src = `/extensions/${EXTENSION_FOLDER}/viewer_vtk_textured.html?v=` + Date.now();
// Create mesh info panel
const infoPanel = document.createElement("div");
infoPanel.style.backgroundColor = "#1a1a1a";
infoPanel.style.borderTop = "1px solid #444";
infoPanel.style.padding = "6px 12px";
infoPanel.style.fontSize = "10px";
infoPanel.style.fontFamily = "monospace";
infoPanel.style.color = "#ccc";
infoPanel.style.lineHeight = "1.3";
infoPanel.style.flexShrink = "0";
infoPanel.style.overflow = "hidden";
infoPanel.innerHTML = '<span style="color: #888;">Textured mesh info will appear here after execution</span>';
// Add iframe and info panel to container
container.appendChild(iframe);
container.appendChild(infoPanel);
// Add widget with required options
const widget = this.addDOMWidget("preview_vtk_textured", "MESH_PREVIEW_VTK_TEXTURED", container, {
getValue() { return ""; },
setValue(v) { }
});
widget.computeSize = () => [512, 640]; // Increased height for viewer + info panel
// Store iframe and info panel references
this.meshViewerIframeVTKTextured = iframe;
this.meshInfoPanelVTKTextured = infoPanel;
// Track iframe load state
let iframeLoaded = false;
iframe.addEventListener('load', () => {
iframeLoaded = true;
});
// Listen for messages from iframe
window.addEventListener('message', async (event) => {
// Handle screenshot messages
if (event.data.type === 'SCREENSHOT' && event.data.image) {
console.log('[GeomPack VTK Textured] Received screenshot from iframe');
try {
// Convert base64 data URL to blob
const base64Data = event.data.image.split(',')[1];
const byteString = atob(base64Data);
const arrayBuffer = new ArrayBuffer(byteString.length);
const uint8Array = new Uint8Array(arrayBuffer);
for (let i = 0; i < byteString.length; i++) {
uint8Array[i] = byteString.charCodeAt(i);
}
const blob = new Blob([uint8Array], { type: 'image/png' });
// Generate filename with timestamp
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `vtk-textured-screenshot-${timestamp}.png`;
// Create FormData for upload
const formData = new FormData();
formData.append('image', blob, filename);
formData.append('type', 'output'); // Save to output directory
formData.append('subfolder', ''); // Root of output folder
// Upload to ComfyUI backend
console.log('[GeomPack VTK Textured] Uploading screenshot to server...');
const response = await fetch('/upload/image', {
method: 'POST',
body: formData
});
if (response.ok) {
const result = await response.json();
console.log('[GeomPack VTK Textured] Screenshot saved to outputs folder:', result.name);
} else {
throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
}
} catch (error) {
console.error('[GeomPack VTK Textured] Error saving screenshot:', error);
}
}
// Handle error messages from iframe
else if (event.data.type === 'MESH_ERROR' && event.data.error) {
console.error('[GeomPack VTK Textured] Error from viewer:', event.data.error);
if (infoPanel) {
infoPanel.innerHTML = `<div style="color: #ff6b6b; padding: 8px;">Error: ${event.data.error}</div>`;
}
}
});
// Set initial node size (increased for info panel)
this.setSize([512, 640]);
// Handle execution
const onExecuted = this.onExecuted;
this.onExecuted = function(message) {
onExecuted?.apply(this, arguments);
// The message IS the UI data (not message.ui)
if (message?.mesh_file && message.mesh_file[0]) {
const filename = message.mesh_file[0];
// Update mesh info panel with metadata
const vertices = message.vertex_count?.[0] || 'N/A';
const faces = message.face_count?.[0] || 'N/A';
const boundsMin = message.bounds_min?.[0] || [];
const boundsMax = message.bounds_max?.[0] || [];
const extents = message.extents?.[0] || [];
// Texture/material info
const hasTexture = message.has_texture?.[0] || false;
const hasVertexColors = message.has_vertex_colors?.[0] || false;
const hasMaterial = message.has_material?.[0] || false;
const visualKind = message.visual_kind?.[0] || 'none';
// Format bounds
let boundsStr = 'N/A';
if (boundsMin.length === 3 && boundsMax.length === 3) {
boundsStr = `[${boundsMin.map(v => v.toFixed(2)).join(', ')}] to [${boundsMax.map(v => v.toFixed(2)).join(', ')}]`;
}
// Format extents
let extentsStr = 'N/A';
if (extents.length === 3) {
extentsStr = `${extents.map(v => v.toFixed(2)).join(' x ')}`;
}
// Build info HTML
let infoHTML = `
<div style="display: grid; grid-template-columns: auto 1fr; gap: 2px 8px;">
<span style="color: #888;">Vertices:</span>
<span>${vertices.toLocaleString()}</span>
<span style="color: #888;">Faces:</span>
<span>${faces.toLocaleString()}</span>
<span style="color: #888;">Bounds:</span>
<span style="font-size: 9px;">${boundsStr}</span>
<span style="color: #888;">Extents:</span>
<span>${extentsStr}</span>
<span style="color: #888;">Visual Type:</span>
<span style="color: #4fc3f7;">${visualKind}</span>
<span style="color: #888;">Texture:</span>
<span style="color: ${hasTexture ? '#6c6' : '#888'};">${hasTexture ? 'Yes' : 'No'}</span>
<span style="color: #888;">Vertex Colors:</span>
<span style="color: ${hasVertexColors ? '#6c6' : '#888'};">${hasVertexColors ? 'Yes' : 'No'}</span>
<span style="color: #888;">Material:</span>
<span style="color: ${hasMaterial ? '#6c6' : '#888'};">${hasMaterial ? 'Yes' : 'No'}</span>
`;
// Add optional fields if available
if (message.is_watertight !== undefined) {
const watertight = message.is_watertight[0] ? 'Yes' : 'No';
const color = message.is_watertight[0] ? '#6c6' : '#c66';
infoHTML += `
<span style="color: #888;">Watertight:</span>
<span style="color: ${color};">${watertight}</span>
`;
}
infoHTML += '</div>';
infoPanel.innerHTML = infoHTML;
// ComfyUI serves output files via /view API endpoint
const filepath = `/view?filename=${encodeURIComponent(filename)}&type=output&subfolder=`;
// Prepare metadata to pass to viewer
const metadata = {
has_texture: hasTexture,
has_vertex_colors: hasVertexColors,
has_material: hasMaterial,
visual_kind: visualKind
};
// Function to send message
const sendMessage = () => {
if (iframe.contentWindow) {
iframe.contentWindow.postMessage({
type: "LOAD_MESH",
filepath: filepath,
metadata: metadata,
timestamp: Date.now()
}, "*");
} else {
console.error("[GeomPack VTK Textured] Iframe contentWindow not available");
}
};
// Send message after iframe is loaded
if (iframeLoaded) {
sendMessage();
} else {
setTimeout(sendMessage, 500);
}
} else {
console.log("[GeomPack VTK Textured] No mesh_file in message data. Keys:", Object.keys(message || {}));
}
};
return r;
};
}
}
});
console.log("[GeomPack] VTK.js textured mesh preview extension registered");
+112
View File
@@ -0,0 +1,112 @@
/**
* VTK.js Point Cloud Preview Widget
* For point cloud visualization nodes (SAM3, DepthAnythingV3, etc.)
*/
import { app } from "../../../scripts/app.js";
import { EXTENSION_FOLDER, getViewerUrl } from "./utils/extensionFolder.js";
import { createContainer, createIframe, createInfoPanel, createWidgetOptions } from "./utils/uiComponents.js";
import { createViewerManager, createErrorHandler, buildViewUrl } from "./utils/postMessage.js";
const POINTCLOUD_NODES = ["SAM3D_PreviewPointCloud", "DA3_PreviewPointCloud"];
app.registerExtension({
name: "comfy3d.pointcloud.vtk",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (POINTCLOUD_NODES.includes(nodeData.name)) {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
// Create container for viewer + info panel
const container = createContainer();
// Create iframe for VTK.js viewer
const iframe = createIframe(getViewerUrl("viewer_vtk"));
// Create info panel
const infoPanel = createInfoPanel("Point cloud will appear after execution");
// Add iframe and info panel to container
container.appendChild(iframe);
container.appendChild(infoPanel);
// Add widget
const widget = this.addDOMWidget("preview_vtk", "POINTCLOUD_PREVIEW_VTK", container, createWidgetOptions());
widget.computeSize = () => [512, 580];
// Store references
this._vtkIframe = iframe;
this._infoPanel = infoPanel;
// Create viewer manager
const viewerManager = createViewerManager(iframe, "[PointCloud VTK]");
// Listen for error messages
window.addEventListener('message', createErrorHandler(infoPanel, "[PointCloud VTK]"));
// Set initial node size
this.setSize([512, 580]);
return r;
};
// Handle execution
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function(message) {
onExecuted?.apply(this, arguments);
// Get file path from ui.file_path
let filePath = null;
if (message?.ui?.file_path) {
filePath = Array.isArray(message.ui.file_path) ? message.ui.file_path[0] : message.ui.file_path;
} else if (message?.file_path) {
filePath = Array.isArray(message.file_path) ? message.file_path[0] : message.file_path;
}
if (filePath && filePath.trim() !== '') {
// Normalize path
filePath = filePath.replace(/\\/g, '/').trim();
// Build URL for ComfyUI view endpoint
const outputMatch = filePath.match(/(?:^|\/)(output|input)\/(.+)$/);
let url;
if (outputMatch) {
const [, type, relativePath] = outputMatch;
const pathParts = relativePath.split('/');
const filename = pathParts.pop();
const subfolder = pathParts.join('/');
url = `/view?filename=${encodeURIComponent(filename)}&type=${type}&subfolder=${encodeURIComponent(subfolder)}`;
} else {
const filename = filePath.split('/').pop();
url = `/view?filename=${encodeURIComponent(filename)}&type=output&subfolder=`;
}
// Update info panel
if (this._infoPanel) {
const filename = filePath.split('/').pop();
this._infoPanel.innerHTML = `<strong>File:</strong> ${filename}`;
}
// Send message to iframe
if (this._vtkIframe?.contentWindow) {
const sendMessage = () => {
this._vtkIframe.contentWindow.postMessage({
type: 'LOAD_MESH',
filepath: url
}, '*');
};
if (this._vtkIframe.contentDocument?.readyState === 'complete') {
sendMessage();
} else {
this._vtkIframe.addEventListener('load', () => setTimeout(sendMessage, 100), { once: true });
}
}
}
};
}
}
});
@@ -0,0 +1,53 @@
// Preview Mesh Batch Render: show the edge_opacity widget only when show_edges
// is enabled (collapse it otherwise).
import { app } from "../../../scripts/app.js";
const TAG = "[PreviewMeshBatchRender]";
console.log(`${TAG} script loaded`);
const findW = (node, name) => (node.widgets || []).find((w) => w.name === name);
function toggleWidget(w, show) {
if (!w) return;
if (!w.__gpOrig) w.__gpOrig = { type: w.type, computeSize: w.computeSize };
if (show) {
w.type = w.__gpOrig.type;
w.computeSize = w.__gpOrig.computeSize;
} else {
w.type = "geompack_hidden"; // unknown type -> not drawn
w.computeSize = () => [0, -4]; // collapse (cancels the 4px row gap)
}
}
app.registerExtension({
name: "geompack.previewmeshbatchrender.ui",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "GeomPackPreviewMeshBatchRender") return;
console.log(`${TAG} registering for GeomPackPreviewMeshBatchRender`);
const onCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = onCreated?.apply(this, arguments);
const node = this;
const showEdges = findW(node, "show_edges");
const edgeOpacity = findW(node, "edge_opacity");
const update = () => {
toggleWidget(edgeOpacity, !!(showEdges && showEdges.value));
node.setSize(node.computeSize());
node.setDirtyCanvas(true, true);
};
if (showEdges) {
const cb = showEdges.callback;
showEdges.callback = function () {
const ret = cb?.apply(this, arguments);
update();
return ret;
};
}
setTimeout(update, 0); // apply initial state (edges off -> hidden)
return r;
};
},
});
+72
View File
@@ -0,0 +1,72 @@
// Preview Mesh Boundaries: interactive VTK.js viewer in the node. On execute the
// Python node writes a .vtp (surface + thresholded boundary edges) to temp; here
// we mount viewer_vtk.html and postMessage it the file URL to load.
import { app } from "../../../scripts/app.js";
const TAG = "[PreviewMeshBoundaries]";
console.log(`${TAG} script loaded`);
const EXTENSION_FOLDER = (() => {
const m = import.meta.url.match(/\/extensions\/([^/]+)\//);
return m ? m[1] : "ComfyUI-GeometryPack";
})();
// Build a /view URL from a payload filename. Handles bare temp names and
// type-prefixed relative paths (output/.. input/.. temp/..).
function viewUrl(name) {
const norm = String(name).replace(/\\/g, "/");
const m = norm.match(/(?:^|\/)(output|input|temp)\/(.+)$/);
if (m) {
const [, type, rel] = m;
const parts = rel.split("/");
const fname = parts.pop();
return `/view?filename=${encodeURIComponent(fname)}&type=${type}&subfolder=${encodeURIComponent(parts.join("/"))}`;
}
return `/view?filename=${encodeURIComponent(norm.split("/").pop())}&type=temp&subfolder=`;
}
app.registerExtension({
name: "geompack.previewmeshboundaries",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "GeomPackPreviewMeshBoundaries") return;
console.log(`${TAG} registering for GeomPackPreviewMeshBoundaries`);
const onCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = onCreated?.apply(this, arguments);
const node = this;
const iframe = document.createElement("iframe");
iframe.style.cssText = "width:100%;height:100%;border:none;background:#2a2a2a;aspect-ratio:1;";
iframe.src = `/extensions/${EXTENSION_FOLDER}/viewer_vtk.html?v=` + Date.now();
const widget = node.addDOMWidget("preview", "MESH_PREVIEW", iframe, {
getValue() { return ""; }, setValue() { },
});
widget.computeSize = (w) => [w || 512, w || 512];
node.size = [Math.max(node.size?.[0] || 0, 380), Math.max(node.size?.[1] || 0, 420)];
node._gpBoundIframe = iframe;
return r;
};
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
onExecuted?.apply(this, arguments);
const iframe = this._gpBoundIframe;
const name = message?.mesh_file?.[0];
if (!iframe || !name) {
console.warn(`${TAG} no mesh_file in payload`, message && Object.keys(message));
return;
}
const url = viewUrl(name);
const send = () => {
if (iframe.contentWindow) {
console.log(`${TAG} loading ${url}`);
iframe.contentWindow.postMessage(
{ type: "LOAD_MESH", filepath: url, timestamp: Date.now() }, "*");
}
};
setTimeout(send, 250); // give the iframe time if just mounted
};
},
});
+163
View File
@@ -0,0 +1,163 @@
/**
* ComfyUI GeomPack - Multi Mesh Preview Widget (Batch Input)
* Grid viewer for a mesh batch, with a user-chosen rows x cols layout.
* Always uses the fields (scalar) viewer -- no wipe/slider/texture modes.
*/
import { app } from "../../../scripts/app.js";
const EXTENSION_FOLDER = (() => {
const url = import.meta.url;
const match = url.match(/\/extensions\/([^/]+)\//);
return match ? match[1] : "ComfyUI-GeometryPack";
})();
console.log('[GeomPack Multi Batch JS] Loading preview_mesh_multi_batch.js extension');
app.registerExtension({
name: "geompack.meshpreview.multibatch",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "GeomPackPreviewMeshMultiBatch") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
// Viewer state persisted via DOM widget serialization
const viewerState = { show_edges: false, camera_state: "", selected_field: "", selected_channel: "magnitude", selected_colormap: "erdc_rainbow_bright" };
const container = document.createElement("div");
container.style.width = "100%";
container.style.height = "100%";
container.style.display = "flex";
container.style.flexDirection = "column";
container.style.backgroundColor = "#2a2a2a";
const iframe = document.createElement("iframe");
iframe.style.width = "100%";
iframe.style.flex = "1";
iframe.style.minHeight = "450px";
iframe.style.border = "none";
iframe.style.backgroundColor = "#2a2a2a";
iframe.src = `/extensions/${EXTENSION_FOLDER}/viewer_multi.html?v=` + Date.now();
const infoPanel = document.createElement("div");
infoPanel.style.backgroundColor = "#1a1a1a";
infoPanel.style.borderTop = "1px solid #444";
infoPanel.style.padding = "6px 12px";
infoPanel.style.fontSize = "10px";
infoPanel.style.fontFamily = "monospace";
infoPanel.style.color = "#ccc";
infoPanel.style.lineHeight = "1.3";
infoPanel.style.flexShrink = "0";
infoPanel.style.overflow = "hidden";
infoPanel.innerHTML = '<span style="color: #888;">Mesh info will appear here after execution</span>';
container.appendChild(iframe);
container.appendChild(infoPanel);
const widget = this.addDOMWidget("preview_multi_batch", "MESH_PREVIEW_MULTI_BATCH", container, {
getValue() { return JSON.stringify(viewerState); },
setValue(v) {
try { Object.assign(viewerState, JSON.parse(v)); } catch(e) {}
}
});
widget.computeSize = () => [768, 580];
this.meshViewerIframeMultiBatch = iframe;
this.meshInfoPanelMultiBatch = infoPanel;
this.setSize([768, 580]);
// Bidirectional sync: viewer -> node widgets (viewerState + real widgets)
const node = this;
window.addEventListener('message', (event) => {
// Without this check, every open viewer instance's listener
// fires for every iframe's messages, not just its own.
if (event.source !== iframe.contentWindow) return;
if (event.data.type === 'WIDGET_UPDATE') {
const { widget: name, value } = event.data;
if (name in viewerState) viewerState[name] = value;
const w = node.widgets?.find(w => w.name === name);
if (w) w.value = value;
}
});
let lastLoad = null; // { numMeshes, filepaths, gridCols, gridRows }
const buildAndSend = () => {
if (!lastLoad || !iframe.contentWindow) return;
const msg = {
type: 'LOAD_MULTI_MESH', numMeshes: lastLoad.numMeshes, meshFiles: lastLoad.filepaths,
gridCols: lastLoad.gridCols, gridRows: lastLoad.gridRows,
timestamp: Date.now(), showEdges: viewerState.show_edges, cameraState: viewerState.camera_state,
selectedField: viewerState.selected_field, selectedChannel: viewerState.selected_channel,
selectedColormap: viewerState.selected_colormap,
};
iframe.contentWindow.postMessage(msg, "*");
};
iframe.addEventListener('load', () => buildAndSend());
const onExecuted = this.onExecuted;
this.onExecuted = function(message) {
onExecuted?.apply(this, arguments);
if (!message?.num_meshes) {
return;
}
const numMeshes = message.num_meshes[0];
const meshFiles = message.mesh_files[0];
const vertexCounts = message.vertex_counts[0];
const faceCounts = message.face_counts[0];
const gridCols = message.grid_cols[0];
const gridRows = message.grid_rows[0];
console.log(`[GeomPack Multi Batch] onExecuted: ${numMeshes} meshes, grid ${gridCols}x${gridRows}`);
const wt = message.is_watertight_list?.[0] || [];
const avg = message.avg_edge_lengths?.[0] || [];
const bnds = message.bounds_list?.[0] || [];
const exts = message.extents_list?.[0] || [];
const fields = message.field_names_list?.[0] || null;
const num = (v) => (v == null ? '—' : Number(v).toLocaleString());
const sig = (v) => (v == null ? '—' : Number(v).toLocaleString(undefined, { maximumSignificantDigits: 4 }));
const ext = (e) => (e ? e.map((x) => Number(x).toFixed(2)).join(' × ') : '—');
const bnd = (b) => (b
? `<span style="font-size:9px;color:#aaa;">[${b[0].map((x) => Number(x).toFixed(1)).join(', ')}]<br>→ [${b[1].map((x) => Number(x).toFixed(1)).join(', ')}]</span>`
: '—');
let infoHTML = `<div style="display: grid; grid-template-columns: auto repeat(${numMeshes}, 1fr); gap: 2px 12px; font: 11px monospace;">`;
infoHTML += `<span style="color: #888;"></span>`;
for (let i = 0; i < numMeshes; i++) {
infoHTML += `<span style="color: #999; font-weight: bold; border-bottom: 1px solid #333;">Mesh ${i + 1}</span>`;
}
const row = (label, valFn) => {
infoHTML += `<span style="color: #888;">${label}</span>`;
for (let i = 0; i < numMeshes; i++) infoHTML += `<span>${valFn(i)}</span>`;
};
row('Vertices:', (i) => num(vertexCounts[i]));
row('Faces:', (i) => num(faceCounts[i]));
row('Watertight:', (i) => { const w = wt[i]; return `<span style="color:${w ? '#7c7' : '#c77'};">${w ? 'Yes' : 'No'}</span>`; });
row('Avg edge:', (i) => sig(avg[i]));
row('Extents:', (i) => ext(exts[i]));
row('Bounds:', (i) => bnd(bnds[i]));
if (fields) {
row('Fields:', (i) => {
const f = fields[i] || [];
return f.length ? `<span style="font-size:9px;color:#9bd;">${f.join(', ')}</span>` : '<span style="color:#666;">—</span>';
});
}
infoHTML += '</div>';
infoPanel.innerHTML = infoHTML;
const filepaths = meshFiles.map(f => `/view?filename=${encodeURIComponent(f)}&type=output&subfolder=`);
lastLoad = { numMeshes, filepaths, gridCols, gridRows };
buildAndSend();
};
return r;
};
}
}
});
+86
View File
@@ -0,0 +1,86 @@
// Preview Mesh Slicer: interactive clip/slice viewer (viewer_slicer.html) in-node.
// On execute the Python node writes the mesh .vtp to temp; we mount the slicer
// iframe and postMessage it the file URL.
import { app } from "../../../scripts/app.js";
const TAG = "[PreviewMeshSlicer]";
console.log(`${TAG} script loaded`);
const EXTENSION_FOLDER = (() => {
const m = import.meta.url.match(/\/extensions\/([^/]+)\//);
return m ? m[1] : "ComfyUI-GeometryPack";
})();
function viewUrl(name) {
const norm = String(name).replace(/\\/g, "/");
const m = norm.match(/(?:^|\/)(output|input|temp)\/(.+)$/);
if (m) {
const [, type, rel] = m;
const parts = rel.split("/");
const fname = parts.pop();
return `/view?filename=${encodeURIComponent(fname)}&type=${type}&subfolder=${encodeURIComponent(parts.join("/"))}`;
}
return `/view?filename=${encodeURIComponent(norm.split("/").pop())}&type=temp&subfolder=`;
}
app.registerExtension({
name: "geompack.previewmeshslicer",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "GeomPackPreviewMeshSlicer") return;
console.log(`${TAG} registering for GeomPackPreviewMeshSlicer`);
const onCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = onCreated?.apply(this, arguments);
const node = this;
const iframe = document.createElement("iframe");
iframe.style.cssText = "width:100%;height:100%;border:none;background:#2a2a2a;";
iframe.src = `/extensions/${EXTENSION_FOLDER}/viewer_slicer.html?v=` + Date.now();
const widget = node.addDOMWidget("slicer", "MESH_PREVIEW", iframe, {
getValue() { return ""; }, setValue() { },
});
widget.computeSize = (w) => [w || 480, Math.round((w || 480) * 1.15)];
node.size = [Math.max(node.size?.[0] || 0, 420), Math.max(node.size?.[1] || 0, 540)];
node._gpSlicerIframe = iframe;
// plane_json (a real schema input -> auto-created widget) is a machine-written
// mirror of whatever clip plane is picked interactively in the iframe, not
// something meant to be hand-edited -- hide it from the node body.
const planeWidget = node.widgets?.find((w) => w.name === "plane_json");
if (planeWidget) {
planeWidget.hidden = true;
planeWidget.computeSize = () => [0, -4];
}
// The clip plane only ever exists as local JS state inside the iframe
// (viewer_slicer.html) -- execute() has no other way to see it, so mirror it
// into the (hidden) plane_json widget on every change; that's what gets
// serialized into the prompt on the next queue.
window.addEventListener("message", (event) => {
if (event.source !== iframe.contentWindow) return;
if (event.data?.type !== "PLANE_UPDATE") return;
const w = node.widgets?.find((x) => x.name === "plane_json");
if (!w) return;
w.value = JSON.stringify(event.data);
node.setDirtyCanvas(true, true);
});
return r;
};
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
onExecuted?.apply(this, arguments);
const iframe = this._gpSlicerIframe;
const name = message?.mesh_file?.[0];
if (!iframe || !name) return;
const url = viewUrl(name);
setTimeout(() => {
if (iframe.contentWindow) {
console.log(`${TAG} loading ${url}`);
iframe.contentWindow.postMessage({ type: "LOAD_MESH", filepath: url, timestamp: Date.now() }, "*");
}
}, 250);
};
},
});
+185
View File
@@ -0,0 +1,185 @@
/**
* ComfyUI GeomPack - Generic Text Report Renderer
* Collapsible info panel. Arrow toggle at top of box.
*/
import { app } from "../../../scripts/app.js";
import { buildTextReportHTML } from "./utils/analysisPanel.js";
const TEXT_REPORT_NODES = [
// CADabra
"CADUltimateInspection",
// Analysis
"GeomPackMeshInfo",
"GeomPackMeshQuality",
"GeomPackDegenerateFaces",
"GeomPackConnectedComponents",
"GeomPackOpenEdges",
"GeomPackComputeCurvature",
"GeomPackSegmentByCurvature",
"GeomPackComputeFilletField",
"GeomPackSegmentPatches",
"GeomPackClusterFaces",
// Repair
"GeomPackFillHoles",
"GeomPackMeshFix",
"GeomPackCheckNormals",
"GeomPackFixNormals",
"GeomPackMeshRepair",
"GeomPackRemoveDegenerateFaces",
"GeomPackMergeVertices",
"GeomPackVisualizeNormals",
"GeomPackAddNormalsToPointCloud",
"GeomPackFixSelfIntersectionsByRemoval",
"GeomPackDetectSelfIntersections",
"GeomPackFixSelfIntersectionsByPerturbation",
"GeomPackRemeshSelfIntersections",
// Smoothing / Decimation (unified dispatcher front nodes — info forwarded
// from the hidden backend via the executed event's display_node)
"GeomPackSharpenMesh",
"GeomPackDecimateMesh",
// Remeshing
"GeomPackRemesh",
"GeomPackRemeshCGAL",
"GeomPackRemeshBlender",
"GeomPackRemeshGPU",
"RefineMesh",
"GeomPackEdgeFlip",
// Reconstruction
"GeomPackReconstructSurface",
"GeomPackAlphaWrap",
// Texture remeshing
"GeomPackTextureToGeometry",
"GeomPackDepthNormalsToMesh",
"GeomPackRemeshWithTexture",
// Boolean
"GeomPackBooleanCGAL",
"GeomPackBooleanBlender",
// Combine
"GeomPackCombineMeshes",
"GeomPackCombineMeshesBatch",
"GeomPackSplitByField",
// Transforms
"GeomPackTransformMesh",
"GeomPackNormalizeMeshToBBox",
// UV
"GeomPackUVUnwrap",
// Distance
"GeomPackPointToMeshDistance",
"GeomPackMeshToMeshDistance",
// IO
"GeomPackLoadMeshFBX",
"GeomPackLoadMeshBlend",
// CADabra
"CADEdgeAnalysis",
"CAD_Quality_Metrics",
"CADCurvature",
"CADSewFaces",
"CADCheckOverlappingFaces",
"CADSplitComponents",
"CADHealShape",
"CADFixDegenerateFaces",
"CADPrimitiveReconstruction",
"CADFaceAnalysis",
// MeshSegmenter
"MeshSegGenerateMasks",
"MeshSegLift2DTo3DLabels",
"MeshSegSmoothLabels",
"MeshSegApplyLabelsToMesh",
];
const TOGGLE_HEIGHT = 28;
const DEFAULT_CONTENT_HEIGHT = 60;
app.registerExtension({
name: "geompack.text_report",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (!TEXT_REPORT_NODES.includes(nodeData.name)) return;
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
let expanded = false;
let contentHeight = DEFAULT_CONTENT_HEIGHT;
let widgetHeight = TOGGLE_HEIGHT;
const node = this;
// --- Single container widget ---
const container = document.createElement("div");
container.style.cssText = "background:#1a1a2e; border:1px solid #333; border-radius:4px; overflow:hidden; display:flex; flex-direction:column; height:100%;";
// Header bar (always visible, top of box)
const header = document.createElement("div");
header.style.cssText = "display:flex; align-items:center; gap:6px; padding:2px 8px; cursor:pointer; user-select:none; font-size:11px; color:#888; flex-shrink:0;";
const arrow = document.createElement("span");
arrow.style.cssText = "font-size:8px; display:inline-block; transition:transform 0.15s;";
arrow.textContent = "\u25B6";
const label = document.createElement("span");
label.textContent = "Info";
header.appendChild(arrow);
header.appendChild(label);
// Content area (hidden when collapsed)
const content = document.createElement("div");
content.style.cssText = "padding:6px 8px; border-top:1px solid #333; font-size:11px; color:#ccc; display:none; overflow-y:auto; flex:1; min-height:0;";
container.appendChild(header);
container.appendChild(content);
// Use getMinHeight/getHeight — the API ComfyUI's DOMWidgetImpl actually reads
const widget = this.addDOMWidget("report_panel", "REPORT_PANEL", container, {
getValue() { return expanded ? "true" : "false"; },
setValue(v) { if (v === "true") setExpanded(true); },
getMinHeight: () => widgetHeight,
getHeight: () => widgetHeight,
});
// Force node to grow to fit the widget
requestAnimationFrame(() => {
node.setSize([node.size[0], node.computeSize()[1]]);
node.setDirtyCanvas(true, true);
});
function resize() {
widgetHeight = expanded ? TOGGLE_HEIGHT + contentHeight : TOGGLE_HEIGHT;
node.setSize([node.size[0], node.computeSize()[1]]);
node.setDirtyCanvas(true, true);
}
function setExpanded(val) {
expanded = val;
content.style.display = expanded ? "block" : "none";
arrow.style.transform = expanded ? "rotate(90deg)" : "";
resize();
}
// Toggle click
header.addEventListener("click", () => {
setExpanded(!expanded);
});
// Execution results
const onExecuted = this.onExecuted;
this.onExecuted = function(message) {
onExecuted?.apply(this, arguments);
if (message?.text && message.text.length > 0) {
const text = message.text[0];
content.innerHTML = buildTextReportHTML(text);
const firstLine = text.split("\n")[0].trim();
if (firstLine) label.textContent = firstLine;
resize();
}
};
return r;
};
}
});
+173
View File
@@ -0,0 +1,173 @@
/**
* ComfyUI GeomPack - Ultimate Mesh Inspection
* Embedded VTK.js viewer + clickable statistics table. Every defect is baked as a
* scalar field on the exported VTP; clicking a stat row reloads the viewer with that
* field as the active scalar (selectedField), lighting it up.
*/
import { app } from "../../../scripts/app.js";
// Auto-detect extension folder name (same trick as the other viewers).
const EXTENSION_FOLDER = (() => {
const url = import.meta.url;
const match = url.match(/\/extensions\/([^/]+)\//);
return match ? match[1] : "ComfyUI-GeometryPack";
})();
app.registerExtension({
name: "geompack.ultimate_mesh_inspection",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "GeomPackUltimateMeshInspection") return;
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
const node = this;
node._umiFilename = null;
node._umiActiveField = null;
const container = document.createElement("div");
container.style.cssText =
"width:100%;height:100%;display:flex;flex-direction:column;background:#222;overflow:hidden;";
// --- title / status bar ---
const bar = document.createElement("div");
bar.style.cssText =
"display:flex;align-items:center;gap:8px;padding:4px 8px;background:#1a1a1a;" +
"border-bottom:1px solid #444;font:11px monospace;color:#bbb;flex:0 0 auto;";
const title = document.createElement("div");
title.textContent = "Ultimate Mesh Inspection";
title.style.cssText = "font-weight:bold;color:#9df;";
const status = document.createElement("div");
status.style.cssText = "flex:1;color:#888;text-align:right;";
status.textContent = "(run to inspect)";
const clearBtn = document.createElement("button");
clearBtn.textContent = "clear highlight";
clearBtn.style.cssText =
"font:10px monospace;padding:2px 6px;border:1px solid #555;border-radius:3px;" +
"background:#333;color:#ccc;cursor:pointer;";
bar.appendChild(title); bar.appendChild(status); bar.appendChild(clearBtn);
// --- vtk.js iframe viewer ---
const iframe = document.createElement("iframe");
iframe.style.cssText = "width:100%;flex:1 1 0;min-height:0;border:none;background:#2a2a2a;";
iframe.src = `/extensions/${EXTENSION_FOLDER}/viewer_vtk.html?v=` + Date.now();
let iframeLoaded = false;
iframe.addEventListener("load", () => { iframeLoaded = true; });
// --- scrollable statistics table ---
const tableWrap = document.createElement("div");
tableWrap.style.cssText =
"flex:0 0 auto;max-height:260px;overflow:auto;background:#1a1a1a;border-top:1px solid #444;" +
"font:11px monospace;color:#ccc;";
const table = document.createElement("table");
table.style.cssText = "width:100%;border-collapse:collapse;";
tableWrap.appendChild(table);
container.appendChild(bar);
container.appendChild(iframe);
container.appendChild(tableWrap);
const widget = this.addDOMWidget("ultimate_inspection", "UMI", container, {
getValue() { return ""; }, setValue() {},
});
widget.computeSize = () => [520, 720];
this.setSize([520, 720]);
// --- helpers ---
const loadMesh = (selectedField, focus) => {
if (!node._umiFilename || !iframe.contentWindow) return;
const filepath = `/view?filename=${encodeURIComponent(node._umiFilename)}&type=output&subfolder=`;
iframe.contentWindow.postMessage({
type: "LOAD_MESH",
filepath,
selectedField: selectedField || null,
timestamp: Date.now(),
}, "*");
node._umiActiveField = selectedField || null;
// for "worst" rows: zoom the camera onto that single face after it loads
if (focus && focus.length === 3) {
setTimeout(() => {
iframe.contentWindow?.postMessage({
type: "FOCUS_ON_POINT", point: focus, timestamp: Date.now(),
}, "*");
}, 500);
}
};
clearBtn.addEventListener("click", () => {
loadMesh(null, null);
table.querySelectorAll("tr.umi-active").forEach(tr => tr.classList.remove("umi-active"));
});
const buildTable = (rows) => {
table.innerHTML = "";
let curGroup = null;
rows.forEach((row) => {
if (row.group !== curGroup) {
curGroup = row.group;
const gtr = document.createElement("tr");
const gtd = document.createElement("td");
gtd.colSpan = 2;
gtd.textContent = curGroup;
gtd.style.cssText =
"padding:4px 8px 2px;color:#9df;font-weight:bold;border-top:1px solid #333;";
gtr.appendChild(gtd); table.appendChild(gtr);
}
const tr = document.createElement("tr");
const tdL = document.createElement("td");
tdL.textContent = row.label;
tdL.style.cssText = "padding:2px 8px;color:#999;white-space:nowrap;";
const tdV = document.createElement("td");
tdV.textContent = row.value;
tdV.style.cssText = "padding:2px 8px;color:#eee;width:100%;";
tr.appendChild(tdL); tr.appendChild(tdV);
if (row.field) {
tr.title = `highlight: ${row.field}`;
tr.style.cursor = "pointer";
tdL.style.color = "#cfc";
tr.addEventListener("mouseenter", () => {
if (!tr.classList.contains("umi-active")) tr.style.background = "#2c2c2c";
});
tr.addEventListener("mouseleave", () => {
if (!tr.classList.contains("umi-active")) tr.style.background = "";
});
tr.addEventListener("click", () => {
table.querySelectorAll("tr.umi-active").forEach(x => {
x.classList.remove("umi-active"); x.style.background = "";
});
tr.classList.add("umi-active");
tr.style.background = "#264";
loadMesh(row.field, row.focus);
});
}
table.appendChild(tr);
});
};
// --- on execute ---
const onExecuted = this.onExecuted;
this.onExecuted = function (message) {
onExecuted?.apply(this, arguments);
const rows = message?.report?.[0] || [];
buildTable(rows);
const vc = message?.vertex_count?.[0] ?? 0;
const fc = message?.face_count?.[0] ?? 0;
const wt = message?.is_watertight?.[0];
status.textContent = `V ${vc.toLocaleString?.() ?? vc} | F ${fc.toLocaleString?.() ?? fc} | ` +
`watertight ${wt ? "yes" : "no"} — click a green row to highlight`;
const fn = message?.mesh_file?.[0];
if (fn) {
node._umiFilename = fn;
const fire = () => loadMesh(null);
if (iframeLoaded) fire(); else setTimeout(fire, 600);
}
};
return r;
};
},
});
+296
View File
@@ -0,0 +1,296 @@
/**
* Analysis panel utilities for mesh analysis nodes
* (Connected components, degenerate faces, open edges, self-intersections)
*/
/**
* Build table header HTML
* @param {string[]} columns - Column headers
* @returns {string} HTML string
*/
export function buildTableHeader(columns) {
let html = '<tr style="color: #888; border-bottom: 1px solid #333;">';
for (const col of columns) {
const align = col.align || 'left';
html += `<th style="text-align: ${align}; padding: 2px 4px;">${col.label}</th>`;
}
html += '</tr>';
return html;
}
/**
* Build table row HTML
* @param {Object[]} cells - Cell data [{value, color, align}]
* @returns {string} HTML string
*/
export function buildTableRow(cells) {
let html = '<tr style="border-bottom: 1px solid #222;">';
for (const cell of cells) {
const color = cell.color || '#ccc';
const align = cell.align || 'left';
const value = cell.value;
html += `<td style="text-align: ${align}; padding: 2px 4px; color: ${color};">${value}</td>`;
}
html += '</tr>';
return html;
}
/**
* Build "and N more" row
* @param {number} remaining - Number of remaining items
* @param {number} colSpan - Number of columns to span
* @param {string} itemName - Name of items (e.g., "components", "faces")
* @returns {string} HTML string
*/
export function buildMoreRow(remaining, colSpan, itemName = "items") {
return `
<tr><td colspan="${colSpan}" style="padding: 4px; color: #888; text-align: center;">
... and ${remaining} more ${itemName}
</td></tr>
`;
}
/**
* Build connected components HTML
* @param {Object} meshData - Mesh component data
* @param {number} maxDisplay - Max components to display (default: 20)
* @returns {string} HTML string
*/
export function buildConnectedComponentsHTML(meshData, maxDisplay = 20) {
const { mesh_name, num_components, total_faces, total_vertices, components } = meshData;
let html = '<div style="margin-bottom: 8px;">';
// Header
html += `<div style="color: #fff; font-weight: bold; margin-bottom: 4px;">`;
html += `${mesh_name}: <span style="color: #6cf;">${num_components}</span> component(s)`;
html += `</div>`;
html += `<div style="color: #888; font-size: 9px; margin-bottom: 4px;">`;
html += `Total: ${total_vertices.toLocaleString()} verts, ${total_faces.toLocaleString()} faces`;
html += `</div>`;
// Table
html += `<table style="width: 100%; border-collapse: collapse; font-size: 9px;">`;
html += buildTableHeader([
{ label: '#', align: 'left' },
{ label: 'Faces', align: 'right' },
{ label: 'Vertices', align: 'right' }
]);
const displayComponents = components.slice(0, maxDisplay);
const maxFaces = Math.max(...components.map(c => c.faces));
for (const comp of displayComponents) {
const color = comp.faces === maxFaces ? '#6f6' : '#ccc';
html += buildTableRow([
{ value: comp.id, color: '#888', align: 'left' },
{ value: comp.faces.toLocaleString(), color: color, align: 'right' },
{ value: comp.vertices.toLocaleString(), color: '#ccc', align: 'right' }
]);
// Face indices for small components
if (comp.face_indices && comp.face_indices.length > 0) {
html += `<tr style="border-bottom: 1px solid #222;">`;
html += `<td colspan="3" style="padding: 2px 4px 4px 16px; color: #888; font-size: 8px;">`;
html += `faces: ${comp.face_indices.join(', ')}`;
html += `</td></tr>`;
}
}
if (components.length > maxDisplay) {
html += buildMoreRow(components.length - maxDisplay, 3, "components");
}
html += '</table></div>';
return html;
}
/**
* Build self-intersections HTML
* @param {Object} data - Self-intersection data
* @param {number} maxDisplay - Max faces to display (default: 20)
* @returns {string} HTML string
*/
export function buildSelfIntersectionsHTML(data, maxDisplay = 20) {
const { mesh_name, num_intersecting_faces, num_intersection_pairs, total_faces, total_vertices, has_cgal, faces } = data;
let html = '<div style="margin-bottom: 8px;">';
const statusColor = num_intersecting_faces === 0 ? '#6f6' : '#f66';
const statusText = num_intersecting_faces === 0 ? 'Clean' : `${num_intersecting_faces} intersecting face(s)`;
html += `<div style="color: #fff; font-weight: bold; margin-bottom: 4px;">`;
html += `${mesh_name}: <span style="color: ${statusColor};">${statusText}</span>`;
html += `</div>`;
if (!has_cgal) {
html += `<div style="color: #f96; font-size: 9px; margin-bottom: 4px;">`;
html += `CGAL not available - results may be incomplete`;
html += `</div>`;
}
if (num_intersecting_faces > 0) {
html += `<div style="color: #888; font-size: 9px; margin-bottom: 4px;">`;
html += `${num_intersection_pairs} intersection pair(s) detected`;
html += `</div>`;
html += `<table style="width: 100%; border-collapse: collapse; font-size: 9px;">`;
html += buildTableHeader([
{ label: 'Face', align: 'left' },
{ label: 'Vertices', align: 'left' }
]);
const displayFaces = faces.slice(0, maxDisplay);
for (const face of displayFaces) {
html += buildTableRow([
{ value: face.id, color: '#f66', align: 'left' },
{ value: `[${face.vertices.join(', ')}]`, color: '#888', align: 'left' }
]);
}
if (faces.length > maxDisplay) {
html += buildMoreRow(faces.length - maxDisplay, 2, "faces");
}
html += '</table>';
} else {
html += `<div style="color: #888; font-size: 9px;">`;
html += `Total: ${total_vertices.toLocaleString()} vertices, ${total_faces.toLocaleString()} faces`;
html += `</div>`;
}
html += '</div>';
return html;
}
/**
* Build degenerate faces HTML
* @param {Object} data - Degenerate faces data
* @param {number} maxDisplay - Max faces to display (default: 20)
* @returns {string} HTML string
*/
export function buildDegenerateFacesHTML(data, maxDisplay = 20) {
const { mesh_name, num_degenerate, total_faces, total_vertices, faces } = data;
let html = '<div style="margin-bottom: 8px;">';
const statusColor = num_degenerate === 0 ? '#6f6' : '#f66';
const statusText = num_degenerate === 0 ? 'Clean' : `${num_degenerate} degenerate face(s)`;
html += `<div style="color: #fff; font-weight: bold; margin-bottom: 4px;">`;
html += `${mesh_name}: <span style="color: ${statusColor};">${statusText}</span>`;
html += `</div>`;
if (num_degenerate > 0 && faces && faces.length > 0) {
html += `<table style="width: 100%; border-collapse: collapse; font-size: 9px;">`;
html += buildTableHeader([
{ label: 'Face', align: 'left' },
{ label: 'Type', align: 'left' },
{ label: 'Vertices', align: 'left' }
]);
const displayFaces = faces.slice(0, maxDisplay);
for (const face of displayFaces) {
html += buildTableRow([
{ value: face.id, color: '#f66', align: 'left' },
{ value: face.type || 'degenerate', color: '#888', align: 'left' },
{ value: `[${face.vertices.join(', ')}]`, color: '#888', align: 'left' }
]);
}
if (faces.length > maxDisplay) {
html += buildMoreRow(faces.length - maxDisplay, 3, "faces");
}
html += '</table>';
} else {
html += `<div style="color: #888; font-size: 9px;">`;
html += `Total: ${total_vertices.toLocaleString()} vertices, ${total_faces.toLocaleString()} faces`;
html += `</div>`;
}
html += '</div>';
return html;
}
/**
* Build open edges HTML
* @param {Object} data - Open edges data
* @param {number} maxDisplay - Max edges to display (default: 20)
* @returns {string} HTML string
*/
export function buildOpenEdgesHTML(data, maxDisplay = 20) {
const { mesh_name, num_open_edges, total_faces, total_vertices, edges } = data;
let html = '<div style="margin-bottom: 8px;">';
const statusColor = num_open_edges === 0 ? '#6f6' : '#fc6';
const statusText = num_open_edges === 0 ? 'Watertight' : `${num_open_edges} open edge(s)`;
html += `<div style="color: #fff; font-weight: bold; margin-bottom: 4px;">`;
html += `${mesh_name}: <span style="color: ${statusColor};">${statusText}</span>`;
html += `</div>`;
if (num_open_edges > 0 && edges && edges.length > 0) {
html += `<table style="width: 100%; border-collapse: collapse; font-size: 9px;">`;
html += buildTableHeader([
{ label: 'Edge', align: 'left' },
{ label: 'Vertices', align: 'left' }
]);
const displayEdges = edges.slice(0, maxDisplay);
for (let i = 0; i < displayEdges.length; i++) {
const edge = displayEdges[i];
html += buildTableRow([
{ value: i, color: '#fc6', align: 'left' },
{ value: `[${edge.vertices.join(', ')}]`, color: '#888', align: 'left' }
]);
}
if (edges.length > maxDisplay) {
html += buildMoreRow(edges.length - maxDisplay, 2, "edges");
}
html += '</table>';
} else {
html += `<div style="color: #888; font-size: 9px;">`;
html += `Total: ${total_vertices.toLocaleString()} vertices, ${total_faces.toLocaleString()} faces`;
html += `</div>`;
}
html += '</div>';
return html;
}
/**
* Calculate dynamic height for analysis panel based on content
* @param {number} numRows - Number of data rows
* @param {number} maxRows - Maximum rows before truncation (default: 20)
* @param {number} baseHeight - Base height for headers (default: 40)
* @param {number} rowHeight - Height per row (default: 16)
* @param {number} minHeight - Minimum panel height (default: 80)
* @param {number} maxHeight - Maximum panel height (default: 250)
* @returns {number} Panel height in pixels
*/
export function calculatePanelHeight(numRows, maxRows = 20, baseHeight = 40, rowHeight = 16, minHeight = 80, maxHeight = 250) {
const displayRows = Math.min(numRows, maxRows) + 3; // +3 for header rows
return Math.min(Math.max(minHeight, displayRows * rowHeight + baseHeight), maxHeight);
}
/**
* Build text report HTML (for nodes that output plain text info/reports)
* @param {string} text - Plain text report
* @returns {string} HTML string with styling
*/
export function buildTextReportHTML(text) {
// Convert newlines to <br>, escape HTML
const escaped = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
return `<div style="white-space: pre-wrap; font-size: 10px; line-height: 1.4;">${escaped}</div>`;
}
+19
View File
@@ -0,0 +1,19 @@
/**
* Auto-detect extension folder name from import.meta.url
* Handles both ComfyUI-GeometryPack and comfyui-geometrypack (lowercase)
*/
export const EXTENSION_FOLDER = (() => {
const url = import.meta.url;
const match = url.match(/\/extensions\/([^/]+)\//);
return match ? match[1] : "ComfyUI-GeometryPack";
})();
/**
* Build a viewer URL with cache buster
* @param {string} viewerName - Name of the viewer HTML file (e.g., "viewer_vtk")
* @returns {string} Full URL with cache buster
*/
export function getViewerUrl(viewerName) {
return `/extensions/${EXTENSION_FOLDER}/${viewerName}.html?v=` + Date.now();
}
+290
View File
@@ -0,0 +1,290 @@
/**
* Formatting utilities for mesh metadata display
*/
/**
* Format bounds array as string
* @param {number[]} boundsMin - [x, y, z] minimum bounds
* @param {number[]} boundsMax - [x, y, z] maximum bounds
* @param {number} decimals - Number of decimal places (default: 2)
* @returns {string} Formatted bounds string or 'N/A'
*/
export function formatBounds(boundsMin, boundsMax, decimals = 2) {
if (!boundsMin || !boundsMax || boundsMin.length !== 3 || boundsMax.length !== 3) {
return 'N/A';
}
const min = boundsMin.map(v => v.toFixed(decimals)).join(', ');
const max = boundsMax.map(v => v.toFixed(decimals)).join(', ');
return `[${min}] to [${max}]`;
}
/**
* Format extents array as string
* @param {number[]} extents - [x, y, z] extents
* @param {number} decimals - Number of decimal places (default: 2)
* @returns {string} Formatted extents string or 'N/A'
*/
export function formatExtents(extents, decimals = 2) {
if (!extents || extents.length !== 3) {
return 'N/A';
}
return extents.map(v => v.toFixed(decimals)).join(' x ');
}
/**
* Format a {typeName: count} map as a sorted, human-readable string, e.g.
* "3 Planes, 2 Cylinders, 1 BSpline".
* @param {Object<string, number>} counts
* @returns {string} Formatted string (empty string if counts is empty/missing)
*/
export function formatTypeCounts(counts) {
if (!counts) return '';
return Object.entries(counts)
.sort((a, b) => b[1] - a[1])
.map(([name, count]) => `${count} ${name}${count === 1 ? '' : 's'}`)
.join(', ');
}
/**
* Get color for mode label
* @param {string} mode - Viewer mode (fields, texture, pbr)
* @returns {string} CSS color
*/
export function getModeColor(mode) {
switch (mode) {
case "texture (PBR)":
case "pbr":
return '#fc6';
case "texture":
return '#c8c';
default:
return '#6cc';
}
}
/**
* Get color for watertight status
* @param {boolean} isWatertight
* @returns {string} CSS color
*/
export function getWatertightColor(isWatertight) {
return isWatertight ? '#6c6' : '#c66';
}
/**
* Build mesh info HTML for single mesh viewer
* @param {Object} data - Mesh metadata
* @returns {string} HTML string
*/
export function buildMeshInfoHTML(data) {
const {
mode = "fields",
vertices = 'N/A',
faces = 'N/A',
boundsMin = [],
boundsMax = [],
extents = [],
avgEdge,
isWatertight,
fieldNames = [],
fieldRanges = [],
hasTexture,
hasVertexColors,
visualKind
} = data;
const modeLabel = mode.charAt(0).toUpperCase() + mode.slice(1);
const modeColor = getModeColor(mode);
const boundsStr = formatBounds(boundsMin, boundsMax);
const extentsStr = formatExtents(extents);
let html = `
<div style="display: grid; grid-template-columns: auto 1fr; gap: 2px 8px;">
<span style="color: #888;">Mode:</span>
<span style="color: ${modeColor}; font-weight: bold;">${modeLabel}</span>
<span style="color: #888;">Vertices:</span>
<span>${typeof vertices === 'number' ? vertices.toLocaleString() : vertices}</span>
<span style="color: #888;">Faces:</span>
<span>${typeof faces === 'number' ? faces.toLocaleString() : faces}</span>
<span style="color: #888;">Bounds:</span>
<span style="font-size: 9px;">${boundsStr}</span>
<span style="color: #888;">Extents:</span>
<span>${extentsStr}</span>
`;
// Average edge length (mesh resolution)
if (avgEdge !== undefined && avgEdge !== null) {
const avgStr = (typeof avgEdge === 'number')
? avgEdge.toLocaleString(undefined, { maximumSignificantDigits: 4 })
: avgEdge;
html += `
<span style="color: #888;">Avg edge:</span>
<span>${avgStr}</span>
`;
}
// Watertight status
if (isWatertight !== undefined) {
const watertightText = isWatertight ? 'Yes' : 'No';
const watertightColor = getWatertightColor(isWatertight);
html += `
<span style="color: #888;">Watertight:</span>
<span style="color: ${watertightColor};">${watertightText}</span>
`;
}
// Mode-specific info
if (mode === "texture" || mode === "pbr") {
if (visualKind !== undefined) {
html += `
<span style="color: #888;">Visual Kind:</span>
<span>${visualKind}</span>
`;
}
if (hasTexture !== undefined) {
const texColor = hasTexture ? '#c8c' : '#888';
html += `
<span style="color: #888;">Textures:</span>
<span style="color: ${texColor};">${hasTexture ? 'Yes' : 'No'}</span>
`;
}
if (hasVertexColors !== undefined) {
html += `
<span style="color: #888;">Vertex Colors:</span>
<span>${hasVertexColors ? 'Yes' : 'No'}</span>
`;
}
} else {
// Fields mode — annotate each field with its [min, max] value range
if (fieldNames && fieldNames.length > 0) {
const fmt = (n) => {
if (n == null || !isFinite(n)) return '?';
if (Number.isInteger(n) && Math.abs(n) < 1e7) return String(n);
return Number(Number(n).toPrecision(4)).toString();
};
const ext = (i, which, v) =>
`<span class="field-extreme" data-fidx="${i}" data-which="${which}" ` +
`style="cursor:pointer;text-decoration:underline dotted;" ` +
`title="focus camera on ${which}">${fmt(v)}</span>`;
const fields = fieldNames.map((name, i) => {
const r = fieldRanges && fieldRanges[i];
return (r && r.length === 2)
? `${name} [${ext(i, 'min', r[0])}, ${ext(i, 'max', r[1])}]`
: name;
}).join(', ');
html += `
<span style="color: #888;">Fields:</span>
<span style="font-size: 9px; color: #6cc;">${fields}</span>
`;
} else {
html += `
<span style="color: #888;">Fields:</span>
<span style="color: #888;">None</span>
`;
}
}
html += '</div>';
return html;
}
/**
* Build dual mesh info HTML for side-by-side comparison
* @param {Object} data - Dual mesh metadata
* @returns {string} HTML string
*/
export function buildDualMeshInfoHTML(data) {
const {
mode = "fields",
layout = "side_by_side",
mesh1 = {},
mesh2 = {},
commonFields = []
} = data;
const modeLabel = mode.charAt(0).toUpperCase() + mode.slice(1);
const modeColor = getModeColor(mode);
const extentsStr1 = formatExtents(mesh1.extents);
const extentsStr2 = formatExtents(mesh2.extents);
const layoutSuffix = layout === "overlay" ? " (Overlay)" : "";
let html = `
<div style="display: grid; grid-template-columns: auto 1fr 1fr; gap: 2px 12px;">
<span style="color: #888;">Mode:</span>
<span colspan="2" style="grid-column: 2 / 4; color: ${modeColor}; font-weight: bold;">${modeLabel}${layoutSuffix}</span>
<span style="color: #888;"></span>
<span style="color: #999; font-weight: bold; border-bottom: 1px solid #333;">Mesh 1</span>
<span style="color: #999; font-weight: bold; border-bottom: 1px solid #333;">Mesh 2</span>
<span style="color: #888;">Vertices:</span>
<span>${(mesh1.vertices || 'N/A').toLocaleString()}</span>
<span>${(mesh2.vertices || 'N/A').toLocaleString()}</span>
<span style="color: #888;">Faces:</span>
<span>${(mesh1.faces || 'N/A').toLocaleString()}</span>
<span>${(mesh2.faces || 'N/A').toLocaleString()}</span>
`;
if (mesh1.faceTypes !== undefined && mesh2.faceTypes !== undefined) {
html += `
<span style="color: #888;">Face types:</span>
<span style="font-size: 9px;">${mesh1.faceTypes || 'n/a'}</span>
<span style="font-size: 9px;">${mesh2.faceTypes || 'n/a'}</span>
`;
}
if (mesh1.edgeTypes !== undefined && mesh2.edgeTypes !== undefined) {
html += `
<span style="color: #888;">Edge types:</span>
<span style="font-size: 9px;">${mesh1.edgeTypes || 'n/a'}</span>
<span style="font-size: 9px;">${mesh2.edgeTypes || 'n/a'}</span>
`;
}
html += `
<span style="color: #888;">Extents:</span>
<span style="font-size: 9px;">${extentsStr1}</span>
<span style="font-size: 9px;">${extentsStr2}</span>
`;
// Watertight info
if (mesh1.isWatertight !== undefined && mesh2.isWatertight !== undefined) {
const color1 = getWatertightColor(mesh1.isWatertight);
const color2 = getWatertightColor(mesh2.isWatertight);
html += `
<span style="color: #888;">Watertight:</span>
<span style="color: ${color1};">${mesh1.isWatertight ? 'Yes' : 'No'}</span>
<span style="color: ${color2};">${mesh2.isWatertight ? 'Yes' : 'No'}</span>
`;
}
// Mode-specific info
if (mode === "texture") {
if (mesh1.hasTexture !== undefined) {
const tex1 = mesh1.hasTexture ? 'Yes' : 'No';
const tex2 = mesh2.hasTexture ? 'Yes' : 'No';
html += `
<span style="color: #888;">Textures:</span>
<span style="color: ${mesh1.hasTexture ? '#c8c' : '#888'};">${tex1}</span>
<span style="color: ${mesh2.hasTexture ? '#c8c' : '#888'};">${tex2}</span>
`;
}
} else {
// Fields mode
if (commonFields && commonFields.length > 0) {
html += `
<span style="color: #888;">Fields:</span>
<span colspan="2" style="grid-column: 2 / 4; color: #9c9;">${commonFields.length} shared field(s)</span>
`;
}
}
html += '</div>';
return html;
}
+11
View File
@@ -0,0 +1,11 @@
/**
* ComfyUI 3D Viewer Utilities
* Shared utilities for ComfyUI 3D viewer integration
*/
export * from './extensionFolder.js';
export * from './screenshot.js';
export * from './uiComponents.js';
export * from './formatting.js';
export * from './analysisPanel.js';
export * from './postMessage.js';
+196
View File
@@ -0,0 +1,196 @@
/**
* PostMessage utilities for iframe communication
*/
/**
* Create a message sender function for an iframe
* @param {HTMLIFrameElement} iframe - The iframe element
* @param {string} logPrefix - Logging prefix (e.g., "[GeomPack VTK]")
* @returns {Function} Message sending function
*/
export function createMessageSender(iframe, logPrefix = "[Viewer]") {
return (data) => {
if (iframe.contentWindow) {
iframe.contentWindow.postMessage(data, "*");
} else {
console.error(`${logPrefix} Iframe contentWindow not available`);
}
};
}
/**
* Create a mesh load message
* @param {string} filepath - URL path to the mesh file
* @returns {Object} Message data object
*/
export function createLoadMeshMessage(filepath) {
return {
type: "LOAD_MESH",
filepath: filepath,
timestamp: Date.now()
};
}
/**
* Create a dual mesh load message
* @param {Object} options - Message options
* @returns {Object} Message data object
*/
export function createLoadDualMeshMessage(options) {
const {
layout,
mesh1Filepath,
mesh2Filepath,
meshFilepath, // For overlay mode
opacity1 = 1.0,
opacity2 = 1.0,
showEdges = false,
cameraState = "",
selectedField = "",
selectedChannel = "magnitude",
selectedColormap = "erdc_rainbow_bright",
} = options;
return {
type: 'LOAD_DUAL_MESH',
layout: layout,
mesh1Filepath,
mesh2Filepath,
meshFilepath,
opacity1,
opacity2,
showEdges,
cameraState,
selectedField,
selectedChannel,
selectedColormap,
timestamp: Date.now()
};
}
/**
* Build ComfyUI view API URL for a file.
* Handles absolute paths (/home/.../output/sub/file.glb),
* relative paths (output/sub/file.glb), and bare filenames (file.glb).
* @param {string} filename - The filename or path
* @param {string} defaultType - Default file type (default: "output")
* @param {string} defaultSubfolder - Default subfolder path (default: "")
* @returns {string} API URL
*/
export function buildViewUrl(filename, defaultType = "output", defaultSubfolder = "") {
const normalized = filename.replace(/\\/g, '/');
const pathMatch = normalized.match(/(?:^|\/)(output|input|temp)\/(.+)$/);
if (pathMatch) {
const [, type, relPath] = pathMatch;
const parts = relPath.split('/');
const fname = parts.pop();
const subfolder = parts.join('/');
return `/view?filename=${encodeURIComponent(fname)}&type=${type}&subfolder=${encodeURIComponent(subfolder)}`;
}
// Bare filename fallback
const basename = normalized.split('/').pop();
return `/view?filename=${encodeURIComponent(basename)}&type=${defaultType}&subfolder=${defaultSubfolder}`;
}
/**
* Create an iframe viewer manager for handling viewer switching
* @param {HTMLIFrameElement} iframe - The iframe element
* @param {string} logPrefix - Logging prefix
* @returns {Object} Viewer manager object
*/
export function createViewerManager(iframe, logPrefix = "[Viewer]") {
let currentViewerType = null;
let iframeLoaded = false;
let pendingMessage = null;
// Track iframe load state
iframe.addEventListener('load', () => {
iframeLoaded = true;
// Send pending message if any
if (pendingMessage && iframe.contentWindow) {
iframe.contentWindow.postMessage(pendingMessage, "*");
pendingMessage = null;
}
});
return {
/**
* Get current viewer type
* @returns {string|null}
*/
getCurrentType() {
return currentViewerType;
},
/**
* Check if iframe is loaded
* @returns {boolean}
*/
isLoaded() {
return iframeLoaded;
},
/**
* Send a message to the iframe viewer
* If iframe is not loaded yet, queue the message
* @param {Object} data - Message data
*/
sendMessage(data) {
if (iframeLoaded && iframe.contentWindow) {
iframe.contentWindow.postMessage(data, "*");
} else {
pendingMessage = data;
}
},
/**
* Switch to a different viewer type if needed
* @param {string} viewerType - New viewer type
* @param {string} viewerUrl - URL of the new viewer
* @param {Object} messageData - Message to send after viewer loads
* @returns {boolean} True if viewer was switched, false if already on correct viewer
*/
switchViewer(viewerType, viewerUrl, messageData) {
if (viewerType === currentViewerType) {
// No switch needed, just send message
this.sendMessage(messageData);
return false;
}
// Viewer switch needed
currentViewerType = viewerType;
iframeLoaded = false;
pendingMessage = messageData;
// Change iframe src to trigger reload
iframe.src = viewerUrl + "?v=" + Date.now();
return true;
},
/**
* Reset the manager state
*/
reset() {
currentViewerType = null;
iframeLoaded = false;
pendingMessage = null;
}
};
}
/**
* Create an error message handler for iframe errors
* @param {HTMLElement} infoPanel - The info panel to display errors
* @param {string} logPrefix - Logging prefix
* @returns {Function} Event handler function
*/
export function createErrorHandler(infoPanel, logPrefix = "[Viewer]") {
return (event) => {
if (event.data.type === 'MESH_ERROR' && event.data.error) {
console.error(`${logPrefix} Error from viewer:`, event.data.error);
if (infoPanel) {
infoPanel.innerHTML = `<div style="color: #ff6b6b; padding: 8px;">Error: ${event.data.error}</div>`;
}
}
};
}
+71
View File
@@ -0,0 +1,71 @@
/**
* Screenshot capture and upload utility for ComfyUI viewers
*/
/**
* Convert base64 data URL to Blob
* @param {string} dataUrl - Base64 data URL (e.g., "data:image/png;base64,...")
* @returns {Blob} PNG image blob
*/
export function dataUrlToBlob(dataUrl) {
const base64Data = dataUrl.split(',')[1];
const byteString = atob(base64Data);
const arrayBuffer = new ArrayBuffer(byteString.length);
const uint8Array = new Uint8Array(arrayBuffer);
for (let i = 0; i < byteString.length; i++) {
uint8Array[i] = byteString.charCodeAt(i);
}
return new Blob([uint8Array], { type: 'image/png' });
}
/**
* Upload screenshot to ComfyUI backend
* @param {string} dataUrl - Base64 data URL of the screenshot
* @param {string} prefix - Filename prefix (e.g., "vtk-screenshot")
* @returns {Promise<{name: string, subfolder: string, type: string}>} Upload result
*/
export async function uploadScreenshot(dataUrl, prefix = 'screenshot') {
const blob = dataUrlToBlob(dataUrl);
// Generate filename with timestamp
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `${prefix}-${timestamp}.png`;
// Create FormData for upload
const formData = new FormData();
formData.append('image', blob, filename);
formData.append('type', 'output'); // Save to output directory
formData.append('subfolder', ''); // Root of output folder
// Upload to ComfyUI backend
const response = await fetch('/upload/image', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
}
return response.json();
}
/**
* Create a message handler for screenshot events from iframe
* @param {string} prefix - Filename prefix for screenshots
* @param {Function} onError - Error callback
* @returns {Function} Event handler function
*/
export function createScreenshotHandler(prefix = 'screenshot', onError = console.error) {
return async (event) => {
if (event.data.type === 'SCREENSHOT' && event.data.image) {
try {
await uploadScreenshot(event.data.image, prefix);
} catch (error) {
onError(`Error saving screenshot: ${error}`);
}
}
};
}
+133
View File
@@ -0,0 +1,133 @@
/**
* UI component factory functions for ComfyUI 3D viewer widgets
*/
/**
* Create a container div for viewer + info panel
* @param {Object} options - Container options
* @param {string} options.backgroundColor - Background color (default: "#2a2a2a")
* @returns {HTMLDivElement} Container element
*/
export function createContainer(options = {}) {
const { backgroundColor = "#2a2a2a" } = options;
const container = document.createElement("div");
container.style.width = "100%";
container.style.height = "100%";
container.style.display = "flex";
container.style.flexDirection = "column";
container.style.backgroundColor = backgroundColor;
container.style.overflow = "hidden";
return container;
}
/**
* Create an iframe for embedding a viewer
* @param {string} src - Initial iframe source URL
* @param {Object} options - Iframe options
* @param {string} options.minHeight - Minimum height (default: "0")
* @param {string} options.backgroundColor - Background color (default: "#2a2a2a")
* @returns {HTMLIFrameElement} Iframe element
*/
export function createIframe(src, options = {}) {
const {
minHeight = "0",
backgroundColor = "#2a2a2a"
} = options;
const iframe = document.createElement("iframe");
iframe.style.width = "100%";
iframe.style.flex = "1 1 0";
iframe.style.minHeight = minHeight;
iframe.style.border = "none";
iframe.style.backgroundColor = backgroundColor;
iframe.src = src;
return iframe;
}
/**
* Create an info panel for displaying mesh metadata
* @param {string} placeholder - Initial placeholder text
* @param {Object} options - Panel options
* @returns {HTMLDivElement} Info panel element
*/
export function createInfoPanel(placeholder = "Info will appear here after execution", options = {}) {
const {
backgroundColor = "#1a1a1a",
borderTop = "1px solid #444",
padding = "6px 12px",
fontSize = "10px",
fontFamily = "monospace",
color = "#ccc",
lineHeight = "1.3"
} = options;
const infoPanel = document.createElement("div");
infoPanel.style.backgroundColor = backgroundColor;
infoPanel.style.borderTop = borderTop;
infoPanel.style.padding = padding;
infoPanel.style.fontSize = fontSize;
infoPanel.style.fontFamily = fontFamily;
infoPanel.style.color = color;
infoPanel.style.lineHeight = lineHeight;
infoPanel.style.flexShrink = "0";
infoPanel.style.overflow = "hidden";
infoPanel.innerHTML = `<span style="color: #888;">${placeholder}</span>`;
return infoPanel;
}
/**
* Create an analysis info panel (for connected components, self-intersections, etc.)
* @param {string} placeholder - Initial placeholder text
* @param {Object} options - Panel options
* @returns {HTMLDivElement} Analysis panel element
*/
export function createAnalysisPanel(placeholder = "Run workflow to see results", options = {}) {
const {
backgroundColor = "#1a1a1a",
padding = "8px",
fontSize = "10px",
fontFamily = "monospace",
color = "#ccc",
lineHeight = "1.4",
maxHeight = "200px",
borderRadius = "4px"
} = options;
const panel = document.createElement("div");
panel.style.backgroundColor = backgroundColor;
panel.style.padding = padding;
panel.style.fontSize = fontSize;
panel.style.fontFamily = fontFamily;
panel.style.color = color;
panel.style.lineHeight = lineHeight;
panel.style.maxHeight = maxHeight;
panel.style.overflowY = "auto";
panel.style.borderRadius = borderRadius;
panel.innerHTML = `<span style="color: #666;">${placeholder}</span>`;
return panel;
}
/**
* Show error in an info panel
* @param {HTMLElement} panel - The info panel element
* @param {string} message - Error message
*/
export function showPanelError(panel, message) {
panel.innerHTML = `<div style="color: #ff6b6b; padding: 8px;">Error: ${message}</div>`;
}
/**
* Create default widget options
* @returns {Object} Widget options object
*/
export function createWidgetOptions() {
return {
getValue() { return ""; },
setValue(v) { }
};
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+276
View File
@@ -0,0 +1,276 @@
/**
* ActorManager - Unified actor/property management for VTK viewers
*
* Handles actor configuration, appearance settings, edge visibility,
* and material properties. Works with both single actors and actor arrays.
*/
import { hexToRgb } from '../utils/ColorUtils.js';
/**
* Default actor appearance settings
*/
const DEFAULT_SETTINGS = {
color: [1.0, 1.0, 1.0], // White
edgeColor: [0.0, 0.0, 0.0], // Black edges
ambient: 0.4,
diffuse: 0.8,
specular: 0.3,
specularPower: 20,
edgeVisibility: false,
lineWidth: 1,
pointSize: 5,
representation: 2, // 0=Points, 1=Wireframe, 2=Surface
opacity: 1.0
};
/**
* Representation mode constants
*/
export const Representation = {
POINTS: 0,
WIREFRAME: 1,
SURFACE: 2
};
export class ActorManager {
/**
* Create an ActorManager
* @param {Object} renderWindow - VTK render window for triggering renders
*/
constructor(renderWindow) {
this.renderWindow = renderWindow;
this.actors = [];
this.settings = { ...DEFAULT_SETTINGS };
}
/**
* Add an actor to be managed
* @param {Object} actor - VTK actor instance
*/
addActor(actor) {
this.actors.push(actor);
this.configureActor(actor, this.settings);
}
/**
* Remove an actor from management
* @param {Object} actor - VTK actor instance
*/
removeActor(actor) {
const index = this.actors.indexOf(actor);
if (index > -1) {
this.actors.splice(index, 1);
}
}
/**
* Clear all managed actors
*/
clearActors() {
this.actors = [];
}
/**
* Set actors to manage (replaces existing)
* @param {Object[]} actors - Array of VTK actor instances
*/
setActors(actors) {
this.actors = Array.isArray(actors) ? actors : [actors];
this.actors.forEach(actor => this.configureActor(actor, this.settings));
}
/**
* Configure a single actor with settings
* @param {Object} actor - VTK actor instance
* @param {Object} settings - Settings object
*/
configureActor(actor, settings = {}) {
const merged = { ...this.settings, ...settings };
const property = actor.getProperty();
if (!property) {
console.warn('[ActorManager] Actor has no property object');
return;
}
// Color (supports hex string or RGB array)
if (merged.color) {
const rgb = typeof merged.color === 'string'
? hexToRgb(merged.color)
: merged.color;
property.setColor(...rgb);
}
// Edge color
if (merged.edgeColor) {
const rgb = typeof merged.edgeColor === 'string'
? hexToRgb(merged.edgeColor)
: merged.edgeColor;
property.setEdgeColor(...rgb);
}
// Lighting properties
if (merged.ambient !== undefined) property.setAmbient(merged.ambient);
if (merged.diffuse !== undefined) property.setDiffuse(merged.diffuse);
if (merged.specular !== undefined) property.setSpecular(merged.specular);
if (merged.specularPower !== undefined) property.setSpecularPower(merged.specularPower);
// Edge and line properties
if (merged.edgeVisibility !== undefined) property.setEdgeVisibility(merged.edgeVisibility);
if (merged.lineWidth !== undefined) property.setLineWidth(merged.lineWidth);
// Point properties
if (merged.pointSize !== undefined) property.setPointSize(merged.pointSize);
// Representation mode
if (merged.representation !== undefined) property.setRepresentation(merged.representation);
// Opacity
if (merged.opacity !== undefined) property.setOpacity(merged.opacity);
}
/**
* Apply settings to all managed actors
* @param {Object} settings - Settings object
* @param {boolean} render - Whether to trigger a render (default: true)
*/
applySettings(settings, render = true) {
this.settings = { ...this.settings, ...settings };
this.actors.forEach(actor => this.configureActor(actor, this.settings));
if (render) {
this.renderWindow.render();
}
}
/**
* Set edge visibility on all managed actors
* @param {boolean} visible - Whether edges should be visible
*/
setEdgeVisibility(visible) {
this.applySettings({ edgeVisibility: visible });
}
/**
* Set color on all managed actors
* @param {string|number[]} color - Hex string or RGB array (0-1 range)
*/
setColor(color) {
this.applySettings({ color });
}
/**
* Set opacity on all managed actors
* @param {number} opacity - Opacity value (0-1)
*/
setOpacity(opacity) {
this.applySettings({ opacity });
}
/**
* Set representation mode on all managed actors
* @param {number} mode - 0=Points, 1=Wireframe, 2=Surface
*/
setRepresentation(mode) {
this.applySettings({ representation: mode });
}
/**
* Set line width for edges/wireframe
* @param {number} width - Line width in pixels
*/
setLineWidth(width) {
this.applySettings({ lineWidth: width });
}
/**
* Set point size for point cloud rendering
* @param {number} size - Point size in pixels
*/
setPointSize(size) {
this.applySettings({ pointSize: size });
}
/**
* Get current settings
* @returns {Object} Current settings object
*/
getSettings() {
return { ...this.settings };
}
/**
* Apply settings from a settings panel (handles hex colors and number parsing)
* @param {Object} panelValues - Values from settings panel inputs
*/
applyPanelSettings(panelValues) {
const settings = {};
if (panelValues.meshColor !== undefined) {
settings.color = panelValues.meshColor;
}
if (panelValues.pointSize !== undefined) {
settings.pointSize = parseFloat(panelValues.pointSize);
}
if (panelValues.edgeWidth !== undefined) {
settings.lineWidth = parseFloat(panelValues.edgeWidth);
}
if (panelValues.representation !== undefined) {
settings.representation = parseInt(panelValues.representation, 10);
}
if (panelValues.opacity !== undefined) {
settings.opacity = parseFloat(panelValues.opacity);
}
this.applySettings(settings);
}
/**
* Configure for textured mode (disables scalar visibility, sets material)
* @param {Object} actor - VTK actor instance
*/
configureForTexture(actor) {
const property = actor.getProperty();
const mapper = actor.getMapper();
if (property) {
// Material settings for proper texture display
if (typeof property.setMetallic === 'function') {
property.setMetallic(0.0);
}
if (typeof property.setRoughness === 'function') {
property.setRoughness(1.0);
}
if (typeof property.setInterpolation === 'function') {
property.setInterpolation(2); // Phong shading
}
// Remove metallic/roughness texture if present (common GLTF issue)
if (typeof property.setRMTexture === 'function') {
property.setRMTexture(null);
}
if (typeof property.setMetallicRoughnessTexture === 'function') {
property.setMetallicRoughnessTexture(null);
}
}
if (mapper) {
mapper.setScalarVisibility(false);
mapper.modified();
}
}
/**
* Enable/disable scalar (field) visualization on mapper
* @param {Object} mapper - VTK mapper instance
* @param {boolean} enabled - Whether to show scalar colors
*/
setScalarVisibility(mapper, enabled) {
if (mapper) {
mapper.setScalarVisibility(enabled);
mapper.modified();
}
}
}
export default ActorManager;
+657
View File
@@ -0,0 +1,657 @@
/**
* BaseViewer - Foundation class for VTK viewers
*
* Provides core lifecycle management, VTK setup, and feature composition.
* Subclasses configure features and behavior through options.
*/
import { CameraController } from './CameraController.js';
import { ActorManager, Representation } from './ActorManager.js';
import { SettingsPanel } from '../ui/SettingsPanel.js';
import { ControlsBar, StandardControls } from '../ui/ControlsBar.js';
import { loadMesh } from '../loaders/LoaderFactory.js';
import { ScreenshotCapture } from '../features/ScreenshotCapture.js';
import { AxisIndicator } from '../features/AxisIndicator.js';
import { FieldVisualization } from '../features/FieldVisualization.js';
import { TextureManager } from '../features/TextureManager.js';
import { sendMeshLoaded, sendError, createMessageListener, extractFilename } from '../utils/MessageHandler.js';
import { getCenter, getMaxDimension, isValidBounds } from '../utils/BoundsUtils.js';
/**
* Default viewer configuration
*/
const DEFAULT_CONFIG = {
// Appearance
backgroundColor: [0.15, 0.15, 0.15],
defaultMeshColor: [0.6, 0.8, 1.0],
edgeColor: [0.2, 0.2, 0.2],
edgeWidth: 1.0,
showEdges: false,
// Camera
cameraDistanceMultiplier: 2.5,
parallelProjection: false,
// Lighting
twoSidedLighting: false,
// Features (enabled/disabled)
enableFields: true,
enableTextures: false,
enableScreenshot: true,
enableAxisIndicator: false,
enableSettings: true,
enableControls: true,
// UI
showLoadingOverlay: true,
showInfoOverlay: true
};
export class BaseViewer {
/**
* Create a BaseViewer
* @param {HTMLElement} container - Container element for the viewer
* @param {Object} config - Configuration options
*/
constructor(container, config = {}) {
this.container = container;
this.config = { ...DEFAULT_CONFIG, ...config };
// VTK objects (initialized in initialize())
this.vtk = null;
this.renderWindow = null;
this.renderer = null;
this.interactor = null;
this.openGLRenderWindow = null;
// Feature modules
this.cameraController = null;
this.actorManager = null;
this.settingsPanel = null;
this.controlsBar = null;
this.screenshotCapture = null;
this.axisIndicator = null;
this.fieldVisualization = null;
this.textureManager = null;
// State
this.actors = [];
this.polyDatas = [];
this.currentFilename = null;
this.isInitialized = false;
// UI elements
this.loadingOverlay = null;
this.infoOverlay = null;
// Message listener cleanup
this._messageCleanup = null;
}
/**
* Initialize the viewer
* Must be called before loading meshes
*/
async initialize() {
if (this.isInitialized) {
console.warn('[BaseViewer] Already initialized');
return;
}
// Get VTK from global
this.vtk = window.vtk;
if (!this.vtk) {
throw new Error('VTK.js not loaded. Include vtk-gltf.js before initializing viewer.');
}
// Create VTK rendering pipeline
this._createRenderingPipeline();
// Initialize feature modules
this._initializeFeatures();
// Create UI elements
this._createUI();
// Setup resize handling
this._setupResizeHandler();
// Setup message listener for loading meshes
this._setupMessageListener();
this.isInitialized = true;
console.log('[BaseViewer] Initialized');
}
/**
* Create VTK rendering pipeline
* @private
*/
_createRenderingPipeline() {
// Create render window
this.renderWindow = this.vtk.Rendering.Core.vtkRenderWindow.newInstance();
// Create renderer
this.renderer = this.vtk.Rendering.Core.vtkRenderer.newInstance();
this.renderer.setBackground(...this.config.backgroundColor);
this.renderWindow.addRenderer(this.renderer);
// Two-sided lighting
if (this.config.twoSidedLighting) {
this.renderer.setTwoSidedLighting(true);
}
// Create OpenGL render window
this.openGLRenderWindow = this.vtk.Rendering.OpenGL.vtkRenderWindow.newInstance();
this.openGLRenderWindow.setContainer(this.container);
this.renderWindow.addView(this.openGLRenderWindow);
// Create interactor
this.interactor = this.vtk.Rendering.Core.vtkRenderWindowInteractor.newInstance();
this.interactor.setView(this.openGLRenderWindow);
this.interactor.initialize();
this.interactor.bindEvents(this.container);
// Set interactor style
const interactorStyle = this.vtk.Interaction.Style.vtkInteractorStyleTrackballCamera.newInstance();
this.interactor.setInteractorStyle(interactorStyle);
// Size to container
const { width, height } = this.container.getBoundingClientRect();
this.openGLRenderWindow.setSize(width, height);
}
/**
* Initialize feature modules
* @private
*/
_initializeFeatures() {
// Camera controller
this.cameraController = new CameraController(this.renderer, {
distanceMultiplier: this.config.cameraDistanceMultiplier,
parallelProjection: this.config.parallelProjection
});
// Actor manager
this.actorManager = new ActorManager(this.vtk, {
defaultColor: this.config.defaultMeshColor,
edgeColor: this.config.edgeColor,
edgeWidth: this.config.edgeWidth,
showEdges: this.config.showEdges
});
// Screenshot capture
if (this.config.enableScreenshot) {
this.screenshotCapture = new ScreenshotCapture(this.renderWindow);
}
// Axis indicator
if (this.config.enableAxisIndicator) {
this.axisIndicator = new AxisIndicator(this.vtk, this.interactor, {
enabled: true
});
}
// Field visualization
if (this.config.enableFields) {
this.fieldVisualization = new FieldVisualization(this.vtk, this.renderWindow);
}
// Texture manager
if (this.config.enableTextures) {
this.textureManager = new TextureManager(this.vtk, this.renderWindow);
}
}
/**
* Create UI elements
* @private
*/
_createUI() {
// Loading overlay
if (this.config.showLoadingOverlay) {
this.loadingOverlay = this._createLoadingOverlay();
}
// Info overlay
if (this.config.showInfoOverlay) {
this.infoOverlay = this._createInfoOverlay();
}
// Settings panel
if (this.config.enableSettings && this.config.settingsConfig) {
this.settingsPanel = new SettingsPanel(this.config.settingsConfig);
this.settingsPanel.onApply((settings) => this._onSettingsApply(settings));
}
// Controls bar
if (this.config.enableControls && this.config.controlsConfig) {
this.controlsBar = new ControlsBar(this.config.controlsConfig);
this._setupControlsHandlers();
}
}
/**
* Create loading overlay element
* @private
* @returns {HTMLElement}
*/
_createLoadingOverlay() {
const overlay = document.createElement('div');
overlay.id = 'loading';
overlay.className = 'loading';
overlay.innerHTML = `
<div class="spinner"></div>
<div class="loading-text">Loading mesh...</div>
<div class="loading-progress" style="display:none;width:140px;height:6px;margin-top:8px;
background:rgba(255,255,255,0.18);border-radius:3px;overflow:hidden;">
<div class="loading-progress-bar" style="width:0%;height:100%;
background:#00c8ff;transition:width 0.1s linear;"></div>
</div>
`;
this.container.parentElement.appendChild(overlay);
return overlay;
}
/**
* Update the download progress bar.
* @param {number|null} fraction - 0..1, or null for indeterminate (hides the bar)
*/
setProgress(fraction) {
if (!this.loadingOverlay) return;
const wrap = this.loadingOverlay.querySelector('.loading-progress');
const bar = this.loadingOverlay.querySelector('.loading-progress-bar');
const textEl = this.loadingOverlay.querySelector('.loading-text');
if (!wrap || !bar) return;
if (fraction == null || isNaN(fraction)) {
wrap.style.display = 'none'; // indeterminate -> spinner only
return;
}
const pct = Math.max(0, Math.min(100, Math.round(fraction * 100)));
wrap.style.display = 'block';
bar.style.width = pct + '%';
if (textEl) textEl.textContent = `Downloading mesh… ${pct}%`;
}
/**
* Create info overlay element
* @private
* @returns {HTMLElement}
*/
_createInfoOverlay() {
const overlay = document.createElement('div');
overlay.id = 'meshInfo';
overlay.className = 'mesh-info';
this.container.parentElement.appendChild(overlay);
return overlay;
}
/**
* Setup resize handler
* @private
*/
_setupResizeHandler() {
const resizeObserver = new ResizeObserver(() => {
const { width, height } = this.container.getBoundingClientRect();
if (width > 0 && height > 0) {
this.openGLRenderWindow.setSize(width, height);
this.renderWindow.render();
}
});
resizeObserver.observe(this.container);
this._resizeObserver = resizeObserver;
}
/**
* Setup message listener for postMessage API
* @private
*/
_setupMessageListener() {
this._messageCleanup = createMessageListener((data) => {
if (data.type === 'loadMesh' && data.url) {
this.loadMesh(data.url);
} else if (data.type === 'screenshot') {
this.captureScreenshot();
}
});
}
/**
* Setup controls bar handlers
* @private
*/
_setupControlsHandlers() {
if (!this.controlsBar) return;
// Camera view buttons
this.controlsBar.onAction('camera-view', (direction) => {
this.setCameraView(direction);
});
// Screenshot button
this.controlsBar.onAction('screenshot', () => {
this.captureScreenshot();
});
// Settings button
this.controlsBar.onAction('settings', () => {
if (this.settingsPanel) {
this.settingsPanel.open();
}
});
// Edges toggle
this.controlsBar.onAction('edges-toggle', (show) => {
this.setEdgeVisibility(show);
});
}
/**
* Handle settings panel apply
* @private
* @param {Object} settings - Applied settings
*/
_onSettingsApply(settings) {
// Apply to actor manager
if (this.actorManager) {
this.actorManager.applySettings(this.actors, settings);
}
// Apply background color
if (settings.backgroundColor) {
this.renderer.setBackground(...settings.backgroundColor);
}
this.render();
}
/**
* Load a mesh from URL
* @param {string} url - Mesh file URL
* @returns {Promise<Object>} Load result with polyData, actors, fields
*/
async loadMesh(url) {
this.showLoading(true, 'Downloading mesh…');
this.setProgress(0);
try {
// Clear existing actors
this.clearScene();
// Detect format and load (with download progress)
const result = await loadMesh(url, this.vtk, {
renderer: this.renderer,
onProgress: (frac) => this.setProgress(frac)
});
// Store state
this.actors = result.actors;
this.polyDatas = result.polyData ? [result.polyData] : [];
this.currentFilename = extractFilename(url);
// Add actors to renderer
result.actors.forEach(actor => {
this.renderer.addActor(actor);
});
// Configure actors
this._configureLoadedActors(result);
// Setup camera
this._setupCameraForMesh();
// Update UI
this._updateInfoOverlay(result);
this.showLoading(false);
// Notify parent
sendMeshLoaded({
filename: this.currentFilename,
format: result.format,
actors: result.actors.length,
hasTexture: result.hasTexture || false,
hasVertexColors: result.hasVertexColors || false,
fields: result.fields || []
});
this.render();
return result;
} catch (error) {
console.error('[BaseViewer] Error loading mesh:', error);
this.showLoading(false);
sendError(error.message);
throw error;
}
}
/**
* Configure actors after loading
* @private
* @param {Object} result - Load result
*/
_configureLoadedActors(result) {
// Apply texture fixes if enabled
if (this.config.enableTextures && this.textureManager && result.hasTexture) {
this.textureManager.applyTextureFixesMultiple(this.actors);
this.textureManager.configureRenderer(this.renderer);
} else if (result.hasVertexColors) {
// Mesh has vertex colors - don't override with default color
// Just configure edges if needed, colors are already set up by loader
this.actors.forEach(actor => {
const property = actor.getProperty();
if (property) {
if (this.config.showEdges) {
property.setEdgeVisibility(true);
if (this.config.edgeColor) {
property.setEdgeColor(...this.config.edgeColor);
}
}
}
});
} else {
// Apply standard actor configuration
this.actorManager.configureActors(this.actors, {
color: this.config.defaultMeshColor,
showEdges: this.config.showEdges,
edgeColor: this.config.edgeColor
});
}
}
/**
* Setup camera position for loaded mesh
* @private
*/
_setupCameraForMesh() {
if (this.actors.length === 0) return;
// Get combined bounds
let bounds = this.actors[0].getBounds();
for (let i = 1; i < this.actors.length; i++) {
const actorBounds = this.actors[i].getBounds();
bounds = [
Math.min(bounds[0], actorBounds[0]),
Math.max(bounds[1], actorBounds[1]),
Math.min(bounds[2], actorBounds[2]),
Math.max(bounds[3], actorBounds[3]),
Math.min(bounds[4], actorBounds[4]),
Math.max(bounds[5], actorBounds[5])
];
}
if (isValidBounds(bounds)) {
this.cameraController.positionInitialCamera(bounds);
}
}
/**
* Update info overlay with mesh information
* @private
* @param {Object} result - Load result
*/
_updateInfoOverlay(result) {
if (!this.infoOverlay) return;
const lines = [`File: ${this.currentFilename}`];
if (result.polyData) {
const numPoints = result.polyData.getNumberOfPoints();
const numCells = result.polyData.getNumberOfCells();
lines.push(`Points: ${numPoints.toLocaleString()}`);
lines.push(`Cells: ${numCells.toLocaleString()}`);
}
if (result.hasTexture) {
lines.push('Textured: Yes');
}
this.infoOverlay.innerHTML = lines.join('<br>');
}
/**
* Clear the scene
*/
clearScene() {
this.actors.forEach(actor => {
this.renderer.removeActor(actor);
});
this.actors = [];
this.polyDatas = [];
}
/**
* Set camera view direction
* @param {string} direction - '+X', '-X', '+Y', '-Y', '+Z', '-Z'
*/
setCameraView(direction) {
if (this.actors.length === 0) return;
const bounds = this.actors[0].getBounds();
this.cameraController.setCameraView(direction, bounds);
this.render();
}
/**
* Set edge visibility
* @param {boolean} visible
*/
setEdgeVisibility(visible) {
this.actors.forEach(actor => {
this.actorManager.setEdgeVisibility(actor, visible);
});
this.render();
}
/**
* Capture screenshot
* @returns {Promise<string>} Base64 image data
*/
async captureScreenshot() {
if (this.screenshotCapture) {
return this.screenshotCapture.captureAndSend();
}
}
/**
* Show/hide loading overlay
* @param {boolean} show
* @param {string} text - Optional loading text
*/
showLoading(show, text = 'Loading mesh...') {
if (this.loadingOverlay) {
this.loadingOverlay.style.display = show ? 'flex' : 'none';
const textEl = this.loadingOverlay.querySelector('.loading-text');
if (textEl) {
textEl.textContent = text;
}
if (!show) {
// reset the progress bar for the next load
const wrap = this.loadingOverlay.querySelector('.loading-progress');
const bar = this.loadingOverlay.querySelector('.loading-progress-bar');
if (wrap) wrap.style.display = 'none';
if (bar) bar.style.width = '0%';
}
}
}
/**
* Render the scene
*/
render() {
this.renderWindow.render();
}
/**
* Resize the viewer
* @param {number} width
* @param {number} height
*/
resize(width, height) {
this.openGLRenderWindow.setSize(width, height);
this.render();
}
/**
* Get current actors
* @returns {Object[]}
*/
getActors() {
return this.actors;
}
/**
* Get renderer
* @returns {Object}
*/
getRenderer() {
return this.renderer;
}
/**
* Cleanup and destroy viewer
*/
destroy() {
// Cleanup message listener
if (this._messageCleanup) {
this._messageCleanup();
}
// Cleanup resize observer
if (this._resizeObserver) {
this._resizeObserver.disconnect();
}
// Cleanup axis indicator
if (this.axisIndicator) {
this.axisIndicator.destroy();
}
// Clear scene
this.clearScene();
// Delete VTK objects
if (this.interactor) {
this.interactor.unbindEvents();
}
if (this.openGLRenderWindow) {
this.openGLRenderWindow.delete();
}
if (this.renderWindow) {
this.renderWindow.delete();
}
// Remove UI elements
if (this.loadingOverlay && this.loadingOverlay.parentElement) {
this.loadingOverlay.parentElement.removeChild(this.loadingOverlay);
}
if (this.infoOverlay && this.infoOverlay.parentElement) {
this.infoOverlay.parentElement.removeChild(this.infoOverlay);
}
console.log('[BaseViewer] Destroyed');
}
}
export default BaseViewer;
@@ -0,0 +1,271 @@
/**
* CameraController - Unified camera management for VTK viewers
*
* Handles camera positioning, view presets, and projection modes.
* Configurable distance multiplier allows different viewers to use different defaults.
*/
import { getCenter, getMaxDimension } from '../utils/BoundsUtils.js';
/**
* View direction configurations
* Maps direction strings to camera position offsets and viewUp vectors
*/
const VIEW_CONFIGS = {
'+X': { offset: [1, 0, 0], viewUp: [0, 0, 1] },
'-X': { offset: [-1, 0, 0], viewUp: [0, 0, 1] },
'+Y': { offset: [0, 1, 0], viewUp: [0, 0, 1] },
'-Y': { offset: [0, -1, 0], viewUp: [0, 0, 1] },
'+Z': { offset: [0, 0, 1], viewUp: [0, 1, 0] },
'-Z': { offset: [0, 0, -1], viewUp: [0, 1, 0] }
};
/**
* Default camera configuration
*/
const DEFAULT_CONFIG = {
distanceMultiplier: 2.5, // Standard viewers use 2.5, textured uses 2.0
initialViewOffset: [1, 1, 1], // Diagonal view for initial positioning
initialViewUp: [0, 0, 1]
};
export class CameraController {
/**
* Create a CameraController
* @param {Object} renderer - VTK renderer instance
* @param {Object} renderWindow - VTK render window instance
* @param {Object} config - Configuration options
* @param {number} config.distanceMultiplier - Camera distance = maxDim * multiplier (default: 2.5)
*/
constructor(renderer, renderWindow, config = {}) {
this.renderer = renderer;
this.renderWindow = renderWindow;
this.config = { ...DEFAULT_CONFIG, ...config };
this.currentBounds = null;
}
/**
* Get the active camera from the renderer
* @returns {Object} VTK camera instance
*/
getCamera() {
return this.renderer.getActiveCamera();
}
/**
* Store bounds for later use (e.g., view presets)
* @param {number[]} bounds - VTK bounds [xmin, xmax, ymin, ymax, zmin, zmax]
*/
setBounds(bounds) {
this.currentBounds = bounds;
}
/**
* Position camera for initial viewing of mesh
* @param {number[]} bounds - VTK bounds [xmin, xmax, ymin, ymax, zmin, zmax]
*/
positionInitialCamera(bounds) {
this.currentBounds = bounds;
const camera = this.getCamera();
const center = getCenter(bounds);
const maxDim = getMaxDimension(bounds);
const distance = maxDim * this.config.distanceMultiplier;
const offset = this.config.initialViewOffset;
const position = [
center[0] + offset[0] * distance,
center[1] + offset[1] * distance,
center[2] + offset[2] * distance
];
camera.setPosition(...position);
camera.setFocalPoint(...center);
camera.setViewUp(...this.config.initialViewUp);
this.renderer.resetCamera();
this.renderWindow.render();
}
/**
* Set camera to a specific axis-aligned view
* @param {string} direction - One of '+X', '-X', '+Y', '-Y', '+Z', '-Z'
* @param {number[]} bounds - Optional bounds, uses stored bounds if not provided
*/
setCameraView(direction, bounds = null) {
const activeBounds = bounds || this.currentBounds;
if (!activeBounds) {
console.warn('[CameraController] No bounds available for setCameraView');
return;
}
const viewConfig = VIEW_CONFIGS[direction];
if (!viewConfig) {
console.warn(`[CameraController] Unknown view direction: ${direction}`);
return;
}
const camera = this.getCamera();
const center = getCenter(activeBounds);
const maxDim = getMaxDimension(activeBounds);
const distance = maxDim * this.config.distanceMultiplier;
const position = [
center[0] + viewConfig.offset[0] * distance,
center[1] + viewConfig.offset[1] * distance,
center[2] + viewConfig.offset[2] * distance
];
camera.setPosition(...position);
camera.setFocalPoint(...center);
camera.setViewUp(...viewConfig.viewUp);
this.renderer.resetCamera();
this.renderWindow.render();
}
/**
* Reset camera to fit the current bounds
*/
resetCamera() {
if (this.currentBounds) {
this.renderer.resetCamera(this.currentBounds);
} else {
this.renderer.resetCamera();
}
this.renderWindow.render();
}
/**
* Reset camera clipping range (useful after mesh updates)
*/
resetClippingRange() {
this.renderer.resetCameraClippingRange();
this.renderWindow.render();
}
/**
* Set parallel (orthographic) vs perspective projection
* @param {boolean} enabled - True for parallel, false for perspective
*/
setParallelProjection(enabled) {
const camera = this.getCamera();
camera.setParallelProjection(enabled);
this.renderWindow.render();
}
/**
* Get current parallel projection state
* @returns {boolean} True if parallel projection is enabled
*/
getParallelProjection() {
return this.getCamera().getParallelProjection();
}
/**
* Synchronize this camera with another camera
* Useful for side-by-side viewers
* @param {Object} sourceCamera - Camera to copy state from
*/
synchronizeFrom(sourceCamera) {
const camera = this.getCamera();
camera.setPosition(...sourceCamera.getPosition());
camera.setFocalPoint(...sourceCamera.getFocalPoint());
camera.setViewUp(...sourceCamera.getViewUp());
camera.setParallelProjection(sourceCamera.getParallelProjection());
camera.setClippingRange(...sourceCamera.getClippingRange());
camera.setParallelScale(sourceCamera.getParallelScale());
camera.setViewAngle(sourceCamera.getViewAngle());
}
/**
* Get camera state for serialization
* @returns {Object} Camera state object
*/
getState() {
const camera = this.getCamera();
return {
position: camera.getPosition(),
focalPoint: camera.getFocalPoint(),
viewUp: camera.getViewUp(),
parallelProjection: camera.getParallelProjection(),
clippingRange: camera.getClippingRange(),
parallelScale: camera.getParallelScale(),
viewAngle: camera.getViewAngle()
};
}
/**
* Restore camera state from serialized object
* @param {Object} state - Camera state object from getState()
*/
setState(state) {
const camera = this.getCamera();
if (state.position) camera.setPosition(...state.position);
if (state.focalPoint) camera.setFocalPoint(...state.focalPoint);
if (state.viewUp) camera.setViewUp(...state.viewUp);
if (state.parallelProjection !== undefined) camera.setParallelProjection(state.parallelProjection);
if (state.clippingRange) camera.setClippingRange(...state.clippingRange);
if (state.parallelScale !== undefined) camera.setParallelScale(state.parallelScale);
if (state.viewAngle !== undefined) camera.setViewAngle(state.viewAngle);
this.renderWindow.render();
}
/**
* Focus camera on a specific 3D point
* Moves the camera to look at the point while maintaining relative view direction
* @param {number[]} point - [x, y, z] coordinates to focus on
* @param {number} viewRadius - Optional radius around point to view (defaults to 1/10 of mesh size)
*/
focusOnPoint(point, viewRadius = null) {
const camera = this.getCamera();
// Get current camera direction (from focal point to camera position)
const currentFocalPoint = camera.getFocalPoint();
const currentPosition = camera.getPosition();
const direction = [
currentPosition[0] - currentFocalPoint[0],
currentPosition[1] - currentFocalPoint[1],
currentPosition[2] - currentFocalPoint[2]
];
// Normalize direction
const length = Math.sqrt(direction[0]**2 + direction[1]**2 + direction[2]**2);
if (length > 0) {
direction[0] /= length;
direction[1] /= length;
direction[2] /= length;
} else {
// Default to looking from +Z if no current direction
direction[0] = 0;
direction[1] = 0;
direction[2] = 1;
}
// Calculate view distance
let distance;
if (viewRadius !== null) {
distance = viewRadius * this.config.distanceMultiplier;
} else if (this.currentBounds) {
const maxDim = getMaxDimension(this.currentBounds);
distance = maxDim * 0.3; // Closer view for focused point
} else {
distance = 10; // Fallback
}
// Position camera along the direction from the point
const newPosition = [
point[0] + direction[0] * distance,
point[1] + direction[1] * distance,
point[2] + direction[2] * distance
];
camera.setFocalPoint(...point);
camera.setPosition(...newPosition);
this.renderer.resetCameraClippingRange();
this.renderWindow.render();
}
}
export default CameraController;
@@ -0,0 +1,242 @@
/**
* AxisIndicator - Orientation widget for VTK viewers
*
* Creates and manages an annotated cube axis indicator that shows
* the current camera orientation.
*/
import { rgbToString } from '../utils/ColorUtils.js';
/**
* Default axis colors
*/
const DEFAULT_COLORS = {
x: '#ff5555', // Red for X axis
y: '#55ff55', // Green for Y axis
z: '#5555ff' // Blue for Z axis
};
/**
* Default widget configuration
*/
const DEFAULT_CONFIG = {
enabled: false,
corner: 'BOTTOM_LEFT', // BOTTOM_LEFT, BOTTOM_RIGHT, TOP_LEFT, TOP_RIGHT
viewportSize: 0.15,
minPixelSize: 100,
maxPixelSize: 300,
faceColor: rgbToString(0.5, 0.5, 0.5)
};
export class AxisIndicator {
/**
* Create an AxisIndicator
* @param {Object} vtk - VTK.js global object
* @param {Object} interactor - VTK interactor (from renderWindow.getInteractor())
* @param {Object} config - Configuration options
*/
constructor(vtk, interactor, config = {}) {
this.vtk = vtk;
this.interactor = interactor;
this.config = { ...DEFAULT_CONFIG, ...config };
// VTK classes
this.vtkOrientationMarkerWidget = vtk.Interaction.Widgets.vtkOrientationMarkerWidget;
this.vtkAnnotatedCubeActor = vtk.Rendering.Core.vtkAnnotatedCubeActor;
// Widget state
this.widget = null;
this.cubeActor = null;
this.enabled = this.config.enabled;
this._createWidget();
}
/**
* Create the orientation widget
* @private
*/
_createWidget() {
// Create annotated cube actor
this.cubeActor = this.vtkAnnotatedCubeActor.newInstance();
// Set default style
this.cubeActor.setDefaultStyle({
text: '+X',
fontStyle: 'bold',
fontFamily: 'Arial',
fontColor: 'white',
fontSizeScale: (res) => res / 2,
faceColor: this.config.faceColor,
faceRotation: 0,
edgeThickness: 0.1,
edgeColor: 'white',
resolution: 400,
});
// Customize each face with axis colors
this.cubeActor.setXPlusFaceProperty({ text: '+X', faceColor: DEFAULT_COLORS.x });
this.cubeActor.setXMinusFaceProperty({ text: '-X', faceColor: DEFAULT_COLORS.x });
this.cubeActor.setYPlusFaceProperty({ text: '+Y', faceColor: DEFAULT_COLORS.y });
this.cubeActor.setYMinusFaceProperty({ text: '-Y', faceColor: DEFAULT_COLORS.y });
this.cubeActor.setZPlusFaceProperty({ text: '+Z', faceColor: DEFAULT_COLORS.z });
this.cubeActor.setZMinusFaceProperty({ text: '-Z', faceColor: DEFAULT_COLORS.z });
// Create orientation marker widget
this.widget = this.vtkOrientationMarkerWidget.newInstance({
actor: this.cubeActor,
interactor: this.interactor,
});
// Configure widget position and size
this._applyCornerConfig();
this.widget.setViewportSize(this.config.viewportSize);
this.widget.setMinPixelSize(this.config.minPixelSize);
this.widget.setMaxPixelSize(this.config.maxPixelSize);
// Set initial enabled state
this.widget.setEnabled(this.enabled);
}
/**
* Apply corner configuration
* @private
*/
_applyCornerConfig() {
const Corners = this.vtkOrientationMarkerWidget.Corners;
const cornerMap = {
'BOTTOM_LEFT': Corners.BOTTOM_LEFT,
'BOTTOM_RIGHT': Corners.BOTTOM_RIGHT,
'TOP_LEFT': Corners.TOP_LEFT,
'TOP_RIGHT': Corners.TOP_RIGHT
};
const corner = cornerMap[this.config.corner] || Corners.BOTTOM_LEFT;
this.widget.setViewportCorner(corner);
}
/**
* Enable the axis indicator
*/
enable() {
this.enabled = true;
if (this.widget) {
this.widget.setEnabled(true);
}
}
/**
* Disable the axis indicator
*/
disable() {
this.enabled = false;
if (this.widget) {
this.widget.setEnabled(false);
}
}
/**
* Toggle the axis indicator
* @returns {boolean} New enabled state
*/
toggle() {
if (this.enabled) {
this.disable();
} else {
this.enable();
}
return this.enabled;
}
/**
* Set enabled state
* @param {boolean} enabled - Whether to enable the indicator
*/
setEnabled(enabled) {
if (enabled) {
this.enable();
} else {
this.disable();
}
}
/**
* Check if enabled
* @returns {boolean}
*/
isEnabled() {
return this.enabled;
}
/**
* Set the corner position
* @param {string} corner - 'BOTTOM_LEFT', 'BOTTOM_RIGHT', 'TOP_LEFT', 'TOP_RIGHT'
*/
setCorner(corner) {
this.config.corner = corner;
this._applyCornerConfig();
}
/**
* Set viewport size (0-1 range)
* @param {number} size - Viewport size as fraction
*/
setViewportSize(size) {
this.config.viewportSize = size;
if (this.widget) {
this.widget.setViewportSize(size);
}
}
/**
* Set axis colors
* @param {Object} colors - Color object with x, y, z properties (hex strings)
*/
setAxisColors(colors) {
if (colors.x) {
this.cubeActor.setXPlusFaceProperty({ faceColor: colors.x });
this.cubeActor.setXMinusFaceProperty({ faceColor: colors.x });
}
if (colors.y) {
this.cubeActor.setYPlusFaceProperty({ faceColor: colors.y });
this.cubeActor.setYMinusFaceProperty({ faceColor: colors.y });
}
if (colors.z) {
this.cubeActor.setZPlusFaceProperty({ faceColor: colors.z });
this.cubeActor.setZMinusFaceProperty({ faceColor: colors.z });
}
}
/**
* Get the underlying VTK widget
* @returns {Object} VTK orientation marker widget
*/
getWidget() {
return this.widget;
}
/**
* Get the cube actor
* @returns {Object} VTK annotated cube actor
*/
getCubeActor() {
return this.cubeActor;
}
/**
* Cleanup resources
*/
destroy() {
if (this.widget) {
this.widget.setEnabled(false);
this.widget.delete();
this.widget = null;
}
if (this.cubeActor) {
this.cubeActor.delete();
this.cubeActor = null;
}
}
}
export default AxisIndicator;
@@ -0,0 +1,352 @@
/**
* FieldVisualization - Scalar field coloring for VTK viewers
*
* Handles scalar field visualization including:
* - Field detection (point/cell data)
* - Colormap application
* - Range calculation (auto/manual, single/synchronized)
*/
/**
* Popular colormaps to show at top of selector
*/
const POPULAR_COLORMAPS = [
'erdc_rainbow_bright',
'Cool to Warm',
'Viridis (matplotlib)',
'Plasma (matplotlib)',
'Inferno (matplotlib)',
'Magma (matplotlib)',
'jet',
'rainbow'
];
export class FieldVisualization {
/**
* Create a FieldVisualization instance
* @param {Object} vtk - VTK.js global object
* @param {Object} renderWindow - VTK render window for triggering renders
*/
constructor(vtk, renderWindow) {
this.vtk = vtk;
this.renderWindow = renderWindow;
// VTK classes
this.vtkColorTransferFunction = vtk.Rendering.Core.vtkColorTransferFunction;
this.vtkColorMaps = vtk.Rendering.Core.vtkColorTransferFunction.vtkColorMaps;
// State
this.currentField = null;
this.currentColormap = 'erdc_rainbow_bright';
this.autoRange = true;
this.manualRange = [0, 1];
}
/**
* Extract field information from polydata
* @param {Object} polyData - VTK polydata object
* @returns {Object} Field information with pointFields and cellFields arrays
*/
extractFields(polyData) {
if (!polyData) {
return { pointFields: [], cellFields: [], allFields: [] };
}
// Fields to hide from dropdown (VTK internal arrays that aren't useful to visualize)
const HIDDEN_FIELDS = ['Attribute', 'vtkOriginalPointIds', 'vtkOriginalCellIds'];
const pointData = polyData.getPointData();
const cellData = polyData.getCellData();
const pointFields = [];
const cellFields = [];
// Extract point (vertex) data arrays
for (let i = 0; i < pointData.getNumberOfArrays(); i++) {
const array = pointData.getArray(i);
const name = array.getName();
// Skip hidden/internal fields
if (HIDDEN_FIELDS.includes(name)) continue;
const range = array.getRange();
pointFields.push({
name,
displayName: `${name} (vertex)`,
type: 'point',
fullName: `point:${name}`,
numComponents: array.getNumberOfComponents(),
range: [range[0], range[1]]
});
}
// Extract cell (face) data arrays
for (let i = 0; i < cellData.getNumberOfArrays(); i++) {
const array = cellData.getArray(i);
const name = array.getName();
// Skip hidden/internal fields
if (HIDDEN_FIELDS.includes(name)) continue;
const range = array.getRange();
cellFields.push({
name,
displayName: `${name} (face)`,
type: 'cell',
fullName: `cell:${name}`,
numComponents: array.getNumberOfComponents(),
range: [range[0], range[1]]
});
}
const allFields = [...pointFields, ...cellFields];
return {
pointFields,
cellFields,
allFields,
hasFields: allFields.length > 0
};
}
/**
* Apply a scalar field to a mapper
* @param {Object} mapper - VTK mapper
* @param {Object} polyData - VTK polydata
* @param {string} fieldName - Field name (e.g., "point:temperature" or "cell:pressure")
* @param {Object} options - Options
* @param {number[]} options.range - Manual range [min, max] (overrides auto)
* @param {string} options.colormap - Colormap name
* @param {boolean} options.render - Whether to trigger render (default: true)
*/
applyField(mapper, polyData, fieldName, options = {}) {
const {
range = null,
colormap = this.currentColormap,
render = true
} = options;
if (!fieldName || fieldName === 'none' || !polyData) {
this.disableScalarVisualization(mapper, render);
return;
}
// Parse field name
const isCellData = fieldName.startsWith('cell:');
const actualFieldName = isCellData
? fieldName.substring(5)
: fieldName.startsWith('point:')
? fieldName.substring(6)
: fieldName;
// Get data array
const data = isCellData ? polyData.getCellData() : polyData.getPointData();
const scalarArray = data.getArrayByName(actualFieldName);
if (!scalarArray) {
console.warn(`[FieldVisualization] Field "${actualFieldName}" not found`);
this.disableScalarVisualization(mapper, render);
return;
}
// Get range
const dataRange = scalarArray.getRange();
const [minVal, maxVal] = range || (this.autoRange ? dataRange : this.manualRange);
// Check for degenerate range
const epsilon = 1e-10;
if (Math.abs(maxVal - minVal) < epsilon && !range) {
console.warn(`[FieldVisualization] Field "${actualFieldName}" has zero-width range`);
this.disableScalarVisualization(mapper, render);
return;
}
// Set active scalars
data.setActiveScalars(actualFieldName);
// Configure mapper
mapper.setScalarVisibility(true);
if (isCellData) {
mapper.setScalarModeToUseCellData();
} else {
mapper.setScalarModeToUsePointData();
}
mapper.setScalarRange(minVal, maxVal);
// Apply colormap
this.applyColormap(mapper, colormap, minVal, maxVal);
// Update state
this.currentField = fieldName;
this.currentColormap = colormap;
// Update mapper to apply changes
mapper.update();
if (render) {
this.renderWindow.render();
}
console.log(`[FieldVisualization] Applied field "${actualFieldName}" with range [${minVal.toFixed(3)}, ${maxVal.toFixed(3)}]`);
}
/**
* Apply a colormap to a mapper
* @param {Object} mapper - VTK mapper
* @param {string} colormapName - Colormap preset name
* @param {number} minVal - Range minimum
* @param {number} maxVal - Range maximum
*/
applyColormap(mapper, colormapName, minVal, maxVal) {
try {
const lookupTable = this.vtkColorTransferFunction.newInstance();
const preset = this.vtkColorMaps.getPresetByName(colormapName);
if (preset) {
lookupTable.applyColorMap(preset);
lookupTable.setMappingRange(minVal, maxVal);
lookupTable.updateRange();
mapper.setLookupTable(lookupTable);
} else {
console.warn(`[FieldVisualization] Colormap "${colormapName}" not found`);
}
} catch (error) {
console.error('[FieldVisualization] Error applying colormap:', error);
}
}
/**
* Disable scalar visualization on a mapper
* @param {Object} mapper - VTK mapper
* @param {boolean} render - Whether to trigger render
*/
disableScalarVisualization(mapper, render = true) {
if (mapper) {
mapper.setScalarVisibility(false);
}
this.currentField = null;
if (render) {
this.renderWindow.render();
}
}
/**
* Get available colormap presets
* @param {boolean} popularFirst - Put popular colormaps at top (default: true)
* @returns {Array} Array of {value, label, isPopular} objects
*/
getColormapOptions(popularFirst = true) {
const allPresets = this.vtkColorMaps.rgbPresetNames || [];
const options = [];
if (popularFirst) {
// Add popular colormaps first
POPULAR_COLORMAPS.forEach(name => {
if (allPresets.includes(name)) {
options.push({ value: name, label: name, isPopular: true });
}
});
// Add separator
options.push({ value: '', label: '----------', disabled: true });
// Add remaining colormaps
allPresets
.filter(name => !POPULAR_COLORMAPS.includes(name))
.sort()
.forEach(name => {
options.push({ value: name, label: name, isPopular: false });
});
} else {
allPresets.sort().forEach(name => {
options.push({ value: name, label: name, isPopular: POPULAR_COLORMAPS.includes(name) });
});
}
return options;
}
/**
* Set auto range mode
* @param {boolean} auto - Whether to use auto range
*/
setAutoRange(auto) {
this.autoRange = auto;
}
/**
* Set manual range
* @param {number} min - Range minimum
* @param {number} max - Range maximum
*/
setManualRange(min, max) {
this.manualRange = [min, max];
}
/**
* Calculate synchronized range for multiple polydatas
* @param {Object[]} polydatas - Array of VTK polydata objects
* @param {string} fieldName - Field name
* @returns {number[]} Combined range [min, max]
*/
calculateSynchronizedRange(polydatas, fieldName) {
let globalMin = Infinity;
let globalMax = -Infinity;
const isCellData = fieldName.startsWith('cell:');
const actualFieldName = isCellData
? fieldName.substring(5)
: fieldName.startsWith('point:')
? fieldName.substring(6)
: fieldName;
polydatas.forEach(polyData => {
if (!polyData) return;
const data = isCellData ? polyData.getCellData() : polyData.getPointData();
const scalarArray = data?.getArrayByName(actualFieldName);
if (scalarArray) {
const range = scalarArray.getRange();
globalMin = Math.min(globalMin, range[0]);
globalMax = Math.max(globalMax, range[1]);
}
});
if (globalMin === Infinity || globalMax === -Infinity) {
return [0, 1]; // Default range
}
return [globalMin, globalMax];
}
/**
* Find common fields between multiple polydatas
* @param {Object[]} polydatas - Array of VTK polydata objects
* @returns {Object} Object with commonPointFields, commonCellFields, commonAllFields
*/
findCommonFields(polydatas) {
if (!polydatas || polydatas.length === 0) {
return { commonPointFields: [], commonCellFields: [], commonAllFields: [] };
}
// Get fields from first polydata
const firstFields = this.extractFields(polydatas[0]);
let commonPointFields = firstFields.pointFields.map(f => f.name);
let commonCellFields = firstFields.cellFields.map(f => f.name);
// Intersect with remaining polydatas
for (let i = 1; i < polydatas.length; i++) {
const fields = this.extractFields(polydatas[i]);
const pointNames = fields.pointFields.map(f => f.name);
const cellNames = fields.cellFields.map(f => f.name);
commonPointFields = commonPointFields.filter(n => pointNames.includes(n));
commonCellFields = commonCellFields.filter(n => cellNames.includes(n));
}
return {
commonPointFields,
commonCellFields,
commonAllFields: [...commonPointFields, ...commonCellFields]
};
}
}
export default FieldVisualization;
@@ -0,0 +1,97 @@
/**
* ScreenshotCapture - Screenshot functionality for VTK viewers
*
* Handles capturing screenshots from VTK render windows and
* sending them via postMessage or triggering downloads.
*/
import { sendScreenshot } from '../utils/MessageHandler.js';
export class ScreenshotCapture {
/**
* Create a ScreenshotCapture instance
* @param {Object} renderWindow - VTK render window
*/
constructor(renderWindow) {
this.renderWindow = renderWindow;
}
/**
* Capture a screenshot as a base64 PNG data URL
* @returns {Promise<string>} Base64 PNG data URL
*/
async capture() {
const images = await this.renderWindow.captureImages();
return images[0]; // Returns base64 PNG data URL
}
/**
* Capture and send screenshot to parent window via postMessage
* @returns {Promise<void>}
*/
async captureAndSend() {
try {
const image = await this.capture();
sendScreenshot(image);
console.log('[ScreenshotCapture] Screenshot captured and sent to parent');
} catch (error) {
console.error('[ScreenshotCapture] Error capturing screenshot:', error);
throw error;
}
}
/**
* Capture and download screenshot as a file
* @param {string} filename - Download filename (default: "screenshot.png")
* @returns {Promise<void>}
*/
async captureAndDownload(filename = 'screenshot.png') {
try {
const image = await this.capture();
// Create download link
const link = document.createElement('a');
link.href = image;
link.download = filename;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
console.log('[ScreenshotCapture] Screenshot downloaded:', filename);
} catch (error) {
console.error('[ScreenshotCapture] Error downloading screenshot:', error);
throw error;
}
}
/**
* Capture screenshot and return as Blob
* @returns {Promise<Blob>} PNG Blob
*/
async captureAsBlob() {
const image = await this.capture();
// Convert base64 to blob
const response = await fetch(image);
return response.blob();
}
/**
* Generate a timestamp-based filename
* @param {string} prefix - Filename prefix (default: "screenshot")
* @param {string} extension - File extension (default: "png")
* @returns {string} Filename with timestamp
*/
static generateFilename(prefix = 'screenshot', extension = 'png') {
const now = new Date();
const timestamp = now.toISOString()
.replace(/[:.]/g, '-')
.replace('T', '_')
.slice(0, 19);
return `${prefix}_${timestamp}.${extension}`;
}
}
export default ScreenshotCapture;
@@ -0,0 +1,213 @@
/**
* TextureManager - Texture handling for GLTF/GLB meshes
*
* Manages texture extraction, material fixes, and rendering configuration
* for properly displaying textured meshes in VTK.js.
*/
/**
* Texture information structure
* @typedef {Object} TextureInfo
* @property {boolean} hasTexture - Whether a texture was found
* @property {boolean} hasMaterial - Whether PBR material properties exist
* @property {string} textureType - Type of texture found (baseColor, diffuse, etc.)
*/
export class TextureManager {
/**
* Create a TextureManager
* @param {Object} vtk - VTK.js global object
* @param {Object} renderWindow - VTK render window
*/
constructor(vtk, renderWindow) {
this.vtk = vtk;
this.renderWindow = renderWindow;
}
/**
* Apply texture fixes to an actor
* This fixes common issues with GLTF texture rendering in VTK.js
* @param {Object} actor - VTK actor
* @returns {TextureInfo} Texture information
*/
applyTextureFixes(actor) {
const property = actor.getProperty();
const mapper = actor.getMapper();
const info = {
hasTexture: false,
hasMaterial: false,
textureType: null
};
if (!property) {
return info;
}
// CRITICAL FIX: Remove metallicRoughnessTexture if present
// VTK.js GLTFImporter may use this instead of baseColorTexture
if (typeof property.setRMTexture === 'function') {
property.setRMTexture(null);
}
if (typeof property.setMetallicRoughnessTexture === 'function') {
property.setMetallicRoughnessTexture(null);
}
// Try multiple methods to get the base color texture
let texture = null;
if (typeof property.getBaseColorTexture === 'function') {
texture = property.getBaseColorTexture();
if (texture) info.textureType = 'baseColor';
}
if (!texture && typeof property.getDiffuseTexture === 'function') {
texture = property.getDiffuseTexture();
if (texture) info.textureType = 'diffuse';
}
if (!texture && typeof property.getTexture === 'function') {
texture = property.getTexture();
if (texture) info.textureType = 'generic';
}
if (texture) {
info.hasTexture = true;
actor.addTexture(texture);
}
// Check for PBR material properties
if (typeof property.getMetallic === 'function' ||
typeof property.getRoughness === 'function') {
info.hasMaterial = true;
}
// Set material to non-metallic for proper texture display
if (typeof property.setMetallic === 'function') {
property.setMetallic(0.0);
}
if (typeof property.setRoughness === 'function') {
property.setRoughness(1.0);
}
if (typeof property.setInterpolation === 'function') {
property.setInterpolation(2); // Phong shading
}
// Configure mapper for texture rendering
if (mapper) {
mapper.setScalarVisibility(false);
mapper.modified();
}
return info;
}
/**
* Apply texture fixes to multiple actors
* @param {Object[]} actors - Array of VTK actors
* @returns {TextureInfo} Combined texture information
*/
applyTextureFixesMultiple(actors) {
const combinedInfo = {
hasTexture: false,
hasMaterial: false,
texturedActors: 0
};
actors.forEach(actor => {
const info = this.applyTextureFixes(actor);
if (info.hasTexture) {
combinedInfo.hasTexture = true;
combinedInfo.texturedActors++;
}
if (info.hasMaterial) {
combinedInfo.hasMaterial = true;
}
});
return combinedInfo;
}
/**
* Configure renderer for texture display
* @param {Object} renderer - VTK renderer
*/
configureRenderer(renderer) {
// Enable two-sided lighting for better texture visibility
if (typeof renderer.setTwoSidedLighting === 'function') {
renderer.setTwoSidedLighting(true);
}
// Disable shadows (can interfere with texture display)
if (typeof renderer.setUseShadows === 'function') {
renderer.setUseShadows(false);
}
}
/**
* Set actor appearance for non-textured display (solid color)
* @param {Object} actor - VTK actor
* @param {number[]} color - RGB color array [0-1 range]
*/
setActorColor(actor, color = [1.0, 1.0, 1.0]) {
const property = actor.getProperty();
const mapper = actor.getMapper();
if (property) {
property.setColor(...color);
}
if (mapper) {
mapper.setScalarVisibility(false);
}
}
/**
* Extract texture information from actor without modifying it
* @param {Object} actor - VTK actor
* @returns {TextureInfo} Texture information
*/
getTextureInfo(actor) {
const property = actor.getProperty();
const info = {
hasTexture: false,
hasMaterial: false,
textureType: null
};
if (!property) {
return info;
}
// Check for textures
if (typeof property.getBaseColorTexture === 'function' && property.getBaseColorTexture()) {
info.hasTexture = true;
info.textureType = 'baseColor';
} else if (typeof property.getDiffuseTexture === 'function' && property.getDiffuseTexture()) {
info.hasTexture = true;
info.textureType = 'diffuse';
} else if (typeof property.getTexture === 'function' && property.getTexture()) {
info.hasTexture = true;
info.textureType = 'generic';
}
// Check for material
if (typeof property.getMetallic === 'function' ||
typeof property.getRoughness === 'function') {
info.hasMaterial = true;
}
return info;
}
/**
* Check if GLTF texture support is available
* @returns {boolean}
*/
isTextureSupported() {
try {
return this.vtk.IO.Geometry.vtkGLTFImporter !== undefined;
} catch {
return false;
}
}
}
export default TextureManager;
+199
View File
@@ -0,0 +1,199 @@
/**
* GeomPack VTK Viewer - Main Entry Point
*
* This module exports the ViewerFactory and all viewer components
* for creating unified VTK viewers.
*/
// Utils
import ColorUtils from './utils/ColorUtils.js';
import BoundsUtils from './utils/BoundsUtils.js';
import MessageHandler from './utils/MessageHandler.js';
// Core
import CameraController from './core/CameraController.js';
import ActorManager, { Representation } from './core/ActorManager.js';
import BaseViewer from './core/BaseViewer.js';
// UI
import SettingsPanel, { SettingTypes } from './ui/SettingsPanel.js';
import ControlsBar, { ControlTypes, StandardControls } from './ui/ControlsBar.js';
// Loaders
import LoaderFactory, { FormatTypes } from './loaders/LoaderFactory.js';
import STLLoader from './loaders/STLLoader.js';
import OBJLoader from './loaders/OBJLoader.js';
import VTPLoader from './loaders/VTPLoader.js';
import GLTFLoader from './loaders/GLTFLoader.js';
// Features
import FieldVisualization from './features/FieldVisualization.js';
import TextureManager from './features/TextureManager.js';
import ScreenshotCapture from './features/ScreenshotCapture.js';
import AxisIndicator from './features/AxisIndicator.js';
// Viewers
import StandardViewer, { createStandardViewer } from './viewers/StandardViewer.js';
import TexturedViewer, { createTexturedViewer } from './viewers/TexturedViewer.js';
import DualViewer, { createDualViewer, DualLayoutMode } from './viewers/DualViewer.js';
// Re-export utilities
export { ColorUtils, BoundsUtils, MessageHandler };
// Re-export core
export { CameraController, ActorManager, Representation, BaseViewer };
// Re-export UI
export { SettingsPanel, SettingTypes, ControlsBar, ControlTypes, StandardControls };
// Re-export loaders
export { LoaderFactory, FormatTypes, STLLoader, OBJLoader, VTPLoader, GLTFLoader };
// Re-export features
export { FieldVisualization, TextureManager, ScreenshotCapture, AxisIndicator };
// Re-export viewers
export { StandardViewer, TexturedViewer, DualViewer, DualLayoutMode };
export { createStandardViewer, createTexturedViewer, createDualViewer };
// Export individual utility functions for convenience
export { rgbToString, hexToRgb, rgbToHex } from './utils/ColorUtils.js';
export { getCenter, getMaxDimension, getDimensions, combineBounds, isValidBounds } from './utils/BoundsUtils.js';
export {
MessageTypes,
sendToParent,
sendError,
sendScreenshot,
sendMeshLoaded,
createMessageListener,
extractFilename
} from './utils/MessageHandler.js';
// Export loader factory functions
export {
loadMesh,
loadMeshes,
detectFormat,
requiresRenderer,
supportsTextures,
supportsFields,
getLoaderConfig
} from './loaders/LoaderFactory.js';
/**
* Viewer types for ViewerFactory
*/
export const ViewerTypes = {
STANDARD: 'standard',
TEXTURED: 'textured',
DUAL: 'dual'
};
/**
* ViewerFactory - Creates viewer instances
*
* Usage:
* const viewer = ViewerFactory.create('standard', containerEl);
* await viewer.initialize();
* await viewer.loadMesh('/path/to/mesh.stl');
*/
export const ViewerFactory = {
/**
* Create a viewer instance
* @param {string} type - Viewer type ('standard', 'textured', 'dual')
* @param {HTMLElement|HTMLElement[]} container - Container element(s)
* @param {Object} options - Viewer options
* @returns {BaseViewer|DualViewer} Viewer instance
*/
create(type, container, options = {}) {
console.log(`[GeomPack Viewer] Creating ${type} viewer`);
switch (type) {
case ViewerTypes.STANDARD:
case 'standard':
return new StandardViewer(container, options);
case ViewerTypes.TEXTURED:
case 'textured':
return new TexturedViewer(container, options);
case ViewerTypes.DUAL:
case 'dual':
// Dual viewer expects two containers
if (Array.isArray(container)) {
return new DualViewer(container[0], container[1], options);
} else if (options.containerB) {
return new DualViewer(container, options.containerB, options);
} else {
throw new Error('DualViewer requires two containers');
}
default:
console.warn(`[GeomPack Viewer] Unknown viewer type: ${type}, falling back to standard`);
return new StandardViewer(container, options);
}
},
/**
* Create and initialize a viewer
* @param {string} type - Viewer type
* @param {HTMLElement|HTMLElement[]} container - Container element(s)
* @param {Object} options - Viewer options
* @returns {Promise<BaseViewer|DualViewer>} Initialized viewer
*/
async createAndInit(type, container, options = {}) {
const viewer = this.create(type, container, options);
await viewer.initialize();
return viewer;
}
};
// Default export
export default {
// Utils
ColorUtils,
BoundsUtils,
MessageHandler,
// Core
CameraController,
ActorManager,
Representation,
BaseViewer,
// UI
SettingsPanel,
SettingTypes,
ControlsBar,
ControlTypes,
StandardControls,
// Loaders
LoaderFactory,
FormatTypes,
STLLoader,
OBJLoader,
VTPLoader,
GLTFLoader,
// Features
FieldVisualization,
TextureManager,
ScreenshotCapture,
AxisIndicator,
// Viewers
StandardViewer,
TexturedViewer,
DualViewer,
DualLayoutMode,
ViewerTypes,
// Factory functions
createStandardViewer,
createTexturedViewer,
createDualViewer,
// Factory
ViewerFactory
};
+299
View File
@@ -0,0 +1,299 @@
/**
* GLTFLoader - GLTF/GLB mesh file loader with texture support
*
* Loads GLTF/GLB files using VTK.js vtkGLTFImporter.
* Includes critical texture fixes for proper rendering:
* - Removes metallicRoughnessTexture (causes rendering issues)
* - Sets material to non-metallic for proper texture display
* - Enables two-sided lighting
*
* This loader properly handles textured meshes, unlike simplified implementations.
*/
/**
* Check if GLTFImporter is available
* @param {Object} vtk - VTK.js global object
* @returns {boolean}
*/
export function isGLTFSupported(vtk) {
try {
return vtk.IO.Geometry.vtkGLTFImporter !== undefined;
} catch {
return false;
}
}
/**
* Load a GLTF/GLB file with full texture support
* @param {string} filepath - URL to the GLTF/GLB file
* @param {Object} vtk - VTK.js global object
* @param {Object} renderer - VTK renderer (required for GLTF import)
* @param {Object} options - Loading options
* @param {boolean} options.applyTextureFixes - Apply material fixes for proper texture display (default: true)
* @param {boolean} options.twoSidedLighting - Enable two-sided lighting (default: true)
* @returns {Object} Result object with actors and metadata
*/
export async function loadGLTF(filepath, vtk, renderer, options = {}) {
const {
applyTextureFixes = true,
twoSidedLighting = true
} = options;
const vtkGLTFImporter = vtk.IO.Geometry.vtkGLTFImporter;
if (!vtkGLTFImporter) {
throw new Error('GLTFImporter not available in this VTK.js version');
}
// Enable two-sided lighting for better texture visibility
if (twoSidedLighting && renderer.setTwoSidedLighting) {
renderer.setTwoSidedLighting(true);
}
const importer = vtkGLTFImporter.newInstance();
// CRITICAL: Set renderer FIRST - importActors() needs this
importer.setRenderer(renderer);
// Set the URL
importer.setUrl(filepath);
// Wait for GLTF parsing to complete using onReady callback
await new Promise((resolve, reject) => {
importer.onReady(() => {
resolve();
});
// Start loading (this triggers parsing)
importer.loadData().catch(reject);
});
// Import actors (creates VTK actors and adds them to renderer)
importer.importActors();
// Get actors from the importer (returns Map<string, vtkActor>)
const actorsMap = importer.getActors();
const actors = Array.from(actorsMap.values());
// Track texture and vertex color information
let hasTexture = false;
let hasMaterial = false;
let hasVertexColors = false;
// Process actors and apply texture fixes
if (applyTextureFixes) {
actors.forEach((actor, index) => {
const mapper = actor.getMapper();
const property = actor.getProperty();
const input = mapper?.getInputData();
if (input && input.getNumberOfPoints() > 0) {
// Check for vertex colors (may be stored as scalars or as named array COLOR_0)
const pointData = input.getPointData();
let scalars = pointData?.getScalars();
// If no default scalars, check for COLOR_0 array (GLTF standard)
if (!scalars && pointData) {
const numArrays = pointData.getNumberOfArrays();
for (let i = 0; i < numArrays; i++) {
const arr = pointData.getArray(i);
const name = arr?.getName() || '';
if (name.toUpperCase().includes('COLOR') && arr.getNumberOfComponents() >= 3) {
scalars = arr;
// Set as active scalars so VTK renders them
pointData.setScalars(arr);
break;
}
}
}
if (scalars && scalars.getNumberOfComponents() >= 3) {
hasVertexColors = true;
console.log('[GLTFLoader] Found vertex colors:', scalars.getName(), 'components:', scalars.getNumberOfComponents());
}
if (property) {
// CRITICAL FIX: Remove metallicRoughnessTexture if present
// VTK.js GLTFImporter may use this instead of baseColorTexture
if (typeof property.setRMTexture === 'function') {
property.setRMTexture(null);
}
if (typeof property.setMetallicRoughnessTexture === 'function') {
property.setMetallicRoughnessTexture(null);
}
// Try multiple methods to get the base color texture
let texture = null;
if (typeof property.getBaseColorTexture === 'function') {
texture = property.getBaseColorTexture();
}
if (!texture && typeof property.getDiffuseTexture === 'function') {
texture = property.getDiffuseTexture();
}
if (!texture && typeof property.getTexture === 'function') {
texture = property.getTexture();
}
if (texture) {
hasTexture = true;
actor.addTexture(texture);
}
// Check for material
if (property.getMetallic !== undefined || property.getRoughness !== undefined) {
hasMaterial = true;
}
// Set material to non-metallic for proper texture display
if (typeof property.setMetallic === 'function') {
property.setMetallic(0.0);
}
if (typeof property.setRoughness === 'function') {
property.setRoughness(1.0);
}
if (typeof property.setInterpolation === 'function') {
property.setInterpolation(2); // Phong shading
}
}
// Configure mapper for rendering
if (mapper) {
console.log('[GLTFLoader] Configuring mapper - hasVertexColors:', hasVertexColors, 'hasTexture:', hasTexture);
if (hasVertexColors && !hasTexture) {
// Enable scalar visibility to show vertex colors
console.log('[GLTFLoader] Enabling vertex color rendering');
mapper.setScalarVisibility(true);
mapper.setScalarModeToUsePointData();
mapper.setColorModeToDirectScalars();
// Set actor color to white so vertex colors show through
if (property) {
console.log('[GLTFLoader] Setting actor color to white');
property.setColor(1.0, 1.0, 1.0);
// Also set PBR base color factor for GLTF materials
if (typeof property.setBaseColorFactor === 'function') {
console.log('[GLTFLoader] Setting PBR baseColorFactor to white');
property.setBaseColorFactor([1.0, 1.0, 1.0, 1.0]);
}
// Set diffuse and ambient explicitly
if (typeof property.setDiffuseColor === 'function') {
property.setDiffuseColor(1.0, 1.0, 1.0);
}
if (typeof property.setAmbientColor === 'function') {
property.setAmbientColor(1.0, 1.0, 1.0);
}
if (typeof property.setAmbient === 'function') {
property.setAmbient(0.2);
}
if (typeof property.setDiffuse === 'function') {
property.setDiffuse(0.8);
}
}
} else {
// Disable scalars for texture rendering
mapper.setScalarVisibility(false);
}
mapper.modified();
}
}
});
}
// Calculate combined bounds from all actors
let bounds = null;
if (actors.length > 0) {
bounds = [Infinity, -Infinity, Infinity, -Infinity, Infinity, -Infinity];
actors.forEach(actor => {
const actorBounds = actor.getBounds();
bounds[0] = Math.min(bounds[0], actorBounds[0]);
bounds[1] = Math.max(bounds[1], actorBounds[1]);
bounds[2] = Math.min(bounds[2], actorBounds[2]);
bounds[3] = Math.max(bounds[3], actorBounds[3]);
bounds[4] = Math.min(bounds[4], actorBounds[4]);
bounds[5] = Math.max(bounds[5], actorBounds[5]);
});
}
// Calculate total points/polys
let totalPoints = 0;
let totalPolys = 0;
actors.forEach(actor => {
const mapper = actor.getMapper();
const input = mapper?.getInputData();
if (input) {
totalPoints += input.getNumberOfPoints();
totalPolys += input.getNumberOfPolys();
}
});
return {
actors,
importer,
bounds,
hasTexture,
hasVertexColors,
metadata: {
format: 'GLTF',
numActors: actors.length,
numPoints: totalPoints,
numPolys: totalPolys,
bounds,
hasTexture,
hasVertexColors,
hasMaterial
}
};
}
/**
* Simplified GLTF load for non-textured display (e.g., field visualization)
* Uses parseAsArrayBuffer instead of URL loading for simpler cases
* @param {ArrayBuffer} arrayBuffer - File data as ArrayBuffer
* @param {Object} vtk - VTK.js global object
* @returns {Object} Result object with polyData and metadata
*/
export async function loadGLTFSimple(arrayBuffer, vtk) {
const vtkGLTFImporter = vtk.IO.Geometry.vtkGLTFImporter;
if (!vtkGLTFImporter) {
throw new Error('GLTFImporter not available');
}
const importer = vtkGLTFImporter.newInstance();
importer.parseAsArrayBuffer(arrayBuffer);
await importer.update();
const polyData = importer.getOutputData();
if (!polyData) {
throw new Error('Failed to extract polydata from GLTF');
}
return {
polyData,
importer,
metadata: {
format: 'GLTF',
numPoints: polyData.getNumberOfPoints(),
numPolys: polyData.getNumberOfPolys(),
bounds: polyData.getBounds()
}
};
}
/**
* Check if a filepath is a GLTF/GLB file
* @param {string} filepath - File path or URL
* @returns {boolean}
*/
export function canLoadGLTF(filepath) {
const lower = filepath.toLowerCase();
return lower.includes('.glb') || lower.includes('.gltf');
}
export default {
load: loadGLTF,
loadSimple: loadGLTFSimple,
canLoad: canLoadGLTF,
isSupported: isGLTFSupported,
format: 'GLTF'
};
@@ -0,0 +1,228 @@
/**
* LoaderFactory - Auto-detect and load mesh files
*
* Provides a unified interface for loading different mesh formats.
* Automatically detects file type and uses the appropriate loader.
*/
import { loadSTL, canLoadSTL } from './STLLoader.js';
import { loadOBJ, canLoadOBJ } from './OBJLoader.js';
import { loadVTP, canLoadVTP } from './VTPLoader.js';
import { loadGLTF, loadGLTFSimple, canLoadGLTF, isGLTFSupported } from './GLTFLoader.js';
/**
* Supported format types
*/
export const FormatTypes = {
STL: 'STL',
OBJ: 'OBJ',
VTP: 'VTP',
GLTF: 'GLTF',
GLB: 'GLB'
};
/**
* Detect file format from filepath
* @param {string} filepath - File path or URL
* @returns {string|null} Format type or null if unknown
*/
export function detectFormat(filepath) {
const lower = filepath.toLowerCase();
if (lower.includes('.stl')) return FormatTypes.STL;
if (lower.includes('.obj')) return FormatTypes.OBJ;
if (lower.includes('.vtp')) return FormatTypes.VTP;
if (lower.includes('.glb')) return FormatTypes.GLB;
if (lower.includes('.gltf')) return FormatTypes.GLTF;
return null;
}
/**
* Check if a format requires special handling (e.g., GLTF needs renderer)
* @param {string} format - Format type
* @returns {boolean}
*/
export function requiresRenderer(format) {
return format === FormatTypes.GLTF || format === FormatTypes.GLB;
}
/**
* Check if a format supports textures
* @param {string} format - Format type
* @returns {boolean}
*/
export function supportsTextures(format) {
return format === FormatTypes.GLTF || format === FormatTypes.GLB;
}
/**
* Check if a format supports scalar fields
* @param {string} format - Format type
* @returns {boolean}
*/
export function supportsFields(format) {
return format === FormatTypes.VTP;
}
/**
* Fetch file data as ArrayBuffer
* @param {string} filepath - URL to fetch
* @returns {Promise<ArrayBuffer>}
*/
async function fetchFile(filepath, onProgress = null) {
const response = await fetch(filepath);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const total = Number(response.headers.get('Content-Length')) || 0;
// Fall back to a plain (indeterminate) read if we can't stream or size it.
if (!response.body || !total || typeof onProgress !== 'function') {
if (typeof onProgress === 'function') onProgress(null);
return response.arrayBuffer();
}
const reader = response.body.getReader();
const chunks = [];
let loaded = 0;
onProgress(0, 0, total);
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
loaded += value.length;
onProgress(Math.min(1, loaded / total), loaded, total);
}
const out = new Uint8Array(loaded);
let offset = 0;
for (const c of chunks) { out.set(c, offset); offset += c.length; }
return out.buffer;
}
/**
* Load a mesh file with automatic format detection
* @param {string} filepath - URL to the mesh file
* @param {Object} vtk - VTK.js global object
* @param {Object} options - Loading options
* @param {Object} options.renderer - VTK renderer (required for GLTF with textures)
* @param {boolean} options.withTextures - Load GLTF with full texture support (default: true)
* @param {boolean} options.twoSidedLighting - Enable two-sided lighting for GLTF (default: true)
* @returns {Promise<Object>} Result object with polyData/actors and metadata
*/
export async function loadMesh(filepath, vtk, options = {}) {
const {
renderer = null,
withTextures = true,
twoSidedLighting = true,
onProgress = null
} = options;
const format = detectFormat(filepath);
if (!format) {
throw new Error(`Unsupported file format: ${filepath}`);
}
console.log(`[LoaderFactory] Loading ${format} file:`, filepath);
// GLTF/GLB with texture support requires renderer and URL-based loading
// (VTK fetches internally, so we can't byte-track it -> indeterminate).
if ((format === FormatTypes.GLTF || format === FormatTypes.GLB) && withTextures && renderer) {
if (typeof onProgress === 'function') onProgress(null);
return loadGLTF(filepath, vtk, renderer, {
applyTextureFixes: true,
twoSidedLighting
});
}
// For all other cases, fetch the file first (with download progress)
const arrayBuffer = await fetchFile(filepath, onProgress);
switch (format) {
case FormatTypes.STL:
return loadSTL(arrayBuffer, vtk);
case FormatTypes.OBJ:
return loadOBJ(arrayBuffer, vtk);
case FormatTypes.VTP:
return loadVTP(arrayBuffer, vtk);
case FormatTypes.GLTF:
case FormatTypes.GLB:
// Simple GLTF load without texture support
return loadGLTFSimple(arrayBuffer, vtk);
default:
throw new Error(`No loader available for format: ${format}`);
}
}
/**
* Load multiple mesh files
* @param {string[]} filepaths - Array of URLs to mesh files
* @param {Object} vtk - VTK.js global object
* @param {Object} options - Loading options (same as loadMesh)
* @returns {Promise<Object[]>} Array of result objects
*/
export async function loadMeshes(filepaths, vtk, options = {}) {
return Promise.all(filepaths.map(fp => loadMesh(fp, vtk, options)));
}
/**
* Create a loader configuration for a specific format
* Useful for manual loader selection
* @param {string} format - Format type
* @returns {Object} Loader configuration
*/
export function getLoaderConfig(format) {
switch (format) {
case FormatTypes.STL:
return {
load: loadSTL,
canLoad: canLoadSTL,
requiresRenderer: false,
supportsTextures: false,
supportsFields: false
};
case FormatTypes.OBJ:
return {
load: loadOBJ,
canLoad: canLoadOBJ,
requiresRenderer: false,
supportsTextures: false,
supportsFields: false
};
case FormatTypes.VTP:
return {
load: loadVTP,
canLoad: canLoadVTP,
requiresRenderer: false,
supportsTextures: false,
supportsFields: true
};
case FormatTypes.GLTF:
case FormatTypes.GLB:
return {
load: loadGLTF,
loadSimple: loadGLTFSimple,
canLoad: canLoadGLTF,
isSupported: isGLTFSupported,
requiresRenderer: true,
supportsTextures: true,
supportsFields: false
};
default:
return null;
}
}
export default {
loadMesh,
loadMeshes,
detectFormat,
requiresRenderer,
supportsTextures,
supportsFields,
getLoaderConfig,
FormatTypes
};
+63
View File
@@ -0,0 +1,63 @@
/**
* OBJLoader - OBJ mesh file loader
*
* Loads Wavefront OBJ files using VTK.js vtkOBJReader.
* Includes normals computation for proper shading.
*/
/**
* Load an OBJ file and return polydata
* @param {ArrayBuffer} arrayBuffer - File data as ArrayBuffer
* @param {Object} vtk - VTK.js global object
* @returns {Object} Result object with polyData and metadata
*/
export async function loadOBJ(arrayBuffer, vtk) {
const vtkOBJReader = vtk.IO.Misc.vtkOBJReader;
const vtkPolyDataNormals = vtk.Filters.Core.vtkPolyDataNormals;
const reader = vtkOBJReader.newInstance();
// OBJ files are text-based
const text = new TextDecoder().decode(arrayBuffer);
reader.parseAsText(text);
const rawPolyData = reader.getOutputData();
if (!rawPolyData) {
throw new Error('Failed to parse OBJ data');
}
// Apply normals filter for proper shading
const normalsFilter = vtkPolyDataNormals.newInstance();
normalsFilter.setInputConnection(reader.getOutputPort());
normalsFilter.update();
const polyData = normalsFilter.getOutputData();
return {
polyData,
reader,
normalsFilter,
outputPort: normalsFilter.getOutputPort(),
metadata: {
format: 'OBJ',
numPoints: polyData.getNumberOfPoints(),
numPolys: polyData.getNumberOfPolys(),
bounds: polyData.getBounds()
}
};
}
/**
* Check if a filepath is an OBJ file
* @param {string} filepath - File path or URL
* @returns {boolean}
*/
export function canLoadOBJ(filepath) {
return filepath.toLowerCase().includes('.obj');
}
export default {
load: loadOBJ,
canLoad: canLoadOBJ,
format: 'OBJ'
};
+60
View File
@@ -0,0 +1,60 @@
/**
* STLLoader - STL mesh file loader
*
* Loads binary or ASCII STL files using VTK.js vtkSTLReader.
* Includes normals computation for proper shading.
*/
/**
* Load an STL file and return polydata
* @param {ArrayBuffer} arrayBuffer - File data as ArrayBuffer
* @param {Object} vtk - VTK.js global object
* @returns {Object} Result object with polyData and metadata
*/
export async function loadSTL(arrayBuffer, vtk) {
const vtkSTLReader = vtk.IO.Geometry.vtkSTLReader;
const vtkPolyDataNormals = vtk.Filters.Core.vtkPolyDataNormals;
const reader = vtkSTLReader.newInstance();
reader.parseAsArrayBuffer(arrayBuffer);
const rawPolyData = reader.getOutputData();
if (!rawPolyData) {
throw new Error('Failed to parse STL data');
}
// Apply normals filter for proper shading
const normalsFilter = vtkPolyDataNormals.newInstance();
normalsFilter.setInputConnection(reader.getOutputPort());
normalsFilter.update();
const polyData = normalsFilter.getOutputData();
return {
polyData,
reader,
normalsFilter,
outputPort: normalsFilter.getOutputPort(),
metadata: {
format: 'STL',
numPoints: polyData.getNumberOfPoints(),
numPolys: polyData.getNumberOfPolys(),
bounds: polyData.getBounds()
}
};
}
/**
* Check if a filepath is an STL file
* @param {string} filepath - File path or URL
* @returns {boolean}
*/
export function canLoadSTL(filepath) {
return filepath.toLowerCase().includes('.stl');
}
export default {
load: loadSTL,
canLoad: canLoadSTL,
format: 'STL'
};
+116
View File
@@ -0,0 +1,116 @@
/**
* VTPLoader - VTK XML PolyData file loader
*
* Loads VTP files using VTK.js vtkXMLPolyDataReader.
* VTP files can contain scalar fields (vertex/face attributes).
* Includes normals computation for proper shading.
*/
/**
* Extract field information from polydata
* @param {Object} polyData - VTK polydata object
* @returns {Object} Field information
*/
function extractFieldInfo(polyData) {
const pointData = polyData.getPointData();
const cellData = polyData.getCellData();
const pointFields = [];
const cellFields = [];
// Extract point (vertex) data arrays
for (let i = 0; i < pointData.getNumberOfArrays(); i++) {
const array = pointData.getArray(i);
const name = array.getName();
const range = array.getRange();
pointFields.push({
name,
type: 'point',
numComponents: array.getNumberOfComponents(),
range: [range[0], range[1]]
});
}
// Extract cell (face) data arrays
for (let i = 0; i < cellData.getNumberOfArrays(); i++) {
const array = cellData.getArray(i);
const name = array.getName();
const range = array.getRange();
cellFields.push({
name,
type: 'cell',
numComponents: array.getNumberOfComponents(),
range: [range[0], range[1]]
});
}
return {
pointFields,
cellFields,
hasFields: pointFields.length > 0 || cellFields.length > 0
};
}
/**
* Load a VTP file and return polydata
* @param {ArrayBuffer} arrayBuffer - File data as ArrayBuffer
* @param {Object} vtk - VTK.js global object
* @returns {Object} Result object with polyData, fields, and metadata
*/
export async function loadVTP(arrayBuffer, vtk) {
const vtkXMLPolyDataReader = vtk.IO.XML.vtkXMLPolyDataReader;
const vtkPolyDataNormals = vtk.Filters.Core.vtkPolyDataNormals;
const reader = vtkXMLPolyDataReader.newInstance();
reader.parseAsArrayBuffer(arrayBuffer);
const rawPolyData = reader.getOutputData();
if (!rawPolyData) {
throw new Error('Failed to parse VTP data');
}
// Extract field info BEFORE normals filter (preserves original data)
const fieldInfo = extractFieldInfo(rawPolyData);
// Apply normals filter for proper shading
const normalsFilter = vtkPolyDataNormals.newInstance();
normalsFilter.setInputConnection(reader.getOutputPort());
normalsFilter.update();
const polyData = normalsFilter.getOutputData();
// Extract field info again from filtered data (should be same)
const filteredFieldInfo = extractFieldInfo(polyData);
return {
polyData,
reader,
normalsFilter,
outputPort: normalsFilter.getOutputPort(),
fields: filteredFieldInfo,
metadata: {
format: 'VTP',
numPoints: polyData.getNumberOfPoints(),
numPolys: polyData.getNumberOfPolys(),
bounds: polyData.getBounds(),
pointFields: filteredFieldInfo.pointFields.map(f => f.name),
cellFields: filteredFieldInfo.cellFields.map(f => f.name),
hasFields: filteredFieldInfo.hasFields
}
};
}
/**
* Check if a filepath is a VTP file
* @param {string} filepath - File path or URL
* @returns {boolean}
*/
export function canLoadVTP(filepath) {
return filepath.toLowerCase().includes('.vtp');
}
export default {
load: loadVTP,
canLoad: canLoadVTP,
format: 'VTP'
};
+398
View File
@@ -0,0 +1,398 @@
/**
* ControlsBar - Configurable controls bar for VTK viewers
*
* Generates and manages the bottom controls bar with camera buttons,
* toggles, selectors, and action buttons.
*/
/**
* Control types
*/
export const ControlTypes = {
BUTTON: 'button',
CHECKBOX: 'checkbox',
SELECT: 'select',
DIVIDER: 'divider',
LABEL: 'label'
};
/**
* Standard control configurations
*/
export const StandardControls = {
settingsButton: {
id: 'settingsButton',
type: ControlTypes.BUTTON,
label: '[Settings]',
title: 'Appearance Settings'
},
divider: {
type: ControlTypes.DIVIDER
},
edgesToggle: {
id: 'showEdges',
type: ControlTypes.CHECKBOX,
label: 'Edges'
},
cameraButtons: {
type: 'camera_group',
buttons: ['+X', '-X', '+Y', '-Y', '+Z', '-Z']
},
screenshotButton: {
id: 'screenshot',
type: ControlTypes.BUTTON,
label: 'Screenshot'
},
saveMeshButton: {
id: 'saveMesh',
type: ControlTypes.BUTTON,
label: 'Save Mesh',
className: 'save-button'
},
saveGLBButton: {
id: 'saveGLB',
type: ControlTypes.BUTTON,
label: 'Save GLB',
className: 'save-button'
},
resetCameraButton: {
id: 'resetCamera',
type: ControlTypes.BUTTON,
label: 'Reset Camera'
},
fieldSelector: {
id: 'fieldSelector',
type: ControlTypes.SELECT,
label: 'Field',
wrapperClass: 'field-label',
hidden: true
},
colormapSelector: {
id: 'colormapSelector',
type: ControlTypes.SELECT,
label: 'Colormap',
wrapperClass: 'colormap-label',
hidden: true
}
};
export class ControlsBar {
/**
* Create a ControlsBar
* @param {Object} options - Configuration options
* @param {HTMLElement} options.container - Container element for the controls bar
* @param {Object} options.handlers - Event handlers keyed by control ID
* @param {Array} options.controls - Array of control configurations
*/
constructor(options = {}) {
this.container = options.container;
this.handlers = options.handlers || {};
this.controls = options.controls || this._getDefaultControls();
this.elements = {};
}
/**
* Get default controls for standard viewer
* @private
*/
_getDefaultControls() {
return [
StandardControls.settingsButton,
StandardControls.divider,
StandardControls.edgesToggle,
StandardControls.divider,
StandardControls.cameraButtons,
StandardControls.divider,
StandardControls.fieldSelector,
StandardControls.colormapSelector,
StandardControls.screenshotButton,
StandardControls.saveMeshButton
];
}
/**
* Generate HTML for the controls bar
* @returns {string} HTML string
*/
generateHTML() {
let html = '';
this.controls.forEach(control => {
if (!control) return;
switch (control.type) {
case ControlTypes.BUTTON:
html += this._generateButton(control);
break;
case ControlTypes.CHECKBOX:
html += this._generateCheckbox(control);
break;
case ControlTypes.SELECT:
html += this._generateSelect(control);
break;
case ControlTypes.DIVIDER:
html += '<span class="controls-divider"></span>';
break;
case ControlTypes.LABEL:
html += `<span class="controls-label">${control.text || ''}</span>`;
break;
case 'camera_group':
html += this._generateCameraGroup(control);
break;
default:
if (typeof control.html === 'string') {
html += control.html;
}
}
});
return html;
}
/**
* Generate button HTML
* @private
*/
_generateButton(control) {
const className = control.className || '';
const style = control.id === 'settingsButton' ? 'width:32px;height:32px;padding:0;font-size:16px;' : '';
const styleAttr = style ? `style="${style}"` : '';
const classAttr = className ? `class="${className}"` : '';
return `<button id="${control.id}" ${classAttr} ${styleAttr} title="${control.title || ''}">${control.label}</button>`;
}
/**
* Generate checkbox HTML
* @private
*/
_generateCheckbox(control) {
const checked = control.checked ? 'checked' : '';
return `
<label>
<input type="checkbox" id="${control.id}" ${checked}> ${control.label}
</label>
`;
}
/**
* Generate select HTML
* @private
*/
_generateSelect(control) {
const wrapperClass = control.wrapperClass || '';
const hiddenStyle = control.hidden ? 'style="display:none;"' : '';
const options = (control.options || []).map(opt =>
`<option value="${opt.value}" ${opt.selected ? 'selected' : ''}>${opt.label}</option>`
).join('');
return `
<label id="${control.id}Label" class="${wrapperClass}" ${hiddenStyle}>
${control.label}:
<select id="${control.id}">${options}</select>
</label>
`;
}
/**
* Generate camera view buttons group
* @private
*/
_generateCameraGroup(control) {
const buttons = control.buttons || ['+X', '-X', '+Y', '-Y', '+Z', '-Z'];
return buttons.map(dir => {
const id = `view${dir.replace('+', 'Pos').replace('-', 'Neg')}`;
return `<button id="${id}">${dir}</button>`;
}).join('');
}
/**
* Render the controls bar into the container
*/
render() {
if (!this.container) {
console.warn('[ControlsBar] No container specified');
return;
}
this.container.innerHTML = this.generateHTML();
this._cacheElements();
this._setupEventListeners();
}
/**
* Cache references to control elements
* @private
*/
_cacheElements() {
this.controls.forEach(control => {
if (control && control.id) {
this.elements[control.id] = document.getElementById(control.id);
}
});
// Also cache camera buttons
['viewPosX', 'viewNegX', 'viewPosY', 'viewNegY', 'viewPosZ', 'viewNegZ'].forEach(id => {
const el = document.getElementById(id);
if (el) this.elements[id] = el;
});
}
/**
* Setup event listeners based on handlers
* @private
*/
_setupEventListeners() {
// Standard handlers
Object.keys(this.handlers).forEach(id => {
const element = this.elements[id];
const handler = this.handlers[id];
if (element && handler) {
if (element.type === 'checkbox') {
element.addEventListener('change', (e) => handler(e.target.checked, e));
} else if (element.tagName === 'SELECT') {
element.addEventListener('change', (e) => handler(e.target.value, e));
} else {
element.addEventListener('click', handler);
}
}
});
// Camera buttons (special handling)
if (this.handlers.cameraView) {
const cameraDirections = ['+X', '-X', '+Y', '-Y', '+Z', '-Z'];
cameraDirections.forEach(dir => {
const id = `view${dir.replace('+', 'Pos').replace('-', 'Neg')}`;
const element = this.elements[id];
if (element) {
element.addEventListener('click', () => this.handlers.cameraView(dir));
}
});
}
}
/**
* Get an element by ID
* @param {string} id - Element ID
* @returns {HTMLElement|null}
*/
getElement(id) {
return this.elements[id] || document.getElementById(id);
}
/**
* Show a control element
* @param {string} id - Element ID
*/
show(id) {
const wrapper = document.getElementById(`${id}Label`) || this.elements[id];
if (wrapper) {
wrapper.style.display = 'flex';
}
}
/**
* Hide a control element
* @param {string} id - Element ID
*/
hide(id) {
const wrapper = document.getElementById(`${id}Label`) || this.elements[id];
if (wrapper) {
wrapper.style.display = 'none';
}
}
/**
* Enable a control element
* @param {string} id - Element ID
*/
enable(id) {
const element = this.elements[id];
if (element) {
element.disabled = false;
}
}
/**
* Disable a control element
* @param {string} id - Element ID
*/
disable(id) {
const element = this.elements[id];
if (element) {
element.disabled = true;
}
}
/**
* Set button text
* @param {string} id - Button ID
* @param {string} text - New text
*/
setButtonText(id, text) {
const element = this.elements[id];
if (element) {
element.textContent = text;
}
}
/**
* Set button style (e.g., for save button states)
* @param {string} id - Button ID
* @param {Object} style - Style properties
*/
setButtonStyle(id, style) {
const element = this.elements[id];
if (element) {
Object.assign(element.style, style);
}
}
/**
* Populate a select element with options
* @param {string} id - Select element ID
* @param {Array} options - Array of {value, label, selected?} objects
* @param {boolean} clearFirst - Whether to clear existing options first
*/
populateSelect(id, options, clearFirst = true) {
const element = this.elements[id] || document.getElementById(id);
if (!element || element.tagName !== 'SELECT') return;
if (clearFirst) {
element.innerHTML = '';
}
options.forEach(opt => {
const option = document.createElement('option');
option.value = opt.value;
option.textContent = opt.label;
if (opt.selected) option.selected = true;
if (opt.disabled) option.disabled = true;
element.appendChild(option);
});
}
/**
* Add a handler for a control
* @param {string} id - Control ID
* @param {Function} handler - Event handler function
*/
addHandler(id, handler) {
this.handlers[id] = handler;
// If already rendered, attach immediately
const element = this.elements[id];
if (element) {
if (element.type === 'checkbox') {
element.addEventListener('change', (e) => handler(e.target.checked, e));
} else if (element.tagName === 'SELECT') {
element.addEventListener('change', (e) => handler(e.target.value, e));
} else {
element.addEventListener('click', handler);
}
}
}
}
export default ControlsBar;
+383
View File
@@ -0,0 +1,383 @@
/**
* SettingsPanel - Configurable settings modal for VTK viewers
*
* Generates and manages a settings panel with configurable fields.
* Different viewers can specify which settings to show.
*/
/**
* Available setting types
*/
export const SettingTypes = {
NUMBER: 'number',
COLOR: 'color',
SELECT: 'select',
CHECKBOX: 'checkbox'
};
/**
* Default settings configuration
*/
const DEFAULT_SETTINGS_CONFIG = [
{
id: 'pointSize',
label: 'Point Size',
type: SettingTypes.NUMBER,
min: 1,
max: 20,
step: 1,
default: 5
},
{
id: 'meshColor',
label: 'Mesh Color',
type: SettingTypes.COLOR,
default: '#ffffff'
},
{
id: 'edgeWidth',
label: 'Edge Width',
type: SettingTypes.NUMBER,
min: 1,
max: 10,
step: 1,
default: 1
},
{
id: 'representation',
label: 'Render Mode',
type: SettingTypes.SELECT,
options: [
{ value: '2', label: 'Surface' },
{ value: '1', label: 'Wireframe' },
{ value: '0', label: 'Points' }
],
default: '2'
},
{
id: 'parallelProjection',
label: 'Parallel Projection',
type: SettingTypes.CHECKBOX,
default: false
},
{
id: 'showAxisIndicator',
label: 'Show Axis Indicator',
type: SettingTypes.CHECKBOX,
default: false
}
];
/**
* Colormap range settings (optional, for field visualization)
*/
const COLORMAP_SETTINGS_CONFIG = [
{
id: 'autoColorRange',
label: 'Auto Range',
type: SettingTypes.CHECKBOX,
default: true,
section: 'Colormap Range'
},
{
id: 'colormapMin',
label: 'Min',
type: SettingTypes.NUMBER,
step: 'any',
placeholder: 'Auto',
default: '',
section: 'Colormap Range'
},
{
id: 'colormapMax',
label: 'Max',
type: SettingTypes.NUMBER,
step: 'any',
placeholder: 'Auto',
default: '',
section: 'Colormap Range'
}
];
export class SettingsPanel {
/**
* Create a SettingsPanel
* @param {Object} options - Configuration options
* @param {HTMLElement} options.container - Container element for the panel
* @param {Function} options.onApply - Callback when settings are applied
* @param {string[]} options.enabledSettings - Array of setting IDs to show (null = all)
* @param {boolean} options.includeColormapSettings - Whether to include colormap range settings
*/
constructor(options = {}) {
this.container = options.container || document.body;
this.onApply = options.onApply || (() => {});
this.enabledSettings = options.enabledSettings || null;
this.includeColormapSettings = options.includeColormapSettings || false;
this.panelElement = null;
this.overlayElement = null;
this.currentValues = {};
// Build settings config
this.settingsConfig = this._buildSettingsConfig();
// Initialize default values
this.settingsConfig.forEach(setting => {
this.currentValues[setting.id] = setting.default;
});
}
/**
* Build the settings configuration based on enabled settings
* @private
*/
_buildSettingsConfig() {
let config = [...DEFAULT_SETTINGS_CONFIG];
if (this.includeColormapSettings) {
config = config.concat(COLORMAP_SETTINGS_CONFIG);
}
if (this.enabledSettings) {
config = config.filter(s => this.enabledSettings.includes(s.id));
}
return config;
}
/**
* Generate the HTML for the settings panel
* @returns {string} HTML string
*/
generateHTML() {
let currentSection = null;
let html = `
<div id="settingsOverlay"></div>
<div id="settingsPanel">
<h3>Appearance Settings</h3>
`;
this.settingsConfig.forEach(setting => {
// Add section header if needed
if (setting.section && setting.section !== currentSection) {
currentSection = setting.section;
html += `
<div class="setting-row setting-section">
<label class="setting-section-label">${setting.section}:</label>
</div>
`;
}
html += this._generateSettingRow(setting);
});
html += `
<div class="button-row">
<button id="applySettings">Apply</button>
<button id="cancelSettings">Cancel</button>
</div>
</div>
`;
return html;
}
/**
* Generate HTML for a single setting row
* @private
*/
_generateSettingRow(setting) {
let inputHtml = '';
switch (setting.type) {
case SettingTypes.NUMBER:
inputHtml = `
<input type="number" id="${setting.id}"
${setting.min !== undefined ? `min="${setting.min}"` : ''}
${setting.max !== undefined ? `max="${setting.max}"` : ''}
${setting.step !== undefined ? `step="${setting.step}"` : ''}
${setting.placeholder ? `placeholder="${setting.placeholder}"` : ''}
value="${setting.default}">
`;
break;
case SettingTypes.COLOR:
inputHtml = `<input type="color" id="${setting.id}" value="${setting.default}">`;
break;
case SettingTypes.SELECT:
inputHtml = `<select id="${setting.id}">`;
setting.options.forEach(opt => {
const selected = opt.value === setting.default ? 'selected' : '';
inputHtml += `<option value="${opt.value}" ${selected}>${opt.label}</option>`;
});
inputHtml += '</select>';
break;
case SettingTypes.CHECKBOX:
const checked = setting.default ? 'checked' : '';
return `
<div class="setting-row">
<label>
<input type="checkbox" id="${setting.id}" ${checked}> ${setting.label}
</label>
</div>
`;
}
return `
<div class="setting-row">
<label>${setting.label}:</label>
${inputHtml}
</div>
`;
}
/**
* Render the panel into the container
*/
render() {
// Create wrapper if needed
const wrapper = document.createElement('div');
wrapper.innerHTML = this.generateHTML();
// Find or create panel elements in container
this.overlayElement = wrapper.querySelector('#settingsOverlay');
this.panelElement = wrapper.querySelector('#settingsPanel');
// Append to container
this.container.appendChild(this.overlayElement);
this.container.appendChild(this.panelElement);
// Setup event listeners
this._setupEventListeners();
}
/**
* Attach to existing panel elements in the DOM
* @param {HTMLElement} panel - The settings panel element
* @param {HTMLElement} overlay - The overlay element
*/
attachToExisting(panel, overlay) {
this.panelElement = panel;
this.overlayElement = overlay;
this._setupEventListeners();
}
/**
* Setup event listeners
* @private
*/
_setupEventListeners() {
// Apply button
const applyBtn = this.panelElement.querySelector('#applySettings');
if (applyBtn) {
applyBtn.addEventListener('click', () => this._handleApply());
}
// Cancel button
const cancelBtn = this.panelElement.querySelector('#cancelSettings');
if (cancelBtn) {
cancelBtn.addEventListener('click', () => this.close());
}
// Overlay click
if (this.overlayElement) {
this.overlayElement.addEventListener('click', () => this.close());
}
}
/**
* Handle apply button click
* @private
*/
_handleApply() {
const values = this.getValues();
this.currentValues = { ...values };
this.onApply(values);
this.close();
}
/**
* Open the settings panel
*/
open() {
// Update inputs with current values
this.setValues(this.currentValues);
if (this.panelElement) {
this.panelElement.classList.add('visible');
}
if (this.overlayElement) {
this.overlayElement.classList.add('visible');
}
}
/**
* Close the settings panel
*/
close() {
if (this.panelElement) {
this.panelElement.classList.remove('visible');
}
if (this.overlayElement) {
this.overlayElement.classList.remove('visible');
}
}
/**
* Get current values from all inputs
* @returns {Object} Object with setting id -> value pairs
*/
getValues() {
const values = {};
this.settingsConfig.forEach(setting => {
const element = document.getElementById(setting.id);
if (!element) return;
if (setting.type === SettingTypes.CHECKBOX) {
values[setting.id] = element.checked;
} else {
values[setting.id] = element.value;
}
});
return values;
}
/**
* Set values on all inputs
* @param {Object} values - Object with setting id -> value pairs
*/
setValues(values) {
this.settingsConfig.forEach(setting => {
const element = document.getElementById(setting.id);
if (!element || values[setting.id] === undefined) return;
if (setting.type === SettingTypes.CHECKBOX) {
element.checked = values[setting.id];
} else {
element.value = values[setting.id];
}
});
}
/**
* Update stored current values
* @param {Object} values - Partial values to update
*/
updateCurrentValues(values) {
this.currentValues = { ...this.currentValues, ...values };
}
/**
* Get current stored values
* @returns {Object} Current values
*/
getCurrentValues() {
return { ...this.currentValues };
}
}
export default SettingsPanel;
+366
View File
@@ -0,0 +1,366 @@
/**
* Shared styles for all VTK viewers
* Single source of truth - changes here affect all viewers
*/
/* ========== Base Layout ========== */
body {
margin: 0;
overflow: hidden;
background: #2a2a2a;
font-family: monospace;
display: flex;
flex-direction: column;
height: 100vh;
}
#viewerContainer {
flex: 1;
position: relative;
min-height: 0;
}
#container {
width: 100%;
height: 100%;
position: relative;
}
/* ========== Loading Overlay ========== */
#loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 14px;
text-align: center;
z-index: 1000;
pointer-events: none;
}
/* ========== Controls Bar ========== */
#controls {
background: #1a1a1a;
border-top: 1px solid #444;
padding: 8px 12px;
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
font-size: 12px;
color: #ccc;
}
#controls label {
display: flex;
align-items: center;
gap: 4px;
}
#controls select,
#controls button {
background: #333;
color: #ccc;
border: 1px solid #555;
padding: 4px 8px;
border-radius: 3px;
font-size: 12px;
font-family: monospace;
}
#controls button {
cursor: pointer;
}
#controls button:hover {
background: #444;
}
#controls button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
#controls input[type="checkbox"] {
cursor: pointer;
}
/* Divider between control groups */
.controls-divider {
width: 1px;
height: 20px;
background: #444;
margin: 0 8px;
}
/* ========== Settings Panel ========== */
#settingsPanel {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #1a1a1a;
border: 1px solid #555;
border-radius: 6px;
padding: 16px;
z-index: 2000;
display: none;
min-width: 280px;
max-height: 80%;
overflow-y: auto;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
}
#settingsPanel.visible {
display: block;
}
#settingsPanel h3 {
margin: 0 0 12px 0;
color: #ccc;
font-size: 14px;
font-weight: normal;
border-bottom: 1px solid #333;
padding-bottom: 8px;
}
#settingsPanel .setting-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
#settingsPanel .setting-row label {
flex: 0 0 100px;
color: #ccc;
font-size: 12px;
}
#settingsPanel .setting-row input[type="color"] {
flex: 1;
height: 32px;
border: 1px solid #555;
background: #333;
cursor: pointer;
}
#settingsPanel .setting-row input[type="number"] {
flex: 1;
background: #333;
color: #ccc;
border: 1px solid #555;
padding: 6px 8px;
border-radius: 3px;
font-size: 12px;
font-family: monospace;
}
#settingsPanel .setting-row select {
flex: 1;
background: #333;
color: #ccc;
border: 1px solid #555;
padding: 6px 8px;
border-radius: 3px;
font-size: 12px;
font-family: monospace;
}
#settingsPanel .setting-row input[type="checkbox"] {
cursor: pointer;
}
#settingsPanel .setting-section {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #444;
}
#settingsPanel .setting-section-label {
font-weight: bold;
color: #aaa;
margin-bottom: 8px;
}
#settingsPanel .button-row {
display: flex;
gap: 8px;
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid #333;
}
#settingsPanel .button-row button {
flex: 1;
padding: 6px 12px;
font-size: 12px;
background: #333;
color: #ccc;
border: 1px solid #555;
border-radius: 3px;
cursor: pointer;
}
#settingsPanel .button-row button:hover {
background: #444;
}
/* Settings overlay (dark backdrop) */
#settingsOverlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1999;
display: none;
}
#settingsOverlay.visible {
display: block;
}
/* Settings button (gear icon) */
#settingsButton {
background: #333;
width: 32px;
height: 32px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
/* ========== Info Panel (for textured viewer) ========== */
#infoPanel {
position: absolute;
top: 10px;
left: 10px;
background: rgba(26, 26, 26, 0.9);
border: 1px solid #444;
border-radius: 4px;
padding: 8px 12px;
font-size: 11px;
color: #ccc;
z-index: 100;
display: none;
}
#infoPanel.visible {
display: block;
}
#infoPanel .info-row {
display: flex;
gap: 8px;
margin-bottom: 4px;
}
#infoPanel .info-row:last-child {
margin-bottom: 0;
}
#infoPanel .info-label {
color: #888;
}
#infoPanel .info-value {
color: #4a9eff;
}
#infoPanel .info-value.warning {
color: #ffaa00;
}
#infoPanel .info-value.error {
color: #ff6b6b;
}
#infoPanel .info-value.success {
color: #6bff6b;
}
/* ========== Dual Viewer Specific ========== */
.viewer-dual #viewerContainer {
display: flex;
}
.viewer-dual .viewport-container {
flex: 1;
position: relative;
min-width: 0;
}
.viewer-dual .viewport-label {
position: absolute;
top: 8px;
left: 8px;
background: rgba(0, 0, 0, 0.6);
color: #ccc;
padding: 4px 8px;
border-radius: 3px;
font-size: 11px;
z-index: 100;
}
.viewer-dual #sideBySideContainer {
display: none;
width: 100%;
height: 100%;
gap: 2px;
}
.viewer-dual #sideBySideContainer.active {
display: flex;
}
.viewer-dual #overlayContainer {
display: none;
width: 100%;
height: 100%;
}
.viewer-dual #overlayContainer.active {
display: block;
}
/* Field controls for dual viewer */
.field-controls {
display: none;
}
.field-controls.active {
display: flex;
align-items: center;
gap: 8px;
}
/* ========== Save Button Variants ========== */
#saveMesh,
#saveGLB {
background: #2a5a2a;
}
#saveMesh:hover,
#saveGLB:hover {
background: #3a6a3a;
}
/* ========== Utility Classes ========== */
.hidden {
display: none !important;
}
.flex {
display: flex;
}
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
+94
View File
@@ -0,0 +1,94 @@
/**
* Bounds calculation utilities for VTK viewers
*/
/**
* Get the center point of bounds
* @param {number[]} bounds - VTK bounds array [xmin, xmax, ymin, ymax, zmin, zmax]
* @returns {number[]} Center point [x, y, z]
*/
export function getCenter(bounds) {
return [
(bounds[0] + bounds[1]) / 2,
(bounds[2] + bounds[3]) / 2,
(bounds[4] + bounds[5]) / 2
];
}
/**
* Get the maximum dimension of bounds
* @param {number[]} bounds - VTK bounds array [xmin, xmax, ymin, ymax, zmin, zmax]
* @returns {number} Maximum dimension
*/
export function getMaxDimension(bounds) {
return Math.max(
bounds[1] - bounds[0],
bounds[3] - bounds[2],
bounds[5] - bounds[4]
);
}
/**
* Get all three dimensions of bounds
* @param {number[]} bounds - VTK bounds array [xmin, xmax, ymin, ymax, zmin, zmax]
* @returns {number[]} Dimensions [width, height, depth]
*/
export function getDimensions(bounds) {
return [
bounds[1] - bounds[0],
bounds[3] - bounds[2],
bounds[5] - bounds[4]
];
}
/**
* Combine multiple bounds into a single bounding box
* @param {number[][]} boundsArray - Array of VTK bounds
* @returns {number[]} Combined bounds [xmin, xmax, ymin, ymax, zmin, zmax]
*/
export function combineBounds(boundsArray) {
if (!boundsArray || boundsArray.length === 0) {
return [0, 0, 0, 0, 0, 0];
}
const combined = [...boundsArray[0]];
for (let i = 1; i < boundsArray.length; i++) {
const b = boundsArray[i];
combined[0] = Math.min(combined[0], b[0]); // xmin
combined[1] = Math.max(combined[1], b[1]); // xmax
combined[2] = Math.min(combined[2], b[2]); // ymin
combined[3] = Math.max(combined[3], b[3]); // ymax
combined[4] = Math.min(combined[4], b[4]); // zmin
combined[5] = Math.max(combined[5], b[5]); // zmax
}
return combined;
}
/**
* Check if bounds are valid (non-empty)
* @param {number[]} bounds - VTK bounds array
* @returns {boolean} True if bounds are valid
*/
export function isValidBounds(bounds) {
if (!bounds || bounds.length !== 6) return false;
// Check for NaN or Infinity
for (const val of bounds) {
if (!Number.isFinite(val)) return false;
}
// Check that max >= min for all dimensions
return bounds[1] >= bounds[0] &&
bounds[3] >= bounds[2] &&
bounds[5] >= bounds[4];
}
export default {
getCenter,
getMaxDimension,
getDimensions,
combineBounds,
isValidBounds
};
+59
View File
@@ -0,0 +1,59 @@
/**
* Color utility functions for VTK viewers
*/
/**
* Convert RGB values (0-1 range) to CSS rgb() string
* @param {number} r - Red component (0-1)
* @param {number} g - Green component (0-1)
* @param {number} b - Blue component (0-1)
* @returns {string} CSS rgb string like "rgb(255, 128, 0)"
*/
export function rgbToString(r, g, b) {
const r255 = Math.round(r * 255);
const g255 = Math.round(g * 255);
const b255 = Math.round(b * 255);
return `rgb(${r255}, ${g255}, ${b255})`;
}
/**
* Convert hex color string to RGB array (0-1 range)
* @param {string} hex - Hex color like "#ffffff" or "#fff"
* @returns {number[]} RGB array [r, g, b] with values 0-1
*/
export function hexToRgb(hex) {
// Remove # if present
hex = hex.replace(/^#/, '');
// Handle shorthand hex (#fff -> #ffffff)
if (hex.length === 3) {
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
}
const r = parseInt(hex.substr(0, 2), 16) / 255;
const g = parseInt(hex.substr(2, 2), 16) / 255;
const b = parseInt(hex.substr(4, 2), 16) / 255;
return [r, g, b];
}
/**
* Convert RGB array (0-1 range) to hex color string
* @param {number} r - Red component (0-1)
* @param {number} g - Green component (0-1)
* @param {number} b - Blue component (0-1)
* @returns {string} Hex color like "#ffffff"
*/
export function rgbToHex(r, g, b) {
const toHex = (c) => {
const hex = Math.round(c * 255).toString(16);
return hex.length === 1 ? '0' + hex : hex;
};
return '#' + toHex(r) + toHex(g) + toHex(b);
}
export default {
rgbToString,
hexToRgb,
rgbToHex
};
@@ -0,0 +1,100 @@
/**
* PostMessage communication utilities for VTK viewers
*/
/**
* Message types used by the viewer system
*/
export const MessageTypes = {
// Incoming messages (from parent)
LOAD_MESH: 'LOAD_MESH',
LOAD_DUAL_MESH: 'LOAD_DUAL_MESH',
// Outgoing messages (to parent)
MESH_LOADED: 'MESH_LOADED',
MESH_ERROR: 'MESH_ERROR',
SCREENSHOT: 'SCREENSHOT'
};
/**
* Send a message to the parent window
* @param {string} type - Message type
* @param {Object} data - Additional data to send
*/
export function sendToParent(type, data = {}) {
if (window.parent && window.parent !== window) {
window.parent.postMessage({
type,
...data
}, '*');
}
}
/**
* Send an error message to the parent window
* @param {string|Error} error - Error message or Error object
*/
export function sendError(error) {
const message = error instanceof Error ? error.message : error;
sendToParent(MessageTypes.MESH_ERROR, { error: message });
}
/**
* Send a screenshot to the parent window
* @param {string} imageDataUrl - Base64 PNG data URL
*/
export function sendScreenshot(imageDataUrl) {
sendToParent(MessageTypes.SCREENSHOT, {
image: imageDataUrl,
timestamp: Date.now()
});
}
/**
* Send mesh loaded confirmation to parent
*/
export function sendMeshLoaded() {
sendToParent(MessageTypes.MESH_LOADED, { error: null });
}
/**
* Create a message listener for the viewer
* @param {Object} handlers - Object mapping message types to handler functions
* @returns {Function} Cleanup function to remove the listener
*/
export function createMessageListener(handlers) {
const listener = (event) => {
const { type } = event.data || {};
if (type && handlers[type]) {
handlers[type](event.data);
}
};
window.addEventListener('message', listener);
// Return cleanup function
return () => {
window.removeEventListener('message', listener);
};
}
/**
* Extract filename from a filepath URL
* @param {string} filepath - URL or path to extract filename from
* @returns {string} Extracted filename
*/
export function extractFilename(filepath) {
const urlParams = new URLSearchParams(filepath.split('?')[1] || '');
return urlParams.get('filename') || filepath.split('/').pop().split('?')[0];
}
export default {
MessageTypes,
sendToParent,
sendError,
sendScreenshot,
sendMeshLoaded,
createMessageListener,
extractFilename
};
+551
View File
@@ -0,0 +1,551 @@
/**
* DualViewer - VTK viewer for dual mesh comparison
*
* Supports side-by-side and overlay layouts with:
* - Camera synchronization between viewports
* - Synchronized scalar field ranges
* - Multiple layout modes (side-by-side, overlay, split)
* - Independent and linked settings
*/
import { CameraController } from '../core/CameraController.js';
import { ActorManager } from '../core/ActorManager.js';
import { loadMesh, supportsTextures } from '../loaders/LoaderFactory.js';
import { FieldVisualization } from '../features/FieldVisualization.js';
import { TextureManager } from '../features/TextureManager.js';
import { ScreenshotCapture } from '../features/ScreenshotCapture.js';
import { sendMeshLoaded, sendError, createMessageListener, extractFilename } from '../utils/MessageHandler.js';
import { combineBounds, isValidBounds } from '../utils/BoundsUtils.js';
/**
* Layout modes for dual viewer
*/
export const LayoutMode = {
SIDE_BY_SIDE: 'side-by-side',
OVERLAY: 'overlay',
VERTICAL: 'vertical'
};
/**
* Dual viewer configuration
*/
const DUAL_CONFIG = {
// Camera
cameraDistanceMultiplier: 2.5,
// Layout
layout: LayoutMode.SIDE_BY_SIDE,
// Sync options
syncCameras: true,
syncFields: true,
// Appearance
backgroundColor: [0.15, 0.15, 0.15],
defaultMeshColorA: [0.6, 0.8, 1.0],
defaultMeshColorB: [0.6, 0.8, 1.0],
showEdges: false,
// Features
enableFields: true,
enableTextures: true
};
export class DualViewer {
/**
* Create a DualViewer
* @param {HTMLElement} containerA - Container for first viewport
* @param {HTMLElement} containerB - Container for second viewport
* @param {Object} config - Configuration options
*/
constructor(containerA, containerB, config = {}) {
this.containerA = containerA;
this.containerB = containerB;
this.config = { ...DUAL_CONFIG, ...config };
// VTK objects
this.vtk = null;
// Viewports (each has its own renderer, render window, etc.)
this.viewportA = null;
this.viewportB = null;
// Feature modules (shared)
this.actorManager = null;
this.fieldVisualization = null;
// State
this.layout = this.config.layout;
this.syncCameras = this.config.syncCameras;
this.syncFields = this.config.syncFields;
this.isInitialized = false;
// Camera sync state
this._cameraObserver = null;
this._isUpdatingCamera = false;
// Message listener cleanup
this._messageCleanup = null;
}
/**
* Initialize the dual viewer
*/
async initialize() {
if (this.isInitialized) return;
this.vtk = window.vtk;
if (!this.vtk) {
throw new Error('VTK.js not loaded');
}
// Create both viewports
this.viewportA = this._createViewport(this.containerA, 'A');
this.viewportB = this._createViewport(this.containerB, 'B');
// Initialize shared modules
this.actorManager = new ActorManager(this.vtk, {
defaultColor: this.config.defaultMeshColorA
});
this.fieldVisualization = new FieldVisualization(this.vtk, this.viewportA.renderWindow);
// Setup camera sync if enabled
if (this.syncCameras) {
this._setupCameraSync();
}
// Setup resize handlers
this._setupResizeHandlers();
// Setup message listener
this._setupMessageListener();
this.isInitialized = true;
console.log('[DualViewer] Initialized');
}
/**
* Create a viewport
* @private
* @param {HTMLElement} container
* @param {string} id
* @returns {Object}
*/
_createViewport(container, id) {
const renderWindow = this.vtk.Rendering.Core.vtkRenderWindow.newInstance();
const renderer = this.vtk.Rendering.Core.vtkRenderer.newInstance();
renderer.setBackground(...this.config.backgroundColor);
renderWindow.addRenderer(renderer);
const openGLRenderWindow = this.vtk.Rendering.OpenGL.vtkRenderWindow.newInstance();
openGLRenderWindow.setContainer(container);
renderWindow.addView(openGLRenderWindow);
const interactor = this.vtk.Rendering.Core.vtkRenderWindowInteractor.newInstance();
interactor.setView(openGLRenderWindow);
interactor.initialize();
interactor.bindEvents(container);
const interactorStyle = this.vtk.Interaction.Style.vtkInteractorStyleTrackballCamera.newInstance();
interactor.setInteractorStyle(interactorStyle);
const { width, height } = container.getBoundingClientRect();
openGLRenderWindow.setSize(width, height);
const cameraController = new CameraController(renderer, {
distanceMultiplier: this.config.cameraDistanceMultiplier
});
// Create texture manager for this viewport
const textureManager = new TextureManager(this.vtk, renderWindow);
return {
id,
container,
renderWindow,
renderer,
openGLRenderWindow,
interactor,
interactorStyle,
cameraController,
textureManager,
actors: [],
polyData: null,
filename: null
};
}
/**
* Setup camera synchronization between viewports
* @private
*/
_setupCameraSync() {
const syncCamera = (sourceViewport, targetViewport) => {
if (this._isUpdatingCamera) return;
this._isUpdatingCamera = true;
const sourceCamera = sourceViewport.renderer.getActiveCamera();
const targetCamera = targetViewport.renderer.getActiveCamera();
targetCamera.setPosition(...sourceCamera.getPosition());
targetCamera.setFocalPoint(...sourceCamera.getFocalPoint());
targetCamera.setViewUp(...sourceCamera.getViewUp());
targetCamera.setParallelScale(sourceCamera.getParallelScale());
targetViewport.renderWindow.render();
this._isUpdatingCamera = false;
};
// Subscribe to camera modified events
const cameraA = this.viewportA.renderer.getActiveCamera();
const cameraB = this.viewportB.renderer.getActiveCamera();
cameraA.onModified(() => {
if (this.syncCameras) {
syncCamera(this.viewportA, this.viewportB);
}
});
cameraB.onModified(() => {
if (this.syncCameras) {
syncCamera(this.viewportB, this.viewportA);
}
});
}
/**
* Setup resize handlers for both viewports
* @private
*/
_setupResizeHandlers() {
const handleResize = (viewport) => {
const { width, height } = viewport.container.getBoundingClientRect();
if (width > 0 && height > 0) {
viewport.openGLRenderWindow.setSize(width, height);
viewport.renderWindow.render();
}
};
this._resizeObserverA = new ResizeObserver(() => handleResize(this.viewportA));
this._resizeObserverB = new ResizeObserver(() => handleResize(this.viewportB));
this._resizeObserverA.observe(this.viewportA.container);
this._resizeObserverB.observe(this.viewportB.container);
}
/**
* Setup message listener
* @private
*/
_setupMessageListener() {
this._messageCleanup = createMessageListener((data) => {
switch (data.type) {
case 'loadMeshA':
this.loadMesh('A', data.url);
break;
case 'loadMeshB':
this.loadMesh('B', data.url);
break;
case 'loadMesh':
// Load to both or specified viewport
if (data.viewport) {
this.loadMesh(data.viewport, data.url);
} else {
this.loadMesh('A', data.url);
}
break;
case 'setLayout':
this.setLayout(data.layout);
break;
case 'setCameraSync':
this.setCameraSync(data.enabled);
break;
case 'setField':
this.applyField(data.field, data.options);
break;
case 'screenshot':
this.captureScreenshot();
break;
}
});
}
/**
* Load mesh into a viewport
* @param {string} viewport - 'A' or 'B'
* @param {string} url - Mesh URL
* @returns {Promise<Object>}
*/
async loadMesh(viewport, url) {
const vp = viewport === 'A' ? this.viewportA : this.viewportB;
const defaultColor = viewport === 'A'
? this.config.defaultMeshColorA
: this.config.defaultMeshColorB;
try {
// Clear existing actors
vp.actors.forEach(actor => vp.renderer.removeActor(actor));
vp.actors = [];
// Load mesh
const result = await loadMesh(url, this.vtk, {
renderer: vp.renderer
});
vp.actors = result.actors;
vp.polyData = result.polyData;
vp.filename = extractFilename(url);
// Add actors to renderer
result.actors.forEach(actor => {
vp.renderer.addActor(actor);
});
// Configure actors based on format
if (result.hasTexture && this.config.enableTextures) {
// Apply texture fixes for GLTF/GLB
vp.textureManager.applyTextureFixesMultiple(result.actors);
vp.textureManager.configureRenderer(vp.renderer);
} else if (result.hasVertexColors) {
// Mesh has vertex colors - don't override with default color
// Just configure edges if needed, colors are already set up by loader
result.actors.forEach(actor => {
const property = actor.getProperty();
if (property && this.config.showEdges) {
property.setEdgeVisibility(true);
}
});
} else {
// Standard actor configuration - apply default color
this.actorManager.configureActors(result.actors, {
color: defaultColor,
showEdges: this.config.showEdges
});
}
// Position camera
this._positionCameraForViewport(vp);
// Render
vp.renderWindow.render();
// Notify parent
sendMeshLoaded({
viewport,
filename: vp.filename,
format: result.format,
hasTexture: result.hasTexture || false,
hasVertexColors: result.hasVertexColors || false
});
return result;
} catch (error) {
console.error(`[DualViewer] Error loading mesh ${viewport}:`, error);
sendError(`Failed to load mesh ${viewport}: ${error.message}`);
throw error;
}
}
/**
* Position camera for a viewport
* @private
*/
_positionCameraForViewport(viewport) {
if (viewport.actors.length === 0) return;
let bounds = viewport.actors[0].getBounds();
for (let i = 1; i < viewport.actors.length; i++) {
bounds = combineBounds(bounds, viewport.actors[i].getBounds());
}
if (isValidBounds(bounds)) {
viewport.cameraController.positionInitialCamera(bounds);
}
}
/**
* Apply field visualization to both viewports
* @param {string} fieldName - Field name
* @param {Object} options - Options including colormap, range
*/
applyField(fieldName, options = {}) {
if (!this.config.enableFields) return;
// Calculate synchronized range if enabled
let range = options.range;
if (this.syncFields && !range) {
const polydatas = [
this.viewportA.polyData,
this.viewportB.polyData
].filter(Boolean);
if (polydatas.length > 0) {
range = this.fieldVisualization.calculateSynchronizedRange(polydatas, fieldName);
}
}
// Apply to viewport A
if (this.viewportA.polyData && this.viewportA.actors.length > 0) {
const mapperA = this.viewportA.actors[0].getMapper();
this.fieldVisualization.applyField(mapperA, this.viewportA.polyData, fieldName, {
...options,
range,
render: false
});
}
// Apply to viewport B
if (this.viewportB.polyData && this.viewportB.actors.length > 0) {
const mapperB = this.viewportB.actors[0].getMapper();
// Need a separate FieldVisualization for viewport B's render window
const fieldVizB = new FieldVisualization(this.vtk, this.viewportB.renderWindow);
fieldVizB.applyField(mapperB, this.viewportB.polyData, fieldName, {
...options,
range,
render: false
});
}
// Render both
this.render();
}
/**
* Set layout mode
* @param {string} layout - LayoutMode value
*/
setLayout(layout) {
this.layout = layout;
// Layout changes are handled by CSS classes on the containers
// This method just updates the state; the parent should update CSS
console.log(`[DualViewer] Layout set to: ${layout}`);
}
/**
* Set camera sync enabled
* @param {boolean} enabled
*/
setCameraSync(enabled) {
this.syncCameras = enabled;
console.log(`[DualViewer] Camera sync: ${enabled}`);
}
/**
* Set field range sync enabled
* @param {boolean} enabled
*/
setFieldSync(enabled) {
this.syncFields = enabled;
}
/**
* Set camera view for both viewports
* @param {string} direction - '+X', '-X', etc.
*/
setCameraView(direction) {
// Get combined bounds from both viewports
const allActors = [...this.viewportA.actors, ...this.viewportB.actors];
if (allActors.length === 0) return;
let bounds = allActors[0].getBounds();
for (let i = 1; i < allActors.length; i++) {
bounds = combineBounds(bounds, allActors[i].getBounds());
}
// Apply to viewport A (will sync to B if enabled)
this.viewportA.cameraController.setCameraView(direction, bounds);
// If sync is disabled, also update B
if (!this.syncCameras) {
this.viewportB.cameraController.setCameraView(direction, bounds);
}
this.render();
}
/**
* Capture screenshot of both viewports
* @returns {Promise<Object>}
*/
async captureScreenshot() {
const screenshotA = new ScreenshotCapture(this.viewportA.renderWindow);
const screenshotB = new ScreenshotCapture(this.viewportB.renderWindow);
const [imageA, imageB] = await Promise.all([
screenshotA.capture(),
screenshotB.capture()
]);
// Send combined screenshot info
window.parent?.postMessage({
type: 'screenshot',
data: {
viewportA: imageA,
viewportB: imageB
}
}, '*');
return { viewportA: imageA, viewportB: imageB };
}
/**
* Render both viewports
*/
render() {
this.viewportA.renderWindow.render();
this.viewportB.renderWindow.render();
}
/**
* Get viewport
* @param {string} id - 'A' or 'B'
* @returns {Object}
*/
getViewport(id) {
return id === 'A' ? this.viewportA : this.viewportB;
}
/**
* Cleanup and destroy
*/
destroy() {
if (this._messageCleanup) {
this._messageCleanup();
}
if (this._resizeObserverA) {
this._resizeObserverA.disconnect();
}
if (this._resizeObserverB) {
this._resizeObserverB.disconnect();
}
// Destroy viewports
[this.viewportA, this.viewportB].forEach(vp => {
if (!vp) return;
vp.actors.forEach(actor => vp.renderer.removeActor(actor));
vp.interactor.unbindEvents();
vp.openGLRenderWindow.delete();
vp.renderWindow.delete();
});
console.log('[DualViewer] Destroyed');
}
}
/**
* Create a DualViewer instance
* @param {HTMLElement} containerA
* @param {HTMLElement} containerB
* @param {Object} config
* @returns {DualViewer}
*/
export function createDualViewer(containerA, containerB, config = {}) {
const viewer = new DualViewer(containerA, containerB, config);
viewer.initialize();
return viewer;
}
export { LayoutMode as DualLayoutMode };
export default DualViewer;
@@ -0,0 +1,241 @@
/**
* StandardViewer - VTK viewer for standard mesh preview
*
* Supports STL, OBJ, VTP formats with:
* - Scalar field visualization
* - Colormap selection
* - Settings panel (color, edges, lighting)
* - Screenshot capture
*/
import { BaseViewer } from '../core/BaseViewer.js';
import { SettingTypes } from '../ui/SettingsPanel.js';
import { ControlTypes, StandardControls } from '../ui/ControlsBar.js';
/**
* Standard viewer configuration
*/
const STANDARD_CONFIG = {
// Camera
cameraDistanceMultiplier: 2.5,
// Features
enableFields: true,
enableTextures: false,
enableScreenshot: true,
enableAxisIndicator: false,
enableSettings: true,
enableControls: true,
// Appearance
backgroundColor: [0.15, 0.15, 0.15],
defaultMeshColor: [0.6, 0.8, 1.0],
showEdges: false
};
export class StandardViewer extends BaseViewer {
/**
* Create a StandardViewer
* @param {HTMLElement} container - Container element
* @param {Object} config - Additional configuration
*/
constructor(container, config = {}) {
// Merge with standard config
const mergedConfig = {
...STANDARD_CONFIG,
...config,
// Settings panel configuration
settingsConfig: {
title: 'Settings',
fields: [
{
type: SettingTypes.COLOR,
id: 'meshColor',
label: 'Mesh Color',
defaultValue: config.defaultMeshColor || STANDARD_CONFIG.defaultMeshColor
},
{
type: SettingTypes.CHECKBOX,
id: 'showEdges',
label: 'Show Edges',
defaultValue: config.showEdges || STANDARD_CONFIG.showEdges
},
{
type: SettingTypes.COLOR,
id: 'edgeColor',
label: 'Edge Color',
defaultValue: config.edgeColor || [0.2, 0.2, 0.2]
},
{
type: SettingTypes.COLOR,
id: 'backgroundColor',
label: 'Background',
defaultValue: config.backgroundColor || STANDARD_CONFIG.backgroundColor
}
]
},
// Controls bar configuration
controlsConfig: {
controls: [
StandardControls.CAMERA_VIEWS,
StandardControls.SCREENSHOT,
StandardControls.SETTINGS
]
}
};
super(container, mergedConfig);
// Field visualization state
this.currentField = null;
this.currentColormap = 'erdc_rainbow_bright';
this.availableFields = [];
}
/**
* Load mesh and extract field information
* @param {string} url - Mesh file URL
* @returns {Promise<Object>}
*/
async loadMesh(url) {
const result = await super.loadMesh(url);
// Extract field information for VTP files
if (this.fieldVisualization && this.polyDatas.length > 0) {
const fieldInfo = this.fieldVisualization.extractFields(this.polyDatas[0]);
this.availableFields = fieldInfo.allFields;
// Notify parent about available fields
if (this.availableFields.length > 0) {
window.parent?.postMessage({
type: 'fieldsAvailable',
fields: this.availableFields.map(f => ({
name: f.fullName,
displayName: f.displayName,
type: f.type,
range: f.range
}))
}, '*');
}
}
return result;
}
/**
* Apply scalar field visualization
* @param {string} fieldName - Field name (e.g., 'point:temperature')
* @param {Object} options - Visualization options
*/
applyField(fieldName, options = {}) {
if (!this.fieldVisualization || this.polyDatas.length === 0) {
console.warn('[StandardViewer] Field visualization not available');
return;
}
const mapper = this.actors[0]?.getMapper();
if (!mapper) return;
this.fieldVisualization.applyField(
mapper,
this.polyDatas[0],
fieldName,
{
colormap: options.colormap || this.currentColormap,
range: options.range,
render: true
}
);
this.currentField = fieldName;
if (options.colormap) {
this.currentColormap = options.colormap;
}
}
/**
* Set colormap
* @param {string} colormap - Colormap name
*/
setColormap(colormap) {
this.currentColormap = colormap;
if (this.currentField) {
this.applyField(this.currentField, { colormap });
}
}
/**
* Clear field visualization (show solid color)
*/
clearField() {
if (this.actors.length > 0) {
const mapper = this.actors[0].getMapper();
if (mapper) {
this.fieldVisualization?.disableScalarVisualization(mapper, true);
}
}
this.currentField = null;
}
/**
* Get available colormap options
* @returns {Array}
*/
getColormapOptions() {
return this.fieldVisualization?.getColormapOptions() || [];
}
/**
* Get available fields
* @returns {Array}
*/
getAvailableFields() {
return this.availableFields;
}
/**
* Setup message listener with field support
* @private
* @override
*/
_setupMessageListener() {
super._setupMessageListener();
// Add field-specific message handling
window.addEventListener('message', (event) => {
const data = event.data;
if (!data || !data.type) return;
switch (data.type) {
case 'setField':
this.applyField(data.field, {
colormap: data.colormap,
range: data.range
});
break;
case 'setColormap':
this.setColormap(data.colormap);
break;
case 'clearField':
this.clearField();
break;
}
});
}
}
/**
* Create a StandardViewer instance
* @param {HTMLElement} container
* @param {Object} config
* @returns {StandardViewer}
*/
export function createStandardViewer(container, config = {}) {
const viewer = new StandardViewer(container, config);
viewer.initialize();
return viewer;
}
export default StandardViewer;
@@ -0,0 +1,161 @@
/**
* TexturedViewer - VTK viewer for textured GLTF/GLB meshes
*
* Specialized for displaying textured 3D models with proper:
* - Material handling (metallic=0, roughness=1)
* - Texture extraction and application
* - Two-sided lighting
* - Closer camera distance for better texture viewing
*/
import { BaseViewer } from '../core/BaseViewer.js';
import { SettingTypes } from '../ui/SettingsPanel.js';
import { StandardControls } from '../ui/ControlsBar.js';
/**
* Textured viewer configuration
*/
const TEXTURED_CONFIG = {
// Camera - closer for texture detail
cameraDistanceMultiplier: 2.0,
// Lighting
twoSidedLighting: true,
// Features
enableFields: false,
enableTextures: true,
enableScreenshot: true,
enableAxisIndicator: false,
enableSettings: true,
enableControls: true,
// Appearance
backgroundColor: [0.15, 0.15, 0.15],
defaultMeshColor: [1.0, 1.0, 1.0],
showEdges: false
};
export class TexturedViewer extends BaseViewer {
/**
* Create a TexturedViewer
* @param {HTMLElement} container - Container element
* @param {Object} config - Additional configuration
*/
constructor(container, config = {}) {
// Merge with textured config
const mergedConfig = {
...TEXTURED_CONFIG,
...config,
// Settings panel configuration
settingsConfig: {
title: 'Settings',
fields: [
{
type: SettingTypes.COLOR,
id: 'backgroundColor',
label: 'Background',
defaultValue: config.backgroundColor || TEXTURED_CONFIG.backgroundColor
},
{
type: SettingTypes.CHECKBOX,
id: 'showEdges',
label: 'Show Edges',
defaultValue: false
}
]
},
// Controls bar configuration
controlsConfig: {
controls: [
StandardControls.CAMERA_VIEWS,
StandardControls.SCREENSHOT,
StandardControls.SETTINGS
]
}
};
super(container, mergedConfig);
// Texture state
this.textureInfo = null;
}
/**
* Configure loaded actors for textured display
* @private
* @override
*/
_configureLoadedActors(result) {
if (this.textureManager && result.actors.length > 0) {
// Apply texture fixes to all actors
this.textureInfo = this.textureManager.applyTextureFixesMultiple(result.actors);
// Configure renderer for texture display
this.textureManager.configureRenderer(this.renderer);
console.log('[TexturedViewer] Texture info:', this.textureInfo);
// If no textures found, apply default white color
if (!this.textureInfo.hasTexture) {
result.actors.forEach(actor => {
this.textureManager.setActorColor(actor, this.config.defaultMeshColor);
});
}
}
}
/**
* Update info overlay with texture information
* @private
* @override
*/
_updateInfoOverlay(result) {
if (!this.infoOverlay) return;
const lines = [`File: ${this.currentFilename}`];
if (result.actors) {
lines.push(`Actors: ${result.actors.length}`);
}
if (this.textureInfo) {
lines.push(`Textured: ${this.textureInfo.hasTexture ? 'Yes' : 'No'}`);
if (this.textureInfo.texturedActors > 0) {
lines.push(`Textured actors: ${this.textureInfo.texturedActors}`);
}
}
this.infoOverlay.innerHTML = lines.join('<br>');
}
/**
* Get texture information
* @returns {Object|null}
*/
getTextureInfo() {
return this.textureInfo;
}
/**
* Check if current mesh has textures
* @returns {boolean}
*/
hasTextures() {
return this.textureInfo?.hasTexture || false;
}
}
/**
* Create a TexturedViewer instance
* @param {HTMLElement} container
* @param {Object} config
* @returns {TexturedViewer}
*/
export function createTexturedViewer(container, config = {}) {
const viewer = new TexturedViewer(container, config);
viewer.initialize();
return viewer;
}
export default TexturedViewer;
File diff suppressed because one or more lines are too long
+94
View File
@@ -0,0 +1,94 @@
/**
* ComfyUI GeomPack - Warp Mesh field selector.
*
* The Warp Mesh node (GeomPackWarpMesh) is an output node: running it reports the
* candidate 3-component vertex fields. This extension renders them as clickable
* chips in a box under the node; clicking one writes it into the `field_name`
* widget. Re-run to apply the warp.
*/
import { app } from "../../../scripts/app.js";
app.registerExtension({
name: "geompack.warp_mesh_field_select",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "GeomPackWarpMesh") return;
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%;box-sizing:border-box;padding:6px;background:#1c1c1c;" +
"border:1px solid #444;border-radius:4px;font:11px monospace;color:#bbb;" +
"display:flex;flex-direction:column;gap:6px;overflow:auto;";
const status = document.createElement("div");
status.style.cssText = "color:#888;flex:0 0 auto;";
status.textContent = "Run to list 3-D vertex fields.";
const chips = document.createElement("div");
chips.style.cssText = "display:flex;flex-wrap:wrap;gap:4px;align-content:flex-start;";
container.appendChild(status);
container.appendChild(chips);
const widget = node.addDOMWidget("warp_fields", "WARP_FIELDS", container, {
getValue() { return ""; }, setValue() {},
});
widget.computeSize = () => [Math.max(node.size?.[0] || 240, 240), 150];
const fieldWidget = () => node.widgets?.find((w) => w.name === "field_name");
const render = (fields, selected) => {
chips.innerHTML = "";
if (!fields || fields.length === 0) {
status.textContent = "No 3-D (n_vertices, 3) vertex fields on this mesh.";
return;
}
status.textContent = "Click a field to warp by it:";
fields.forEach((name) => {
const chip = document.createElement("button");
chip.textContent = name;
const isSel = name === selected;
chip.style.cssText =
"font:11px monospace;padding:2px 8px;border-radius:10px;cursor:pointer;" +
"border:1px solid " + (isSel ? "#6c6" : "#555") + ";" +
"background:" + (isSel ? "#264" : "#2b2b2b") + ";" +
"color:" + (isSel ? "#cfc" : "#ddd") + ";";
chip.addEventListener("mouseenter", () => {
if (name !== selected) chip.style.background = "#383838";
});
chip.addEventListener("mouseleave", () => {
if (name !== selected) chip.style.background = "#2b2b2b";
});
chip.addEventListener("click", () => {
const w = fieldWidget();
if (w) {
w.value = name;
w.callback?.(name);
}
render(fields, name);
node.setDirtyCanvas(true, true);
});
chips.appendChild(chip);
});
};
const onExecuted = node.onExecuted;
node.onExecuted = function (message) {
onExecuted?.apply(this, arguments);
const fields = message?.fields?.[0] || [];
const selected = message?.selected?.[0] || (fieldWidget()?.value ?? "");
const warped = message?.warped?.[0];
render(fields, selected);
if (warped) status.textContent = "Warped by '" + selected + "'. Pick another to change.";
};
return r;
};
},
});
+218
View File
@@ -0,0 +1,218 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>GeomPack 3D Viewer</title>
<style>
body {
margin: 0;
overflow: hidden;
background: #2a2a2a;
font-family: monospace;
}
#canvas {
width: 100%;
height: 100%;
display: block;
}
#loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 14px;
text-align: center;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<div id="loading">Ready to load mesh</div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
console.log('[GeomPack Viewer] Initializing...');
const canvas = document.getElementById('canvas');
const loading = document.getElementById('loading');
// Scene
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x2a2a2a);
// Camera
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(2, 2, 2);
// Renderer
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
// Controls
const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.zoomSpeed = 3.0; // 3x faster zoom
// Lighting
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);
const dirLight1 = new THREE.DirectionalLight(0xffffff, 0.8);
dirLight1.position.set(5, 10, 7);
scene.add(dirLight1);
const dirLight2 = new THREE.DirectionalLight(0xffffff, 0.4);
dirLight2.position.set(-5, -5, -7);
scene.add(dirLight2);
// Grid
const grid = new THREE.GridHelper(10, 10, 0x444444, 0x222222);
scene.add(grid);
// Axes
const axes = new THREE.AxesHelper(1);
scene.add(axes);
// Current mesh
let currentMesh = null;
// Animation loop
function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
animate();
// Handle window resize
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// Load mesh function
function loadMesh(filepath) {
console.log('[GeomPack Viewer] Loading:', filepath);
console.log('[GeomPack Viewer DEBUG] Full filepath URL:', filepath);
loading.textContent = 'Loading mesh...';
loading.style.display = 'block';
// Remove old mesh
if (currentMesh) {
scene.remove(currentMesh);
currentMesh = null;
}
// Check if filepath contains .glb or .gltf (even with query params)
const isGLB = filepath.includes('.glb') || filepath.includes('.gltf');
const loader = isGLB ? new GLTFLoader() : new OBJLoader();
console.log('[GeomPack Viewer] File type detected:', isGLB ? 'GLB/GLTF' : 'OBJ');
console.log('[GeomPack Viewer DEBUG] Using loader:', loader.constructor.name);
console.log('[GeomPack Viewer DEBUG] About to call loader.load()...');
loader.load(
filepath,
(result) => {
console.log('[GeomPack Viewer DEBUG] Loader success callback called');
console.log('[GeomPack Viewer DEBUG] Result:', result);
currentMesh = isGLB ? result.scene : result;
console.log('[GeomPack Viewer DEBUG] currentMesh:', currentMesh);
// Center mesh
const box = new THREE.Box3().setFromObject(currentMesh);
const center = box.getCenter(new THREE.Vector3());
currentMesh.position.sub(center);
console.log('[GeomPack Viewer DEBUG] Mesh bounds:', box);
console.log('[GeomPack Viewer DEBUG] Mesh center:', center);
// Add material if needed
let meshCount = 0;
currentMesh.traverse((child) => {
if (child.isMesh) {
meshCount++;
if (!child.material) {
child.material = new THREE.MeshStandardMaterial({
color: 0x88aacc,
metalness: 0.3,
roughness: 0.7
});
}
}
});
console.log('[GeomPack Viewer DEBUG] Mesh count in scene:', meshCount);
// Add to scene
scene.add(currentMesh);
console.log('[GeomPack Viewer DEBUG] Mesh added to scene');
// Adjust camera
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const distance = maxDim * 2.5;
camera.position.set(distance, distance, distance);
camera.lookAt(0, 0, 0);
controls.target.set(0, 0, 0);
controls.update();
console.log('[GeomPack Viewer DEBUG] Camera adjusted to distance:', distance);
loading.style.display = 'none';
console.log('[GeomPack Viewer] Mesh Loaded successfully');
},
(progress) => {
if (progress.lengthComputable) {
const percent = (progress.loaded / progress.total * 100).toFixed(0);
loading.textContent = `Loading mesh... ${percent}%`;
}
},
(error) => {
console.error('[GeomPack Viewer] Error loading mesh:', error);
console.error('[GeomPack Viewer DEBUG] Error type:', error?.constructor?.name);
console.error('[GeomPack Viewer DEBUG] Error message:', error?.message);
console.error('[GeomPack Viewer DEBUG] Error stack:', error?.stack);
loading.textContent = 'Error loading mesh - check console';
loading.style.color = '#ff6666';
}
);
}
// Listen for messages from parent window
window.addEventListener('message', (event) => {
console.log('[GeomPack Viewer DEBUG] Received postMessage:', event.data);
console.log('[GeomPack Viewer DEBUG] Message type:', event.data?.type);
console.log('[GeomPack Viewer DEBUG] Message filepath:', event.data?.filepath);
if (event.data.type === 'LOAD_MESH') {
console.log('[GeomPack Viewer] Received LOAD_MESH message:', event.data.filepath);
loadMesh(event.data.filepath);
} else {
console.warn('[GeomPack Viewer DEBUG] Unknown message type or missing type');
}
});
console.log('[GeomPack Viewer] Ready');
console.log('[GeomPack Viewer DEBUG] Waiting for LOAD_MESH postMessage...');
</script>
</body>
</html>
+277
View File
@@ -0,0 +1,277 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { margin: 0; overflow: hidden; background: #1a1a1a; font-family: Arial, sans-serif; }
#canvas-container { width: 100%; height: 100%; }
#loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 18px;
z-index: 50;
}
#info {
position: absolute;
top: 10px;
left: 10px;
color: white;
background: rgba(0, 0, 0, 0.7);
padding: 10px;
border-radius: 5px;
font-size: 14px;
font-family: monospace;
z-index: 100;
}
</style>
</head>
<body>
<div id="loading">Loading BVH animation...</div>
<div id="info" style="display: none;"></div>
<div id="canvas-container"></div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { BVHLoader } from 'three/addons/loaders/BVHLoader.js';
let scene, camera, renderer, controls;
let skeletonHelper = null;
let mixer = null;
let currentAction = null;
let clip = null;
let clock = new THREE.Clock();
let isPlaying = false;
let currentFrame = 0;
let totalFrames = 0;
let frameTime = 0;
let skeleton = null;
let gridHelper, axesHelper;
function init() {
scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a1a);
camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 10000);
camera.position.set(2, 2, 3);
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
document.getElementById('canvas-container').appendChild(renderer.domElement);
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.target.set(0, 1, 0);
controls.update();
const ambientLight = new THREE.AmbientLight(0xffffff, 0.7);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.5);
directionalLight.position.set(5, 10, 7.5);
scene.add(directionalLight);
gridHelper = new THREE.GridHelper(10, 10, 0x444444, 0x222222);
scene.add(gridHelper);
axesHelper = new THREE.AxesHelper(1);
scene.add(axesHelper);
window.addEventListener('resize', onWindowResize);
window.addEventListener('message', handleMessage);
animate();
window.parent.postMessage({ type: 'VIEWER_READY' }, '*');
}
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
function handleMessage(event) {
const data = event.data;
if (data.type === 'loadBVH') {
loadBVHFromString(data.bvhContent, data.bvhInfo);
} else if (data.type === 'play') {
playAnimation();
} else if (data.type === 'pause') {
pauseAnimation();
} else if (data.type === 'setFrame') {
setFrame(data.frame);
} else if (data.type === 'setSpeed') {
if (mixer) mixer.timeScale = data.speed;
}
}
function loadBVHFromString(bvhContent, bvhInfo) {
if (mixer) {
mixer.stopAllAction();
mixer.uncacheRoot(mixer.getRoot());
mixer = null;
}
const toRemove = [];
scene.traverse((child) => {
if (child.isSkeletonHelper || (child.isMesh && child !== gridHelper && child !== axesHelper) || child.type === 'Bone') {
toRemove.push(child);
}
});
toRemove.forEach(child => {
scene.remove(child);
if (child.geometry) child.geometry.dispose();
if (child.material) {
if (Array.isArray(child.material)) child.material.forEach(m => m.dispose());
else child.material.dispose();
}
});
skeletonHelper = null;
skeleton = null;
const loader = new BVHLoader();
let result;
try {
result = loader.parse(bvhContent);
} catch (error) {
document.getElementById('loading').textContent = 'Error loading BVH: ' + error.message;
return;
}
skeleton = result.skeleton;
clip = result.clip;
scene.add(skeleton.bones[0]);
skeletonHelper = new THREE.SkeletonHelper(skeleton.bones[0]);
skeletonHelper.skeleton = skeleton;
skeletonHelper.material = new THREE.LineBasicMaterial({ color: 0x00ff00, linewidth: 3, depthTest: false });
scene.add(skeletonHelper);
const jointGeometry = new THREE.SphereGeometry(0.02, 8, 8);
const jointMaterial = new THREE.MeshBasicMaterial({ color: 0xff0000, depthTest: false });
skeleton.bones[0].traverse((bone) => {
if (bone.isBone) {
const jointMarker = new THREE.Mesh(jointGeometry, jointMaterial);
bone.add(jointMarker);
}
});
mixer = new THREE.AnimationMixer(skeleton.bones[0]);
currentAction = mixer.clipAction(clip);
currentAction.setEffectiveWeight(1.0);
isPlaying = true;
currentFrame = 0;
totalFrames = Math.floor(clip.duration / (1/30));
if (bvhInfo && bvhInfo.num_frames) totalFrames = bvhInfo.num_frames;
frameTime = clip.duration / totalFrames;
try {
const box = new THREE.Box3().setFromObject(skeleton.bones[0]);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
if (size.lengthSq() < 0.001) {
center.set(0, 1, 0);
size.set(1, 2, 1);
}
const maxDim = Math.max(size.x, size.y, size.z);
const fov = camera.fov * (Math.PI / 180);
let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2));
cameraZ *= 1.5;
camera.position.set(center.x, center.y + size.y/2, center.z + cameraZ);
camera.lookAt(center);
controls.target.copy(center);
controls.update();
} catch (e) {
console.warn("[BVHViewer] Camera framing failed:", e);
}
const infoDiv = document.getElementById('info');
infoDiv.innerHTML = `
<strong>BVH Animation</strong><br>
Bones: ${skeleton.bones.length}<br>
Frames: ${totalFrames}<br>
FPS: ${bvhInfo?.fps || 30}<br>
Duration: ${clip.duration.toFixed(2)}s
`;
infoDiv.style.display = 'block';
document.getElementById('loading').style.display = 'none';
playAnimation();
window.parent.postMessage({ type: 'frameChanged', frame: 0, totalFrames: totalFrames }, '*');
}
function playAnimation() {
if (!currentAction) return;
isPlaying = true;
currentAction.play();
clock.start();
window.parent.postMessage({ type: 'playing' }, '*');
}
function pauseAnimation() {
if (!currentAction) return;
isPlaying = false;
currentAction.paused = true;
window.parent.postMessage({ type: 'paused' }, '*');
}
function setFrame(frame) {
if (!mixer || !currentAction || !clip) return;
currentFrame = Math.max(0, Math.min(frame, totalFrames - 1));
const time = (currentFrame / totalFrames) * clip.duration;
mixer.setTime(time);
currentAction.time = time;
mixer.update(0);
window.parent.postMessage({ type: 'frameChanged', frame: currentFrame, totalFrames: totalFrames }, '*');
}
function animate() {
requestAnimationFrame(animate);
controls.update();
if (mixer && isPlaying) {
const delta = clock.getDelta();
mixer.update(delta);
if (currentAction && clip) {
const progress = currentAction.time / clip.duration;
currentFrame = Math.floor(progress * totalFrames);
if (currentAction.time >= clip.duration - 0.01) {
window.parent.postMessage({ type: 'looped' }, '*');
}
}
}
renderer.render(scene, camera);
}
init();
console.log('[BVH Viewer] Loaded successfully');
</script>
</body>
</html>
+354
View File
@@ -0,0 +1,354 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
html, body { width: 100%; height: 100%; margin: 0; padding: 0; overflow: hidden; background: #1a1a1a; font-family: Arial, sans-serif; }
#container { display: flex; flex-direction: column; width: 100%; height: 100%; }
#canvas-container { flex: 1; position: relative; overflow: hidden; width: 100%; min-height: 0; background: #000; }
#controls {
height: 40px;
flex-shrink: 0;
background: #252525;
display: flex;
align-items: center;
padding: 0 10px;
gap: 10px;
border-top: 1px solid #333;
z-index: 200;
}
.panel-label {
position: absolute;
top: 10px;
color: rgba(255, 255, 255, 0.7);
font-weight: bold;
font-size: 14px;
pointer-events: none;
background: rgba(0,0,0,0.5);
padding: 4px 8px;
border-radius: 4px;
z-index: 10;
}
#label-smpl { left: 10px; }
#label-bvh { left: 50%; margin-left: 10px; }
#loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 16px;
background: rgba(0,0,0,0.8);
padding: 15px 20px;
border-radius: 8px;
z-index: 100;
}
button {
background: #4a9eff;
border: none;
color: white;
border-radius: 4px;
width: 30px;
height: 30px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
}
button:hover { background: #3a8eef; }
button:disabled { background: #444; cursor: default; }
input[type=range] {
flex-grow: 1;
height: 6px;
background: #444;
border-radius: 3px;
outline: none;
}
.frame-counter {
color: #aaa;
font-family: monospace;
font-size: 12px;
min-width: 80px;
text-align: right;
}
</style>
</head>
<body>
<div id="container">
<div id="canvas-container">
<div id="loading">Waiting for data...</div>
<div id="label-smpl" class="panel-label">SMPL (Mesh)</div>
<div id="label-bvh" class="panel-label">BVH (Skeleton)</div>
</div>
<div id="controls">
<button id="play-btn">&#9654;</button>
<input type="range" id="slider" min="0" max="0" value="0" step="1">
<div class="frame-counter" id="frame-display">0 / 0</div>
</div>
</div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { BVHLoader } from 'three/addons/loaders/BVHLoader.js';
let renderer, camera, controls;
let sceneSMPL, sceneBVH;
let smplData = null;
let smplMesh = null;
let bvhHelper = null;
let bvhMixer = null;
let bvhAction = null;
let bvhSkeleton = null;
let isPlaying = false;
let currentFrame = 0;
let totalFrames = 0;
let fps = 30;
let clock = new THREE.Clock();
const slider = document.getElementById('slider');
const frameDisplay = document.getElementById('frame-display');
const playBtn = document.getElementById('play-btn');
function init() {
const container = document.getElementById('canvas-container');
const w = container.clientWidth;
const h = container.clientHeight;
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(w, h);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setScissorTest(true);
container.appendChild(renderer.domElement);
camera = new THREE.PerspectiveCamera(50, w / h, 0.01, 100);
camera.position.set(0, 1.5, 4);
controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 1, 0);
controls.enableDamping = true;
sceneSMPL = new THREE.Scene();
sceneSMPL.background = new THREE.Color(0x111111);
setupSceneCommon(sceneSMPL);
sceneBVH = new THREE.Scene();
sceneBVH.background = new THREE.Color(0x1a1a1a);
setupSceneCommon(sceneBVH);
window.addEventListener('resize', onWindowResize);
window.addEventListener('message', handleMessage);
playBtn.onclick = togglePlay;
slider.oninput = (e) => {
pause();
seek(parseInt(e.target.value));
};
animate();
window.parent.postMessage({ type: 'VIEWER_READY' }, '*');
}
function setupSceneCommon(scene) {
const grid = new THREE.GridHelper(10, 10, 0x444444, 0x222222);
scene.add(grid);
scene.add(new THREE.AxesHelper(0.5));
const dirLight = new THREE.DirectionalLight(0xffffff, 1.5);
dirLight.position.set(2, 5, 5);
scene.add(dirLight);
const ambLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambLight);
}
function onWindowResize() {
const container = document.getElementById('canvas-container');
const w = container.clientWidth;
const h = container.clientHeight;
if (w === 0 || h === 0) return;
camera.aspect = (w * 0.5) / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
}
function togglePlay() {
if (isPlaying) pause(); else play();
}
function play() {
isPlaying = true;
playBtn.innerHTML = "&#9208;";
clock.start();
}
function pause() {
isPlaying = false;
playBtn.innerHTML = "&#9654;";
}
function seek(frame) {
currentFrame = Math.max(0, Math.min(frame, totalFrames - 1));
updateVisuals(currentFrame);
updateUI();
}
function updateUI() {
slider.value = Math.floor(currentFrame);
frameDisplay.textContent = Math.floor(currentFrame) + ' / ' + totalFrames;
}
function updateVisuals(frame) {
const time = frame / fps;
if (smplMesh && smplData) {
const f = Math.min(Math.floor(frame), smplData.numFrames - 1);
const start = f * smplData.numVerts * 3;
const end = start + smplData.numVerts * 3;
const frameVerts = smplData.vertices.subarray(start, end);
smplMesh.geometry.attributes.position.array.set(frameVerts);
smplMesh.geometry.attributes.position.needsUpdate = true;
smplMesh.geometry.computeVertexNormals();
}
if (bvhMixer) {
bvhMixer.setTime(time);
}
}
function animate() {
requestAnimationFrame(animate);
controls.update();
if (isPlaying && totalFrames > 0) {
const delta = clock.getDelta();
currentFrame += delta * fps;
if (currentFrame >= totalFrames) currentFrame = 0;
updateVisuals(currentFrame);
updateUI();
}
renderSplitScreen();
}
function renderSplitScreen() {
const container = document.getElementById('canvas-container');
const w = container.clientWidth;
const h = container.clientHeight;
if (w === 0 || h === 0) return;
renderer.setViewport(0, 0, w / 2, h);
renderer.setScissor(0, 0, w / 2, h);
renderer.render(sceneSMPL, camera);
renderer.setViewport(w / 2, 0, w / 2, h);
renderer.setScissor(w / 2, 0, w / 2, h);
renderer.render(sceneBVH, camera);
}
async function handleMessage(event) {
const data = event.data;
if (data.type === 'loadData') {
await loadAllData(data.smplFilename, data.bvhContent, data.smplMeshApiPath);
}
}
async function loadAllData(smplFilename, bvhContent, smplMeshApiPath) {
const loading = document.getElementById('loading');
loading.style.display = 'block';
loading.textContent = "Loading data...";
pause();
currentFrame = 0;
totalFrames = 0;
try {
if (smplFilename) {
const apiPath = smplMeshApiPath || '/motioncapture/smpl_mesh';
const res = await fetch(apiPath + '?filename=' + smplFilename);
const buf = await res.arrayBuffer();
parseSMPL(buf);
}
if (bvhContent) {
parseBVH(bvhContent);
}
slider.max = totalFrames - 1;
updateVisuals(0);
updateUI();
loading.style.display = 'none';
onWindowResize();
} catch (e) {
loading.textContent = "Error: " + e.message;
}
}
function parseSMPL(buffer) {
const dv = new DataView(buffer);
let offset = 4;
const numFrames = dv.getUint32(offset, true); offset += 4;
const numVerts = dv.getUint32(offset, true); offset += 4;
const numFaces = dv.getUint32(offset, true); offset += 4;
fps = dv.getFloat32(offset, true); offset += 4;
const verts = new Float32Array(buffer, offset, numFrames * numVerts * 3);
offset += numFrames * numVerts * 3 * 4;
const faces = new Uint32Array(buffer, offset, numFaces * 3);
smplData = { vertices: verts, faces: faces, numFrames, numVerts };
if (smplMesh) sceneSMPL.remove(smplMesh);
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(verts.subarray(0, numVerts*3), 3));
geo.setIndex(new THREE.BufferAttribute(faces, 1));
geo.computeVertexNormals();
const mat = new THREE.MeshPhongMaterial({ color: 0x4a9eff, flatShading: false, shininess: 30, side: THREE.DoubleSide });
smplMesh = new THREE.Mesh(geo, mat);
sceneSMPL.add(smplMesh);
totalFrames = Math.max(totalFrames, numFrames);
}
function parseBVH(content) {
const loader = new BVHLoader();
const result = loader.parse(content);
if (bvhHelper) sceneBVH.remove(bvhHelper);
if (bvhSkeleton) sceneBVH.remove(bvhSkeleton);
bvhSkeleton = result.skeleton.bones[0];
bvhHelper = new THREE.SkeletonHelper(bvhSkeleton);
bvhHelper.material.color.setHex(0x00ff00);
bvhHelper.material.linewidth = 3;
sceneBVH.add(bvhSkeleton);
sceneBVH.add(bvhHelper);
bvhMixer = new THREE.AnimationMixer(bvhSkeleton);
bvhAction = bvhMixer.clipAction(result.clip);
bvhAction.play();
const bvhFrames = Math.floor(result.clip.duration * fps);
totalFrames = Math.max(totalFrames, bvhFrames);
}
init();
console.log('[Compare SMPL BVH Viewer] Loaded successfully');
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+316
View File
@@ -0,0 +1,316 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { margin: 0; overflow: hidden; background: #1a1a1a; font-family: Arial, sans-serif; }
#canvas-container { width: 100%; height: 100%; }
#loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 18px;
z-index: 50;
}
</style>
</head>
<body>
<div id="loading">Loading animated FBX...</div>
<div id="canvas-container"></div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';
let scene, camera, renderer, controls;
let currentModel = null;
let skeletonHelper = null;
let modelBoundingBox = null;
let mixer = null;
let currentAction = null;
let animations = [];
let clock = new THREE.Clock();
let isPlaying = false;
let showSkeleton = true;
let showMesh = true;
function init() {
scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a1a);
camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 10000);
camera.position.set(0, 1.5, 3);
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.shadowMap.enabled = true;
document.getElementById('canvas-container').appendChild(renderer.domElement);
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(5, 10, 7.5);
directionalLight.castShadow = true;
scene.add(directionalLight);
const gridHelper = new THREE.GridHelper(10, 10, 0x444444, 0x222222);
scene.add(gridHelper);
const axesHelper = new THREE.AxesHelper(1);
scene.add(axesHelper);
animate();
window.addEventListener('resize', onWindowResize);
window.addEventListener('message', handleMessage);
window.parent.postMessage({ type: 'VIEWER_READY' }, '*');
}
function handleMessage(event) {
const { type, ...data } = event.data;
switch(type) {
case 'LOAD_FBX': loadFBX(data.path); break;
case 'PLAY_PAUSE': togglePlayPause(); break;
case 'RESET': resetAnimation(); break;
case 'SET_TIMELINE': setTimeline(data.progress); break;
case 'SET_SPEED': setSpeed(data.speed); break;
case 'SET_LOOP': setLoop(data.loop); break;
case 'CHANGE_ANIMATION': playAnimation(data.index); break;
case 'TOGGLE_SKELETON': toggleSkeleton(data.visible); break;
case 'TOGGLE_MESH': toggleMesh(data.visible); break;
case 'TOGGLE_XRAY': toggleXRay(data.xray); break;
case 'RESET_CAMERA': resetCamera(); break;
}
}
function loadFBX(path) {
const loader = new FBXLoader();
const url = (path.startsWith('http') || path.startsWith('blob:')) ? path : window.parent.location.origin + path;
loader.load(url, (fbx) => {
if (currentModel) scene.remove(currentModel);
if (skeletonHelper) scene.remove(skeletonHelper);
if (mixer) mixer.stopAllAction();
currentModel = fbx;
scene.add(fbx);
fbx.traverse((child) => {
if (child.isMesh) {
child.castShadow = true;
child.receiveShadow = true;
child.visible = showMesh;
}
});
let skinnedMesh = null;
fbx.traverse((child) => {
if (child.isSkinnedMesh && !skinnedMesh) skinnedMesh = child;
});
if (skinnedMesh && skinnedMesh.skeleton) {
skeletonHelper = new THREE.SkeletonHelper(fbx);
skeletonHelper.material.linewidth = 2;
skeletonHelper.visible = showSkeleton;
scene.add(skeletonHelper);
}
animations = fbx.animations || [];
if (animations.length > 0) {
setupAnimations();
} else {
notifyParent({ type: 'NO_ANIMATIONS' });
}
centerModel(fbx);
document.getElementById('loading').style.display = 'none';
}, (xhr) => {
const percent = (xhr.loaded / xhr.total * 100).toFixed(0);
document.getElementById('loading').textContent = 'Loading... ' + percent + '%';
}, (error) => {
document.getElementById('loading').textContent = 'Error loading FBX';
});
}
function setupAnimations() {
mixer = new THREE.AnimationMixer(currentModel);
const animationNames = animations.map((clip, i) => ({
index: i,
name: clip.name || ('Animation ' + (i + 1)),
duration: clip.duration
}));
notifyParent({ type: 'ANIMATIONS_LOADED', animations: animationNames });
playAnimation(0);
}
function playAnimation(index) {
if (mixer && animations[index]) {
if (currentAction) currentAction.stop();
currentAction = mixer.clipAction(animations[index]);
currentAction.setLoop(THREE.LoopRepeat);
currentAction.timeScale = 1.0;
currentAction.play();
isPlaying = true;
notifyParent({ type: 'ANIMATION_CHANGED', index: index, duration: currentAction.getClip().duration });
}
}
function togglePlayPause() {
if (!currentAction) return;
if (isPlaying) {
currentAction.paused = true;
isPlaying = false;
} else {
currentAction.paused = false;
isPlaying = true;
}
notifyParent({ type: 'PLAY_STATE_CHANGED', isPlaying });
}
function resetAnimation() {
if (currentAction) {
currentAction.reset();
currentAction.play();
isPlaying = true;
notifyParent({ type: 'PLAY_STATE_CHANGED', isPlaying: true });
}
}
function setTimeline(progress) {
if (currentAction) {
const duration = currentAction.getClip().duration;
currentAction.time = progress * duration;
}
}
function setSpeed(speed) {
if (currentAction) currentAction.timeScale = speed;
}
function setLoop(loop) {
if (currentAction) currentAction.setLoop(loop ? THREE.LoopRepeat : THREE.LoopOnce);
}
function toggleSkeleton(visible) {
showSkeleton = visible;
if (skeletonHelper) skeletonHelper.visible = visible;
}
function toggleMesh(visible) {
showMesh = visible;
if (currentModel) {
currentModel.traverse((child) => {
if (child.isMesh) child.visible = visible;
});
}
}
function toggleXRay(xray) {
if (skeletonHelper) {
skeletonHelper.material.depthTest = !xray;
skeletonHelper.material.depthWrite = !xray;
}
}
function centerModel(model) {
const box = new THREE.Box3().setFromObject(model);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
model.position.sub(center);
const maxDim = Math.max(size.x, size.y, size.z);
const fov = camera.fov * (Math.PI / 180);
let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2));
cameraZ *= 1.5;
camera.position.set(0, size.y / 2, cameraZ);
camera.lookAt(0, size.y / 2, 0);
controls.target.set(0, size.y / 2, 0);
controls.update();
modelBoundingBox = box;
}
function resetCamera() {
if (modelBoundingBox) {
const size = modelBoundingBox.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const fov = camera.fov * (Math.PI / 180);
let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2));
cameraZ *= 1.5;
camera.position.set(0, size.y / 2, cameraZ);
camera.lookAt(0, size.y / 2, 0);
controls.target.set(0, size.y / 2, 0);
controls.update();
}
}
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
function notifyParent(data) {
window.parent.postMessage(data, '*');
}
let lastTimeUpdate = 0;
function sendTimeUpdate() {
if (!currentAction) return;
const currentTime = currentAction.time;
const duration = currentAction.getClip().duration;
const progress = (currentTime / duration) * 100;
const fps = 30;
const currentFrame = Math.floor(currentTime * fps);
const totalFrames = Math.floor(duration * fps);
const now = Date.now();
if (now - lastTimeUpdate > 50) {
notifyParent({
type: 'TIME_UPDATE',
time: currentTime,
duration: duration,
progress: progress,
frame: currentFrame,
totalFrames: totalFrames
});
lastTimeUpdate = now;
}
}
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
if (mixer && isPlaying && !currentAction?.paused) {
mixer.update(delta);
sendTimeUpdate();
}
controls.update();
renderer.render(scene, camera);
}
init();
console.log('[FBX Animation Viewer] Loaded successfully');
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+958
View File
@@ -0,0 +1,958 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GeomPack Gaussian Splat Viewer</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #1a1a1a;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
flex-direction: column;
height: 100vh;
}
#viewerContainer {
position: relative;
flex: 1;
min-height: 0;
}
canvas {
width: 100%;
height: 100%;
display: block;
}
.error {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
color: #ff6b6b;
padding: 20px;
max-width: 80%;
}
.error.hidden {
display: none;
}
#controlsBar {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
background: #252525;
padding: 8px 12px;
border-top: 1px solid #333;
flex-shrink: 0;
}
#controlsBar button {
background: #444;
border: none;
color: #fff;
padding: 4px 10px;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
}
#controlsBar button:hover {
background: #555;
}
.scale-control {
display: flex;
align-items: center;
gap: 6px;
}
.scale-control span {
color: #888;
font-size: 11px;
}
.scale-control input[type="number"] {
width: 60px;
background: #333;
border: 1px solid #555;
color: #fff;
font-size: 11px;
padding: 4px 6px;
border-radius: 3px;
}
.info-panel {
position: absolute;
top: 8px;
left: 8px;
background: rgba(0,0,0,0.7);
padding: 8px 12px;
border-radius: 4px;
color: #aaa;
font-size: 11px;
font-family: monospace;
z-index: 50;
}
.info-panel.hidden {
display: none;
}
</style>
</head>
<body>
<div id="viewerContainer">
<canvas id="canvas"></canvas>
<div id="error" class="error hidden"></div>
<div id="infoPanel" class="info-panel hidden">
<div id="infoContent"></div>
</div>
</div>
<div id="controlsBar">
<div class="scale-control">
<span>Scale:</span>
<input type="number" id="gaussianScaleValue" min="0.01" max="100" step="0.1" value="1.0" title="Gaussian scale multiplier">
</div>
<button id="resetCamera" title="Reset camera view">Reset View</button>
<button id="screenshot" title="Take screenshot">Screenshot</button>
<button id="copyImage" title="Copy image to clipboard">Copy Image</button>
</div>
<!-- Load bundled gsplat.js -->
<script src="./js/gsplat-bundle.js"></script>
<script type="module">
// Use the globally exported GSPLAT
const SPLAT = window.GSPLAT;
// DOM elements
const canvas = document.getElementById('canvas');
const errorEl = document.getElementById('error');
const infoPanel = document.getElementById('infoPanel');
const infoContent = document.getElementById('infoContent');
// gsplat.js components
let scene = null;
let camera = null;
let renderer = null;
let controls = null;
let animationId = null;
let currentSplat = null;
let originalScales = null; // Store original scales for dynamic adjustment
// Default focal length
const DEFAULT_FOCAL_LENGTH = 30;
// Initialize the viewer
function initViewer() {
try {
scene = new SPLAT.Scene();
camera = new SPLAT.Camera();
renderer = new SPLAT.WebGLRenderer(canvas);
controls = new SPLAT.OrbitControls(camera, canvas);
// Set default focal length
camera.data.fx = DEFAULT_FOCAL_LENGTH;
camera.data.fy = DEFAULT_FOCAL_LENGTH;
// Handle resize
const resize = () => {
// Only resize renderer if resolution is NOT locked
// If locked, the canvas resolution stays fixed (e.g. at 2K),
// and CSS handles the visual fitting to container.
if (!isResolutionLocked) {
renderer.setSize(canvas.clientWidth, canvas.clientHeight);
}
};
window.addEventListener('resize', resize);
resize();
// Start render loop
const frame = () => {
controls.update();
renderer.render(scene, camera);
animationId = requestAnimationFrame(frame);
};
frame();
console.log('[GaussianViewer] Initialized successfully');
} catch (err) {
console.error('[GaussianViewer] Init error:', err);
showError('Failed to initialize WebGL viewer: ' + err.message);
}
}
// Show error
function showError(message) {
errorEl.textContent = message;
errorEl.classList.remove('hidden');
}
// Store initial camera params for reset
let initialCameraData = null;
// Resolution lock state
let isResolutionLocked = false;
// Scale compensation: when focal length is scaled down for canvas size,
// gaussian scales need to be multiplied by this factor to maintain correct screen-space size
let gaussianScaleCompensation = 1.0;
// Resume the animation loop (used after pausing for screenshot/clipboard)
function resumeAnimationLoop() {
const frame = () => {
controls.update();
renderer.render(scene, camera);
animationId = requestAnimationFrame(frame);
};
frame();
}
// Set camera from extrinsics and intrinsics
// extrinsics: 4x4 matrix (identity for SHARP = camera at origin looking down +Z)
// intrinsics: 3x3 matrix [[fx, 0, cx], [0, fy, cy], [0, 0, 1]]
// splat: the loaded Gaussian splat object (optional, used for bounds)
function setCameraFromExtrinsics(extrinsics, intrinsics, splat) {
if (!camera || !controls) return;
console.log('[GaussianViewer] Setting camera from extrinsics/intrinsics');
console.log('[GaussianViewer] Extrinsics:', JSON.stringify(extrinsics));
console.log('[GaussianViewer] Intrinsics:', JSON.stringify(intrinsics));
// Parse extrinsics to get camera position
// For a 4x4 extrinsics matrix [R|t], camera position in world space is -R^T * t
// But for identity matrix, camera is simply at origin
let camPosX = 0, camPosY = 0, camPosZ = 0;
if (extrinsics && Array.isArray(extrinsics) && extrinsics.length === 4) {
// Extract rotation and translation from 4x4 extrinsics
const R = [
[extrinsics[0][0], extrinsics[0][1], extrinsics[0][2]],
[extrinsics[1][0], extrinsics[1][1], extrinsics[1][2]],
[extrinsics[2][0], extrinsics[2][1], extrinsics[2][2]]
];
const t = [extrinsics[0][3], extrinsics[1][3], extrinsics[2][3]];
// Camera position = -R^T * t
camPosX = -(R[0][0] * t[0] + R[1][0] * t[1] + R[2][0] * t[2]);
camPosY = -(R[0][1] * t[0] + R[1][1] * t[1] + R[2][1] * t[2]);
camPosZ = -(R[0][2] * t[0] + R[1][2] * t[1] + R[2][2] * t[2]);
console.log('[GaussianViewer] Computed camera position from extrinsics:', camPosX, camPosY, camPosZ);
}
// Calculate FOV and set camera focal length from intrinsics
// intrinsics[0][0] = fx (focal length in pixels)
// intrinsics[1][1] = fy (focal length in pixels)
// intrinsics[1][2] = cy (principal point y = image_height / 2)
let targetZ = 2; // default
if (intrinsics && Array.isArray(intrinsics) && intrinsics.length >= 2) {
const fx = intrinsics[0][0];
const fy = intrinsics[1][1];
const cx = intrinsics[0][2];
const cy = intrinsics[1][2];
const imageWidth = cx * 2; // cx is typically image_width / 2
const imageHeight = cy * 2; // cy is typically image_height / 2
console.log('[GaussianViewer] Intrinsics: fx=', fx, 'fy=', fy, 'cx=', cx, 'cy=', cy);
console.log('[GaussianViewer] Image dimensions (from intrinsics):', imageWidth, 'x', imageHeight);
// Determine if we should lock resolution to native image size
// We lock resolution if intrinsics are provided to ensure "WYSIWYG" with high-res output
isResolutionLocked = true;
// Get current container size for scale compensation calculation
const containerWidth = canvas.clientWidth || 512;
const containerHeight = canvas.clientHeight || 512;
// Lock renderer size to NATIVE image resolution
console.log(`[GaussianViewer] Locking resolution to native: ${imageWidth}x${imageHeight}`);
renderer.setSize(imageWidth, imageHeight);
// Ensure CSS keeps it fitted to container
canvas.style.width = '100%';
canvas.style.height = '100%';
// Use NATIVE focal length (no scaling down)
camera.data.fx = fx;
camera.data.fy = fy;
// IMPORTANT: Calculate scale compensation
// gsplat.js renders points based on their world space size projected to screen space.
// When we view a 2048x2048 image in a 512x512 container, we are effectively zooming out.
// To make the points appear as "solid" as they do in the original training view,
// we need to compensate for this difference.
// Empirical observation suggests we need a stronger compensation than just the ratio.
// If user says Scale ~14 is good when ratio is ~4 (2048/512), then compensation might be squared ratio?
// Or maybe the points from this trainer are just naturally small.
// Let's stick to linear ratio for now but allow a base multiplier.
// For now, let's trust the ratio but maybe add a boost factor if needed.
// User reported needing ~14x scale. If image is 2048 and container 512, ratio is 4.
// 14 / 4 = 3.5. So maybe there's an inherent factor of 3-4 missing?
// Actually, let's just apply the ratio logic strictly first:
// If I render at 2048px width, a point of size 1 world unit covers X pixels.
// If I render at 512px width, it covers X/4 pixels.
// But visually, if I look at the 2048px image scaled down to 512px on my screen (via CSS),
// the point covers X pixels of the *buffer*, which is X/4 pixels on *screen*.
// Wait, if we render at high res, the points are physically "correct" size in pixels for that resolution.
// The issue is likely that "correct" size is often too sparse for point clouds that rely on splatting overlap.
// Let's use the ratio, but ensure we aren't double counting.
gaussianScaleCompensation = imageWidth / containerWidth;
// Add an extra boost because often raw splats are too small without the "dilation" effect of low-res rendering
// When you render low-res, rasterization snaps to pixels, effectively making small points relatively larger.
// High-res rendering loses this "free aliasing coverage".
// Let's add a heuristic boost factor.
// User said 14x was good. 14 / (2048/512) = 3.5.
// Let's try boosting the compensation significantly.
// gaussianScaleCompensation *= 3.0; // Boost factor
// Actually, let's change how we calculate this.
// If the user wants "WYSIWYG" relative to the preview size:
// We want the point to cover the same *percentage* of the screen width.
// PointRadius / ImageWidth = Constant.
// If we increase ImageWidth by factor S, PointRadius must increase by factor S.
// This is what gaussianScaleCompensation = imageWidth / containerWidth does.
// However, the user reports it's still sparse. This implies the *base* size is too small.
// Maybe the "preview" logic in other viewers implicitly boosts size?
// Or maybe the training output relies on a specific focal length?
// Let's force a minimum compensation to avoid sparsity.
// And let's default the UI scale slider to something higher if we detect high-res?
// No, better to fix the compensation calculation.
// Let's try applying a stronger multiplier.
// If resolution is locked, we are in "High Res Mode".
// Let's scale up by the ratio AND a bit more.
gaussianScaleCompensation = (imageWidth / containerWidth) * 3.0; // 3.0 is a heuristic "density boost"
console.log('[GaussianViewer] Native resolution:', imageWidth, 'x', imageHeight);
console.log('[GaussianViewer] Container size:', containerWidth, 'x', containerHeight);
console.log('[GaussianViewer] Gaussian scale compensation (with 3x boost):', gaussianScaleCompensation.toFixed(2), 'x');
// Calculate vertical FOV in radians for logging
const fovY = 2 * Math.atan(imageHeight / (2 * fy));
const fovYDeg = fovY * 180 / Math.PI;
console.log('[GaussianViewer] Calculated FOV:', fovYDeg, 'degrees');
// For SHARP, the scene is in front of the camera
// Use focal length to estimate a reasonable target distance
// The disparity range in metadata can help estimate scene depth
// For now, use a simple heuristic based on typical SHARP output
targetZ = Math.max(1, fy / imageHeight * 2);
console.log('[GaussianViewer] Target Z distance:', targetZ);
}
// Determine the target position (what the camera looks at)
// For the INITIAL view (matching original image), look STRAIGHT AHEAD along Z axis
// NOT at the scene center - that would tilt the camera
let targetX = 0, targetY = 0;
let orbitZ = targetZ; // For orbiting, use a closer point
if (splat && splat.bounds) {
const center = splat.bounds.center();
const size = splat.bounds.size();
// Use scene center Z for the view direction
targetZ = center.z;
// For orbit center, use distance to closest object + 50%
// Camera is at origin, so minZ is the distance to closest point
const minZ = center.z - size.z / 2;
orbitZ = minZ * 1.5; // 50% past the closest point
console.log('[GaussianViewer] Scene center:', center.x, -center.y, center.z);
console.log('[GaussianViewer] Closest point Z:', minZ.toFixed(1));
console.log('[GaussianViewer] Orbit center Z:', orbitZ.toFixed(1), '(closest + 50%)');
}
// SHARP uses Y-down coordinate system, so flip camera Y as well
camPosY = -camPosY;
// For SHARP: camera is at origin, scene is at positive Z
// The orbit controls work by having the camera orbit around a target point.
// To get the "original image view", we set camera at the computed position
// and look at the scene center.
const camPos = new SPLAT.Vector3(camPosX, camPosY, camPosZ);
// Use orbitZ for the target - closer to camera for better orbit feel
const targetPos = new SPLAT.Vector3(targetX, targetY, orbitZ);
// Store for reset
initialCameraData = {
position: camPos,
target: targetPos,
fx: camera.data.fx,
fy: camera.data.fy
};
// Set camera position - IMPORTANT: must set camera.position, not camera.data.position
// camera.position is used by OrbitControls
// camera.data.position is used for rendering
camera.position.x = camPosX;
camera.position.y = camPosY;
camera.position.z = camPosZ;
// Update orbit controls to orbit around the target point
// This recalculates the internal orbit state from current camera.position
controls.setCameraTarget(targetPos);
console.log('[GaussianViewer] Camera set to position:', camPosX, camPosY, camPosZ);
console.log('[GaussianViewer] Looking at target:', targetX, targetY, targetZ);
console.log('[GaussianViewer] This should match the original input image view');
}
// Load a PLY file from ArrayBuffer data
async function loadPLYFromData(arrayBuffer, filename, extrinsics, intrinsics) {
try {
// Reset lock state
isResolutionLocked = false;
// Clear ALL existing objects from scene
if (scene.objects && scene.objects.length > 0) {
console.log('[GaussianViewer] Clearing', scene.objects.length, 'existing objects from scene');
// Remove all objects (iterate backwards to avoid index issues)
while (scene.objects.length > 0) {
scene.removeObject(scene.objects[0]);
}
}
currentSplat = null;
// Pre-calculate scale compensation from intrinsics BEFORE loading
// This ensures gaussian scales are correct from the start
gaussianScaleCompensation = 1.0; // Default if no intrinsics
if (intrinsics && Array.isArray(intrinsics) && intrinsics.length >= 2) {
const cx = intrinsics[0][2];
const imageWidth = cx * 2;
const containerWidth = canvas.clientWidth || 512;
// Base compensation: Ratio of Native / Display
let ratio = imageWidth / containerWidth;
// Heuristic: Add a "Density Boost" for high-res rendering.
// High-res rendering tends to make points look sparse because they don't overlap as much
// as they do in lower resolutions (due to sub-pixel vs pixel rasterization effects).
// We multiply by an extra factor to simulate the "fullness" of a lower-res view.
// Based on user feedback, a factor of ~10.0 (total) is often needed for ideal density.
const DENSITY_BOOST = 10.0;
gaussianScaleCompensation = ratio * DENSITY_BOOST;
console.log('[GaussianViewer] Pre-calculated scale compensation:', gaussianScaleCompensation.toFixed(2), 'x');
console.log('[GaussianViewer] Ratio:', ratio.toFixed(2), 'Density Boost:', DENSITY_BOOST);
console.log('[GaussianViewer] (Image:', imageWidth, 'px / Container:', containerWidth, 'px)');
}
console.log('[GaussianViewer] Loading from data, size:', arrayBuffer.byteLength);
// Create a blob URL from the data
const blob = new Blob([arrayBuffer], { type: 'application/octet-stream' });
const blobUrl = URL.createObjectURL(blob);
// Load the PLY file from blob URL using PLYLoader
await SPLAT.PLYLoader.LoadAsync(blobUrl, scene);
// Clean up blob URL
URL.revokeObjectURL(blobUrl);
// Get the loaded splat (last object in scene)
if (scene.objects && scene.objects.length > 0) {
currentSplat = scene.objects[scene.objects.length - 1];
// Log bounds info for debugging
if (currentSplat.bounds) {
const center = currentSplat.bounds.center();
const size = currentSplat.bounds.size();
console.log('[GaussianViewer] Scene bounds center:', center.x, center.y, center.z);
console.log('[GaussianViewer] Scene bounds size:', size.x, size.y, size.z);
}
// Store original scales and log statistics
if (currentSplat.data && currentSplat.data.scales) {
const scales = currentSplat.data.scales;
const numGaussians = scales.length / 3;
let minScale = Infinity, maxScale = -Infinity, sumScale = 0;
// Store a copy of original scales (BEFORE compensation) for dynamic adjustment
// The stored scales include the compensation factor so user scale=1 shows correct size
originalScales = new Float32Array(scales.length);
for (let i = 0; i < scales.length; i++) {
// Apply compensation: gaussians need to be larger when focal length is scaled down
originalScales[i] = scales[i] * gaussianScaleCompensation;
}
console.log('[GaussianViewer] Stored compensated scales (factor:', gaussianScaleCompensation.toFixed(2), 'x)');
// Apply compensated scales immediately
for (let i = 0; i < scales.length; i++) {
scales[i] = originalScales[i];
}
// Mark data as changed so gsplat.js re-uploads to GPU
currentSplat.data.changed = true;
for (let i = 0; i < scales.length; i++) {
const s = scales[i];
minScale = Math.min(minScale, s);
maxScale = Math.max(maxScale, s);
sumScale += s;
}
const avgScale = sumScale / scales.length;
console.log('[GaussianViewer] === GAUSSIAN SCALE DEBUG (after compensation) ===');
console.log('[GaussianViewer] Number of Gaussians:', numGaussians);
console.log('[GaussianViewer] Compensated scale min:', minScale.toFixed(6));
console.log('[GaussianViewer] Compensated scale max:', maxScale.toFixed(6));
console.log('[GaussianViewer] Compensated scale avg:', avgScale.toFixed(6));
console.log('[GaussianViewer] =================================================');
} else {
console.log('[GaussianViewer] No scales data found on splat object');
console.log('[GaussianViewer] Splat properties:', Object.keys(currentSplat));
if (currentSplat.data) {
console.log('[GaussianViewer] Splat.data properties:', Object.keys(currentSplat.data));
}
originalScales = null;
}
// Reset scale when loading new splat
scaleInput.value = 1.0;
currentScale = 1.0;
}
// Set camera from extrinsics and intrinsics if provided
if (extrinsics || intrinsics) {
setCameraFromExtrinsics(extrinsics, intrinsics, currentSplat);
if (controls) controls.update();
}
// Show info panel
infoPanel.classList.remove('hidden');
infoContent.innerHTML = `<span style="color:#6cc;">Gaussian Splat Loaded</span><br><span style="color:#888;">${filename}</span>`;
// Notify parent
window.parent.postMessage({
type: 'MESH_LOADED',
error: null,
timestamp: Date.now()
}, '*');
console.log('[GaussianViewer] Loaded successfully');
} catch (err) {
console.error('[GaussianViewer] Load error:', err);
showError('Failed to load PLY: ' + err.message);
window.parent.postMessage({
type: 'MESH_ERROR',
error: err.message,
timestamp: Date.now()
}, '*');
}
}
async function loadPLYFromURL(url, filename, extrinsics, intrinsics) {
try {
// Reset lock state
isResolutionLocked = false;
// Clear ALL existing objects from scene
if (scene.objects && scene.objects.length > 0) {
console.log('[GaussianViewer] Clearing', scene.objects.length, 'existing objects from scene');
while (scene.objects.length > 0) {
scene.removeObject(scene.objects[0]);
}
}
currentSplat = null;
// Pre-calculate scale compensation from intrinsics BEFORE loading
gaussianScaleCompensation = 1.0;
if (intrinsics && Array.isArray(intrinsics) && intrinsics.length >= 2) {
const cx = intrinsics[0][2];
const imageWidth = cx * 2;
const containerWidth = canvas.clientWidth || 512;
let ratio = imageWidth / containerWidth;
const DENSITY_BOOST = 10.0;
gaussianScaleCompensation = ratio * DENSITY_BOOST;
console.log('[GaussianViewer] Pre-calculated scale compensation:', gaussianScaleCompensation.toFixed(2), 'x');
}
console.log('[GaussianViewer] Loading from URL:', url);
// Load PLY directly from URL — gsplat.js streams it
await SPLAT.PLYLoader.LoadAsync(url, scene);
// Get the loaded splat (last object in scene)
if (scene.objects && scene.objects.length > 0) {
currentSplat = scene.objects[scene.objects.length - 1];
if (currentSplat.bounds) {
const center = currentSplat.bounds.center();
const size = currentSplat.bounds.size();
console.log('[GaussianViewer] Scene bounds center:', center.x, center.y, center.z);
console.log('[GaussianViewer] Scene bounds size:', size.x, size.y, size.z);
}
// Store original scales and apply compensation
if (currentSplat.data && currentSplat.data.scales) {
const scales = currentSplat.data.scales;
const numGaussians = scales.length / 3;
originalScales = new Float32Array(scales.length);
for (let i = 0; i < scales.length; i++) {
originalScales[i] = scales[i] * gaussianScaleCompensation;
}
for (let i = 0; i < scales.length; i++) {
scales[i] = originalScales[i];
}
currentSplat.data.changed = true;
console.log('[GaussianViewer] Applied scale compensation:', gaussianScaleCompensation.toFixed(2), 'x,', numGaussians, 'gaussians');
} else {
originalScales = null;
}
scaleInput.value = 1.0;
currentScale = 1.0;
}
// Set camera from extrinsics and intrinsics if provided
if (extrinsics || intrinsics) {
setCameraFromExtrinsics(extrinsics, intrinsics, currentSplat);
if (controls) controls.update();
}
infoPanel.classList.remove('hidden');
infoContent.innerHTML = `<span style="color:#6cc;">Gaussian Splat Loaded</span><br><span style="color:#888;">${filename}</span>`;
window.parent.postMessage({
type: 'MESH_LOADED',
error: null,
timestamp: Date.now()
}, '*');
console.log('[GaussianViewer] Loaded successfully from URL');
} catch (err) {
console.error('[GaussianViewer] Load error:', err);
showError('Failed to load PLY: ' + err.message);
window.parent.postMessage({
type: 'MESH_ERROR',
error: err.message,
timestamp: Date.now()
}, '*');
}
}
// Reset camera to initial position
function resetCamera() {
if (camera && controls) {
if (initialCameraData) {
// Set camera.position (used by OrbitControls), not camera.data.position
const pos = initialCameraData.position;
camera.position.x = pos.x;
camera.position.y = pos.y;
camera.position.z = pos.z;
// Restore focal lengths
if (initialCameraData.fx) {
camera.data.fx = initialCameraData.fx;
}
if (initialCameraData.fy) {
camera.data.fy = initialCameraData.fy;
}
// Set the target - this recalculates orbit state from current camera.position
if (initialCameraData.target) {
controls.setCameraTarget(initialCameraData.target);
}
console.log('[GaussianViewer] Reset camera to position:', pos.x, pos.y, pos.z);
console.log('[GaussianViewer] Reset camera target:', initialCameraData.target?.x, initialCameraData.target?.y, initialCameraData.target?.z);
} else {
// Default fallback
camera.position.x = 0;
camera.position.y = 0;
camera.position.z = 5;
console.log('[GaussianViewer] Reset camera to default position (0, 0, 5)');
}
controls.update();
}
}
// Helper to draw black background behind transparent canvas for screenshot
// This solves the issue where transparent parts of the gaussian render appear weirdly when pasted
// or viewed in tools that don't handle premultiplied alpha correctly, or simply when the
// viewer background (CSS) was providing the black color but the canvas itself was transparent.
function getCanvasWithBlackBackground(sourceCanvas) {
const tempCanvas = document.createElement('canvas');
tempCanvas.width = sourceCanvas.width;
tempCanvas.height = sourceCanvas.height;
const ctx = tempCanvas.getContext('2d');
// Fill with black first
ctx.fillStyle = '#000000';
ctx.fillRect(0, 0, tempCanvas.width, tempCanvas.height);
// Draw original canvas on top
ctx.drawImage(sourceCanvas, 0, 0);
return tempCanvas;
}
// Take screenshot directly from canvas
// Avoiding captureStream() because it can introduce artifacts or capture inter-frame states
// Since we are now rendering at full resolution (WYSIWYG), toDataURL is the most accurate representation
async function takeScreenshot() {
if (!renderer || !renderer.canvas) return;
try {
const canvas = renderer.canvas;
const width = canvas.width;
const height = canvas.height;
// Pause the animation loop to ensure we capture a stable frame
if (animationId) cancelAnimationFrame(animationId);
// Force a single synchronous render to ensure the buffer is populated exactly as expected
if (scene && camera) {
controls.update();
renderer.render(scene, camera);
}
console.log('[GaussianViewer] Capturing screenshot via toDataURL:', width, 'x', height);
// Composite with black background to ensure consistency with preview
// The preview looks black because of CSS background, but the canvas might be transparent
const finalCanvas = getCanvasWithBlackBackground(canvas);
const dataUrl = finalCanvas.toDataURL('image/png');
window.parent.postMessage({
type: 'SCREENSHOT',
image: dataUrl,
timestamp: Date.now()
}, '*');
resumeAnimationLoop();
console.log('[GaussianViewer] Screenshot captured successfully');
} catch (err) {
console.error('[GaussianViewer] Screenshot error:', err);
resumeAnimationLoop();
}
}
// Copy image to clipboard directly from canvas
async function copyImageToClipboard() {
if (!renderer || !renderer.canvas) return;
try {
const canvas = renderer.canvas;
const width = canvas.width;
const height = canvas.height;
// Pause the animation loop
if (animationId) cancelAnimationFrame(animationId);
// Force a single synchronous render
if (scene && camera) {
controls.update();
renderer.render(scene, camera);
}
console.log('[GaussianViewer] Copying to clipboard via toBlob:', width, 'x', height);
// Composite with black background
const finalCanvas = getCanvasWithBlackBackground(canvas);
// Direct blob creation from composited canvas
const blob = await canvasToBlob(finalCanvas);
try {
await navigator.clipboard.write([
new ClipboardItem({ 'image/png': blob })
]);
console.log('[GaussianViewer] Image copied to clipboard:', width, 'x', height);
window.parent.postMessage({
type: 'COPY_IMAGE',
success: true,
timestamp: Date.now()
}, '*');
} catch (clipboardError) {
console.error('[GaussianViewer] Clipboard write failed:', clipboardError);
window.parent.postMessage({
type: 'COPY_IMAGE',
success: false,
error: 'Clipboard write failed: ' + clipboardError.message,
timestamp: Date.now()
}, '*');
}
resumeAnimationLoop();
} catch (err) {
console.error('[GaussianViewer] Copy to clipboard error:', err);
resumeAnimationLoop();
window.parent.postMessage({
type: 'COPY_IMAGE',
success: false,
error: err.message,
timestamp: Date.now()
}, '*');
}
}
// Helper function to convert canvas to blob
function canvasToBlob(canvas) {
return new Promise((resolve, reject) => {
canvas.toBlob(blob => {
if (blob) {
resolve(blob);
} else {
reject(new Error('Canvas toBlob failed'));
}
}, 'image/png');
});
}
// Gaussian scale control
const scaleInput = document.getElementById('gaussianScaleValue');
let currentScale = 1.0;
let scaleUpdatePending = false;
let lastScaleValue = 1.0;
function updateGaussianScale(newScale) {
newScale = Math.max(0.01, Math.min(100, parseFloat(newScale) || 1.0));
currentScale = newScale;
// Update input
scaleInput.value = newScale;
// Apply scale to current splat by modifying the underlying scale data
// gsplat.js applies Math.exp() during PLY loading, so data.scales contains LINEAR values
// To multiply visual size by factor f, we multiply the scale values directly
if (currentSplat && currentSplat.data && currentSplat.data.scales && originalScales) {
console.log('[GaussianViewer] Updating gaussian scales with factor:', newScale);
const scales = currentSplat.data.scales;
// Apply scale factor by multiplying (scales are in linear space)
for (let i = 0; i < scales.length; i++) {
scales[i] = originalScales[i] * newScale;
}
// Mark data as changed to trigger GPU re-upload
// gsplat.js checks data.changed and data.detached
currentSplat.data.changed = true;
currentSplat.data.detached = false;
console.log('[GaussianViewer] Set data.changed=true, data.detached=false');
// Get the render data (could be _renderData or renderData)
const renderData = renderer._renderData || renderer.renderData;
if (renderData) {
console.log('[GaussianViewer] Found renderData, keys:', Object.keys(renderData).join(', '));
// Mark the splat as dirty
if (renderData.markDirty) {
renderData.markDirty(currentSplat);
console.log('[GaussianViewer] Called markDirty()');
}
// Try forcing the flags directly
renderData.dataChanged = true;
console.log('[GaussianViewer] Set renderData.dataChanged = true');
// Check needsRebuild status
console.log('[GaussianViewer] renderData.needsRebuild:', renderData.needsRebuild);
console.log('[GaussianViewer] renderData._updating size:', renderData._updating?.size);
console.log('[GaussianViewer] renderData._dirty size:', renderData._dirty?.size);
} else {
console.log('[GaussianViewer] No renderData found on renderer');
console.log('[GaussianViewer] renderer keys:', Object.keys(renderer).join(', '));
}
// Also dispatch change event (triggers scene to pick up changes)
if (currentSplat.dispatchEvent && currentSplat._changeEvent) {
currentSplat.dispatchEvent(currentSplat._changeEvent);
console.log('[GaussianViewer] Dispatched change event');
}
// Throttled force refresh by removing and re-adding the splat to scene
// This is a workaround for gsplat.js not supporting dynamic scale updates
if (scene && scene.removeObject && scene.addObject) {
lastScaleValue = newScale; // Always update to latest value
if (!scaleUpdatePending) {
scaleUpdatePending = true;
const splatToRefresh = currentSplat; // Capture reference
// Delay the refresh to avoid flickering during slider drag
setTimeout(() => {
scaleUpdatePending = false;
// Only refresh if splat is still current
if (splatToRefresh === currentSplat) {
console.log('[GaussianViewer] Forcing refresh by re-adding splat to scene');
scene.removeObject(splatToRefresh);
scene.addObject(splatToRefresh);
console.log('[GaussianViewer] Splat re-added to scene');
} else {
console.log('[GaussianViewer] Skipping refresh, splat changed during delay');
}
}, 150); // 150ms debounce for smoother dragging
}
}
console.log('[GaussianViewer] Scale update complete. New scale[0]:', scales[0].toFixed(6),
'original[0]:', originalScales[0].toFixed(6), 'multiplier:', newScale);
} else if (currentSplat) {
console.log('[GaussianViewer] Cannot update scales - missing data or originalScales');
console.log('[GaussianViewer] currentSplat.data:', !!currentSplat.data);
console.log('[GaussianViewer] currentSplat.data.scales:', !!(currentSplat.data && currentSplat.data.scales));
console.log('[GaussianViewer] originalScales:', !!originalScales);
}
}
scaleInput.addEventListener('change', (e) => updateGaussianScale(e.target.value));
// Event listeners
document.getElementById('resetCamera').addEventListener('click', resetCamera);
document.getElementById('screenshot').addEventListener('click', takeScreenshot);
document.getElementById('copyImage').addEventListener('click', copyImageToClipboard);
// Listen for messages from parent
window.addEventListener('message', (event) => {
const { type, data, url, filename, extrinsics, intrinsics } = event.data;
if (type === 'LOAD_MESH_URL' && url) {
console.log('[GaussianViewer] Received LOAD_MESH_URL:', url);
loadPLYFromURL(url, filename || 'gaussian.ply', extrinsics, intrinsics);
} else if (type === 'LOAD_MESH_DATA' && data) {
console.log('[GaussianViewer] Received LOAD_MESH_DATA, size:', data.byteLength);
loadPLYFromData(data, filename || 'gaussian.ply', extrinsics, intrinsics);
}
});
// Initialize on load
initViewer();
console.log('[GaussianViewer] Ready - waiting for LOAD_MESH message');
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+476
View File
@@ -0,0 +1,476 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>GeomPack VTK.js Multi Mesh Slider Viewer</title>
<style>
body { margin: 0; overflow: hidden; background: #2a2a2a; font-family: monospace;
display: flex; flex-direction: column; height: 100vh; }
#viewerContainer { flex: 1; position: relative; min-height: 0; }
.render-layer { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
#loading { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
color: white; font-size: 14px; text-align: center; z-index: 1000; pointer-events: none; }
#sliderOverlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none; z-index: 100; }
.slider-line { position: absolute; top: 0; width: 2px; height: 100%; background: #00e0ff;
pointer-events: auto; cursor: ew-resize; box-shadow: 0 0 8px rgba(0,224,255,0.5); }
.slider-handle { position: absolute; width: 20px; height: 20px; background: #00e0ff;
border: 2px solid white; border-radius: 50%; transform: translate(-50%,-50%);
pointer-events: auto; cursor: ew-resize; transition: transform 0.1s, box-shadow 0.1s;
box-shadow: 0 0 8px rgba(0,224,255,0.5); }
.slider-handle:hover, .slider-handle.dragging {
transform: translate(-50%,-50%) scale(1.2); box-shadow: 0 0 15px rgba(0,224,255,0.8); }
.mesh-label { position: absolute; top: 8px; color: white; font-size: 13px; font-weight: bold;
text-shadow: 2px 2px 4px black; pointer-events: none; background: rgba(0,0,0,0.6);
padding: 4px 10px; border-radius: 4px; z-index: 101; transform: translateX(-50%); white-space: nowrap; }
#controls { background: #1a1a1a; border-top: 1px solid #444; padding: 8px 12px; display: flex;
gap: 12px; align-items: center; flex-wrap: wrap; font-size: 12px; color: #ccc;
min-height: 48px; overflow-x: auto; }
#controls button, #controls select { background: #333; color: #ccc; border: 1px solid #555;
padding: 4px 8px; border-radius: 3px; font-size: 12px; font-family: monospace; cursor: pointer; }
#controls button:hover { background: #444; }
#controls label { display: flex; align-items: center; gap: 4px; }
.divider { width: 1px; height: 20px; background: #444; margin: 0 8px; }
#settingsPanel { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%);
background: #1a1a1a; border: 1px solid #555; border-radius: 6px; padding: 16px; z-index: 2000;
display: none; min-width: 280px; max-height: 80%; overflow-y: auto; box-shadow: 0 4px 12px rgba(0,0,0,0.5); }
#settingsPanel.visible { display: block; }
#settingsPanel h3 { margin: 0 0 12px 0; color: #ccc; font-size: 14px; font-weight: normal;
border-bottom: 1px solid #333; padding-bottom: 8px; }
#settingsPanel .setting-row { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
#settingsPanel .setting-row label { flex: 0 0 100px; color: #ccc; font-size: 12px; }
#settingsPanel .setting-row input, #settingsPanel .setting-row select { flex: 1; background: #333;
color: #ccc; border: 1px solid #555; padding: 6px 8px; border-radius: 3px; font-size: 12px; font-family: monospace; }
#settingsPanel .button-row { display: flex; gap: 8px; margin-top: 16px; padding-top: 12px; border-top: 1px solid #333; }
#settingsPanel .button-row button { flex: 1; padding: 6px 12px; font-size: 12px; }
#settingsOverlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.5); z-index: 1999; display: none; }
#settingsOverlay.visible { display: block; }
#settingsButton { background: #333; width: 32px; height: 32px; padding: 0; display: flex;
align-items: center; justify-content: center; font-size: 16px; }
</style>
</head>
<body>
<div id="viewerContainer">
<!-- render layers + slider overlay are created dynamically -->
<div id="sliderOverlay"></div>
<div id="loading">Ready to load meshes</div>
<div id="settingsOverlay"></div>
<div id="settingsPanel">
<h3>Appearance Settings</h3>
<div class="setting-row"><label>Point Size:</label><input type="number" id="pointSize" min="1" max="20" step="1" value="10"></div>
<div class="setting-row"><label>Edge Width:</label><input type="number" id="edgeWidth" min="1" max="10" step="1" value="1"></div>
<div class="setting-row"><label>Render Mode:</label>
<select id="representationSettings"><option value="2">Surface</option><option value="1">Wireframe</option><option value="0">Points</option></select></div>
<div class="setting-row"><label><input type="checkbox" id="parallelProjectionSetting"> Parallel Projection</label></div>
<div class="button-row"><button id="applySettings">Apply</button><button id="cancelSettings">Cancel</button></div>
</div>
</div>
<div id="controls">
<button id="settingsButton" title="Appearance Settings">[Settings]</button>
<span class="divider"></span>
<label><input type="checkbox" id="showEdges"> Edges</label>
<span class="divider"></span>
<button id="viewPosX">+X</button><button id="viewNegX">-X</button>
<button id="viewPosY">+Y</button><button id="viewNegY">-Y</button>
<button id="viewPosZ">+Z</button><button id="viewNegZ">-Z</button>
<span class="divider"></span>
<button id="resetCamera">Reset Camera</button>
<button id="resetSlider">Reset Sliders</button>
</div>
<script src="./js/vtk-gltf.js"></script>
<script src="./js/viewer-bundle-vtk.js"></script>
<script>
const vtkFullScreenRenderWindow = vtk.Rendering.Misc.vtkFullScreenRenderWindow;
const vtkActor = vtk.Rendering.Core.vtkActor;
const vtkMapper = vtk.Rendering.Core.vtkMapper;
const vtkSTLReader = vtk.IO.Geometry.vtkSTLReader;
const vtkOBJReader = vtk.IO.Misc.vtkOBJReader;
const vtkXMLPolyDataReader = vtk.IO.XML.vtkXMLPolyDataReader;
const vtkPolyDataNormals = vtk.Filters.Core.vtkPolyDataNormals;
const vtkInteractorStyleTrackballCamera = vtk.Interaction.Style.vtkInteractorStyleTrackballCamera;
const vtkGLTFImporter = vtk.IO.Geometry.vtkGLTFImporter;
console.log('[GeomPack Multi Slider] Initializing...');
const loading = document.getElementById('loading');
const viewerContainer = document.getElementById('viewerContainer');
const sliderOverlay = document.getElementById('sliderOverlay');
// distinct default colors per layer
const PALETTE = [
[0.30, 0.50, 0.95], // blue
[0.30, 0.85, 0.40], // green
[0.95, 0.65, 0.25], // orange
[0.85, 0.40, 0.85], // magenta
];
// per-layer arrays
let N = 0;
let containers = [], fsRenderers = [], renderers = [], renderWindows = [], interactors = [];
let actors = [], mappers = [], boundsArr = [], gltfActorsArr = [], polydataArr = [];
let combinedBounds = null;
let positions = []; // N-1 divider positions in [0,1], strictly increasing
let sliderLines = [], sliderHandles = [], bandLabels = [];
let dragIndex = -1;
const EPS = 0.02;
// camera sync
let animatingIndex = -1, isSyncing = false;
let _cameraWheelTimer = null;
function teardown() {
fsRenderers.forEach(r => { try { r.delete(); } catch (e) {} });
containers.forEach(c => c.remove());
sliderOverlay.innerHTML = '';
containers = []; fsRenderers = []; renderers = []; renderWindows = []; interactors = [];
actors = []; mappers = []; boundsArr = []; gltfActorsArr = []; polydataArr = [];
sliderLines = []; sliderHandles = []; bandLabels = []; combinedBounds = null;
}
// Build DOM layers + dividers for n meshes
function buildLayers(n) {
teardown();
N = n;
for (let i = 0; i < n; i++) {
const div = document.createElement('div');
div.className = 'render-layer';
div.id = `meshContainer${i}`;
div.style.zIndex = (i + 1).toString();
viewerContainer.insertBefore(div, sliderOverlay);
containers.push(div);
}
// N-1 dividers
for (let j = 0; j < n - 1; j++) {
const line = document.createElement('div'); line.className = 'slider-line';
const handle = document.createElement('div'); handle.className = 'slider-handle';
line.dataset.idx = j; handle.dataset.idx = j;
sliderOverlay.appendChild(line); sliderOverlay.appendChild(handle);
sliderLines.push(line); sliderHandles.push(handle);
line.addEventListener('mousedown', (e) => startDrag(e, j));
handle.addEventListener('mousedown', (e) => startDrag(e, j));
line.addEventListener('touchstart', (e) => startDrag(e, j));
handle.addEventListener('touchstart', (e) => startDrag(e, j));
}
// N band labels
for (let i = 0; i < n; i++) {
const lab = document.createElement('div'); lab.className = 'mesh-label';
lab.textContent = `Mesh ${i + 1}`;
sliderOverlay.appendChild(lab); bandLabels.push(lab);
}
// default evenly-spaced dividers
positions = [];
for (let j = 1; j < n; j++) positions.push(j / n);
}
function initViewers(n) {
for (let i = 0; i < n; i++) {
const fsr = vtkFullScreenRenderWindow.newInstance({
background: [0.16, 0.16, 0.16], container: containers[i] });
const renderer = fsr.getRenderer();
const rw = fsr.getRenderWindow();
const interactor = rw.getInteractor();
interactor.setInteractorStyle(vtkInteractorStyleTrackballCamera.newInstance());
const actor = vtkActor.newInstance();
const mapper = vtkMapper.newInstance();
actor.setMapper(mapper);
const prop = actor.getProperty();
const c = PALETTE[i % PALETTE.length];
prop.setColor(c[0], c[1], c[2]);
prop.setEdgeVisibility(false);
prop.setAmbient(0.4); prop.setDiffuse(0.8); prop.setSpecular(0.3); prop.setSpecularPower(20);
renderer.addActor(actor);
fsRenderers.push(fsr); renderers.push(renderer); renderWindows.push(rw);
interactors.push(interactor); actors.push(actor); mappers.push(mapper);
boundsArr.push(null); gltfActorsArr.push(null); polydataArr.push(null);
fsr.resize();
}
setupCameraSync();
console.log(`[GeomPack Multi Slider] ${n} viewers initialized`);
}
function syncFrom(srcIdx) {
if (isSyncing) return;
isSyncing = true;
const src = renderers[srcIdx].getActiveCamera();
for (let i = 0; i < N; i++) {
if (i === srcIdx) continue;
const cam = renderers[i].getActiveCamera();
cam.setPosition(...src.getPosition());
cam.setFocalPoint(...src.getFocalPoint());
cam.setViewUp(...src.getViewUp());
cam.setParallelProjection(src.getParallelProjection());
cam.setClippingRange(...src.getClippingRange());
cam.setParallelScale(src.getParallelScale());
cam.setViewAngle(src.getViewAngle());
renderers[i].resetCameraClippingRange();
renderWindows[i].render();
}
isSyncing = false;
}
function sendCameraState() {
const cam = renderers[0]?.getActiveCamera();
if (!cam) return;
window.parent.postMessage({ type: 'WIDGET_UPDATE', widget: 'camera_state',
value: JSON.stringify({ position: cam.getPosition(), focalPoint: cam.getFocalPoint(), viewUp: cam.getViewUp() }) }, '*');
}
function setupCameraSync() {
for (let i = 0; i < N; i++) {
const idx = i;
interactors[idx].onStartAnimation(() => { animatingIndex = idx; });
interactors[idx].onAnimation(() => { if (animatingIndex === idx) syncFrom(idx); });
interactors[idx].onEndAnimation(() => {
if (animatingIndex === idx) { animatingIndex = -1; syncFrom(idx); sendCameraState(); }
});
}
setTimeout(() => {
for (let i = 0; i < N; i++) {
const idx = i;
const canvas = containers[idx].querySelector('canvas');
if (!canvas) continue;
canvas.addEventListener('wheel', () => {
setTimeout(() => syncFrom(idx), 0);
clearTimeout(_cameraWheelTimer);
_cameraWheelTimer = setTimeout(sendCameraState, 150);
});
}
}, 500);
}
async function loadMesh(filepath, idx) {
console.log(`[GeomPack Multi Slider] Loading mesh ${idx + 1}:`, filepath);
const isSTL = filepath.includes('.stl');
const isOBJ = filepath.includes('.obj');
const isVTP = filepath.includes('.vtp');
const isGLB = filepath.includes('.glb');
if (isGLB) {
const targetRenderer = renderers[idx];
const prev = gltfActorsArr[idx];
if (prev) prev.forEach(a => targetRenderer.removeActor(a));
const importer = vtkGLTFImporter.newInstance();
importer.setRenderer(targetRenderer);
importer.setUrl(filepath);
await new Promise((resolve, reject) => { importer.onReady(() => resolve()); importer.loadData().catch(reject); });
importer.importActors();
const acts = Array.from(importer.getActors().values());
if (acts.length === 0) throw new Error('No actors found in GLTF');
targetRenderer.removeActor(actors[idx]);
let bounds = null;
acts.forEach(actor => {
const mapper = actor.getMapper(); const property = actor.getProperty(); const input = mapper?.getInputData();
if (input) {
const ab = actor.getBounds();
if (!bounds) bounds = [...ab];
else { bounds[0]=Math.min(bounds[0],ab[0]); bounds[1]=Math.max(bounds[1],ab[1]); bounds[2]=Math.min(bounds[2],ab[2]); bounds[3]=Math.max(bounds[3],ab[3]); bounds[4]=Math.min(bounds[4],ab[4]); bounds[5]=Math.max(bounds[5],ab[5]); }
const scalars = input.getPointData()?.getScalars();
if (scalars && scalars.getNumberOfComponents() >= 3) {
mapper.setScalarVisibility(true); mapper.setScalarModeToUsePointData(); mapper.setColorModeToDirectScalars();
}
if (property) { if (property.setMetallic) property.setMetallic(0.0); if (property.setRoughness) property.setRoughness(1.0); }
}
});
boundsArr[idx] = bounds; polydataArr[idx] = null; gltfActorsArr[idx] = acts;
return;
}
let reader;
if (isSTL) reader = vtkSTLReader.newInstance();
else if (isOBJ) reader = vtkOBJReader.newInstance();
else if (isVTP) reader = vtkXMLPolyDataReader.newInstance();
else throw new Error(`Unsupported file format: ${filepath}`);
const response = await fetch(filepath);
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
const arrayBuffer = await response.arrayBuffer();
if (isSTL) reader.parseAsArrayBuffer(arrayBuffer);
else if (isOBJ) reader.parseAsText(new TextDecoder().decode(arrayBuffer));
else if (isVTP) reader.parseAsArrayBuffer(arrayBuffer);
const polydata = reader.getOutputData();
if (!polydata) throw new Error('Failed to parse mesh data');
const normalsFilter = vtkPolyDataNormals.newInstance();
normalsFilter.setInputConnection(reader.getOutputPort());
mappers[idx].setInputConnection(normalsFilter.getOutputPort());
mappers[idx].setScalarVisibility(false);
mappers[idx].update();
const mp = mappers[idx].getInputData();
boundsArr[idx] = mp.getBounds(); polydataArr[idx] = mp;
}
function updateCombinedBounds() {
const valid = boundsArr.filter(b => b);
if (!valid.length) { combinedBounds = null; return; }
combinedBounds = [...valid[0]];
valid.forEach(b => {
combinedBounds[0]=Math.min(combinedBounds[0],b[0]); combinedBounds[1]=Math.max(combinedBounds[1],b[1]);
combinedBounds[2]=Math.min(combinedBounds[2],b[2]); combinedBounds[3]=Math.max(combinedBounds[3],b[3]);
combinedBounds[4]=Math.min(combinedBounds[4],b[4]); combinedBounds[5]=Math.max(combinedBounds[5],b[5]);
});
}
// clip each layer to its band [leftEdge, rightEdge]; place dividers + labels
function updateSliders() {
if (!N) return;
const rect = viewerContainer.getBoundingClientRect();
for (let i = 0; i < N; i++) {
const left = (i === 0) ? 0 : positions[i - 1];
const right = (i === N - 1) ? 1 : positions[i];
containers[i].style.clipPath = `inset(0 ${(1 - right) * 100}% 0 ${left * 100}%)`;
const mid = (left + right) / 2;
if (bandLabels[i]) bandLabels[i].style.left = `${mid * rect.width}px`;
}
for (let j = 0; j < positions.length; j++) {
const x = positions[j] * rect.width;
sliderLines[j].style.left = `${x}px`;
sliderHandles[j].style.left = `${x}px`;
sliderHandles[j].style.top = `${rect.height / 2}px`;
}
}
function positionCamera(bounds) {
if (!bounds) return;
const center = [(bounds[0]+bounds[1])/2, (bounds[2]+bounds[3])/2, (bounds[4]+bounds[5])/2];
const maxDim = Math.max(bounds[1]-bounds[0], bounds[3]-bounds[2], bounds[5]-bounds[4]);
const distance = maxDim * 2.5;
for (let i = 0; i < N; i++) {
const cam = renderers[i].getActiveCamera();
cam.setPosition(center[0]+distance, center[1]+distance, center[2]+distance);
cam.setFocalPoint(...center); cam.setViewUp(0, 0, 1);
renderers[i].resetCamera(bounds); renderers[i].resetCameraClippingRange();
}
}
function renderAll() { for (let i = 0; i < N; i++) renderWindows[i].render(); }
// ----- divider drag (clamped, never crossing) -----
function startDrag(e, j) { dragIndex = j; sliderHandles[j].classList.add('dragging'); e.preventDefault(); }
function onDrag(e) {
if (dragIndex < 0) return;
const rect = viewerContainer.getBoundingClientRect();
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
let p = (clientX - rect.left) / rect.width;
const lo = (dragIndex === 0) ? 0 : positions[dragIndex - 1];
const hi = (dragIndex === positions.length - 1) ? 1 : positions[dragIndex + 1];
p = Math.max(lo + EPS, Math.min(hi - EPS, p));
positions[dragIndex] = p;
updateSliders();
}
function endDrag() { if (dragIndex >= 0) sliderHandles[dragIndex].classList.remove('dragging'); dragIndex = -1; }
document.addEventListener('mousemove', onDrag);
document.addEventListener('mouseup', endDrag);
document.addEventListener('touchmove', onDrag);
document.addEventListener('touchend', endDrag);
// ----- message protocol -----
window.addEventListener('message', async (event) => {
if (event.data.type !== 'LOAD_MULTI_SLIDER') return;
const data = event.data;
const files = (data.mesh_files || data.meshFiles || []).filter(Boolean);
console.log('[GeomPack Multi Slider] LOAD_MULTI_SLIDER:', files.length, 'meshes');
if (files.length < 2) {
loading.textContent = 'Need at least 2 meshes for the wipe view'; loading.style.display = 'block'; return;
}
loading.textContent = 'Loading meshes...'; loading.style.display = 'block';
try {
buildLayers(files.length);
initViewers(files.length);
for (let i = 0; i < files.length; i++) await loadMesh(files[i], i);
updateCombinedBounds();
updateSliders();
positionCamera(combinedBounds);
const showEdges = data.show_edges || data.showEdges || false;
document.getElementById('showEdges').checked = showEdges;
actors.forEach(a => a.getProperty().setEdgeVisibility(showEdges));
gltfActorsArr.forEach(acts => { if (acts) acts.forEach(a => a.getProperty().setEdgeVisibility(showEdges)); });
renderAll();
loading.style.display = 'none';
} catch (err) {
console.error('[GeomPack Multi Slider] load error:', err);
loading.textContent = `Error: ${err.message}`; loading.style.color = '#ff6b6b';
}
});
// ----- controls -----
document.getElementById('showEdges').addEventListener('change', (e) => {
const v = e.target.checked;
actors.forEach(a => a.getProperty().setEdgeVisibility(v));
gltfActorsArr.forEach(acts => { if (acts) acts.forEach(a => a.getProperty().setEdgeVisibility(v)); });
renderAll();
window.parent.postMessage({ type: 'WIDGET_UPDATE', widget: 'show_edges', value: v }, '*');
});
document.getElementById('resetCamera').addEventListener('click', () => { positionCamera(combinedBounds); renderAll(); });
document.getElementById('resetSlider').addEventListener('click', () => {
positions = []; for (let j = 1; j < N; j++) positions.push(j / N); updateSliders();
});
function setCameraView(direction) {
if (!combinedBounds) return;
const b = combinedBounds;
const center = [(b[0]+b[1])/2, (b[2]+b[3])/2, (b[4]+b[5])/2];
const maxDim = Math.max(b[1]-b[0], b[3]-b[2], b[5]-b[4]);
const d = maxDim * 2.5;
let position, viewUp;
switch (direction) {
case '+X': position=[center[0]+d,center[1],center[2]]; viewUp=[0,0,1]; break;
case '-X': position=[center[0]-d,center[1],center[2]]; viewUp=[0,0,1]; break;
case '+Y': position=[center[0],center[1]+d,center[2]]; viewUp=[0,0,1]; break;
case '-Y': position=[center[0],center[1]-d,center[2]]; viewUp=[0,0,1]; break;
case '+Z': position=[center[0],center[1],center[2]+d]; viewUp=[0,1,0]; break;
case '-Z': position=[center[0],center[1],center[2]-d]; viewUp=[0,1,0]; break;
}
for (let i = 0; i < N; i++) {
const cam = renderers[i].getActiveCamera();
cam.setPosition(...position); cam.setFocalPoint(...center); cam.setViewUp(...viewUp);
renderers[i].resetCamera(); renderers[i].resetCameraClippingRange();
}
renderAll();
}
document.getElementById('viewPosX').addEventListener('click', () => setCameraView('+X'));
document.getElementById('viewNegX').addEventListener('click', () => setCameraView('-X'));
document.getElementById('viewPosY').addEventListener('click', () => setCameraView('+Y'));
document.getElementById('viewNegY').addEventListener('click', () => setCameraView('-Y'));
document.getElementById('viewPosZ').addEventListener('click', () => setCameraView('+Z'));
document.getElementById('viewNegZ').addEventListener('click', () => setCameraView('-Z'));
// ----- settings panel -----
const settingsButton = document.getElementById('settingsButton');
const settingsPanel = document.getElementById('settingsPanel');
const settingsOverlay = document.getElementById('settingsOverlay');
let currentPointSize = 3, currentEdgeWidth = 1, currentRepresentation = 2, currentParallelProjection = false;
settingsButton.addEventListener('click', () => {
document.getElementById('pointSize').value = currentPointSize;
document.getElementById('edgeWidth').value = currentEdgeWidth;
document.getElementById('representationSettings').value = currentRepresentation.toString();
document.getElementById('parallelProjectionSetting').checked = currentParallelProjection;
settingsPanel.classList.add('visible'); settingsOverlay.classList.add('visible');
});
function closeSettings() { settingsPanel.classList.remove('visible'); settingsOverlay.classList.remove('visible'); }
settingsOverlay.addEventListener('click', closeSettings);
document.getElementById('cancelSettings').addEventListener('click', closeSettings);
document.getElementById('applySettings').addEventListener('click', () => {
const ps = parseFloat(document.getElementById('pointSize').value);
const ew = parseFloat(document.getElementById('edgeWidth').value);
const rep = parseInt(document.getElementById('representationSettings').value);
const pp = document.getElementById('parallelProjectionSetting').checked;
actors.forEach(a => { const p=a.getProperty(); p.setPointSize(ps); p.setLineWidth(ew); p.setRepresentation(rep); });
gltfActorsArr.forEach(acts => { if (acts) acts.forEach(a => { const p=a.getProperty(); p.setPointSize(ps); p.setLineWidth(ew); p.setRepresentation(rep); }); });
for (let i = 0; i < N; i++) { renderers[i].getActiveCamera().setParallelProjection(pp); renderers[i].resetCameraClippingRange(); }
currentPointSize=ps; currentEdgeWidth=ew; currentRepresentation=rep; currentParallelProjection=pp;
renderAll(); closeSettings();
});
window.addEventListener('resize', () => { fsRenderers.forEach(r => r.resize()); updateSliders(); renderAll(); });
console.log('[GeomPack Multi Slider] Ready');
</script>
</body>
</html>
+409
View File
@@ -0,0 +1,409 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>GeomPack PBR Viewer</title>
<style>
body {
margin: 0;
overflow: hidden;
background: #1a1a1a;
font-family: monospace;
}
#canvas {
width: 100%;
height: 100%;
display: block;
}
#loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 14px;
text-align: center;
}
#info {
position: absolute;
bottom: 10px;
left: 10px;
color: #888;
font-size: 11px;
pointer-events: none;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<div id="loading">Ready to load mesh</div>
<div id="info">PBR Viewer</div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { EXRLoader } from 'three/addons/loaders/EXRLoader.js';
import { RGBELoader } from 'three/addons/loaders/RGBELoader.js';
console.log('[GeomPack PBR Viewer] Initializing...');
const canvas = document.getElementById('canvas');
const loading = document.getElementById('loading');
const info = document.getElementById('info');
// Scene
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a1a);
// Camera
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.01, 1000);
camera.position.set(2, 2, 2);
// Renderer with PBR settings
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
// Critical PBR settings
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
renderer.sortObjects = true; // Enable proper transparency sorting
// Controls
const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.zoomSpeed = 3.0;
// Grid (subtle)
const grid = new THREE.GridHelper(10, 20, 0x333333, 0x222222);
scene.add(grid);
// Send camera state to parent node widget
function sendCameraState() {
window.parent.postMessage({
type: 'WIDGET_UPDATE',
widget: 'camera_state',
value: JSON.stringify({
position: [camera.position.x, camera.position.y, camera.position.z],
focalPoint: [controls.target.x, controls.target.y, controls.target.z],
viewUp: [camera.up.x, camera.up.y, camera.up.z]
})
}, '*');
}
function applyCameraState(cameraState) {
if (!cameraState) return false;
try {
const cs = typeof cameraState === 'string' ? JSON.parse(cameraState) : cameraState;
if (!cs.position || !cs.focalPoint || !cs.viewUp) return false;
camera.position.set(...cs.position);
controls.target.set(...cs.focalPoint);
camera.up.set(...cs.viewUp);
camera.updateProjectionMatrix();
controls.update();
return true;
} catch(e) { return false; }
}
controls.addEventListener('end', sendCameraState);
let _pendingCameraState = null;
// Current mesh
let currentMesh = null;
let envMapLoaded = false;
// Get the extension folder path from the current URL
const getExtensionPath = () => {
const url = window.location.href;
const match = url.match(/\/extensions\/([^/]+)\//);
return match ? `/extensions/${match[1]}` : '/extensions/comfyui-geometrypack';
};
// Load environment map for PBR reflections
const loadEnvironment = () => {
return new Promise((resolve, reject) => {
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(
exrPath,
(texture) => {
texture.mapping = THREE.EquirectangularReflectionMapping;
scene.environment = texture;
envMapLoaded = true;
console.log('[GeomPack PBR Viewer] Environment map Loaded successfully');
resolve(texture);
},
(progress) => {
if (progress.lengthComputable) {
console.log('[GeomPack PBR Viewer] Env map loading:', Math.round(progress.loaded / progress.total * 100) + '%');
}
},
(error) => {
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);
}
);
});
};
// Fallback environment if EXR fails to load
const createFallbackEnvironment = () => {
const pmremGenerator = new THREE.PMREMGenerator(renderer);
pmremGenerator.compileEquirectangularShader();
// Create a simple gradient sky
const skyGeo = new THREE.SphereGeometry(500, 32, 15);
const skyMat = new THREE.ShaderMaterial({
uniforms: {
topColor: { value: new THREE.Color(0x444466) },
bottomColor: { value: new THREE.Color(0x222222) },
offset: { value: 33 },
exponent: { value: 0.6 }
},
vertexShader: `
varying vec3 vWorldPosition;
void main() {
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
vWorldPosition = worldPosition.xyz;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 topColor;
uniform vec3 bottomColor;
uniform float offset;
uniform float exponent;
varying vec3 vWorldPosition;
void main() {
float h = normalize(vWorldPosition + offset).y;
gl_FragColor = vec4(mix(bottomColor, topColor, max(pow(max(h, 0.0), exponent), 0.0)), 1.0);
}
`,
side: THREE.BackSide
});
const sky = new THREE.Mesh(skyGeo, skyMat);
// Generate environment map from sky
const envScene = new THREE.Scene();
envScene.add(sky);
const envMap = pmremGenerator.fromScene(envScene).texture;
scene.environment = envMap;
envMapLoaded = true;
pmremGenerator.dispose();
console.log('[GeomPack PBR Viewer] Using fallback gradient environment');
};
// Lighting setup matching standard ComfyUI Load3D viewer
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(ambientLight);
// Main light
const mainLight = new THREE.DirectionalLight(0xffffff, 0.8);
mainLight.position.set(0, 10, 10);
scene.add(mainLight);
// Back light
const backLight = new THREE.DirectionalLight(0xffffff, 0.5);
backLight.position.set(0, 10, -10);
scene.add(backLight);
// Left fill light
const leftFillLight = new THREE.DirectionalLight(0xffffff, 0.3);
leftFillLight.position.set(-10, 0, 0);
scene.add(leftFillLight);
// Right fill light
const rightFillLight = new THREE.DirectionalLight(0xffffff, 0.3);
rightFillLight.position.set(10, 0, 0);
scene.add(rightFillLight);
// Bottom light
const bottomLight = new THREE.DirectionalLight(0xffffff, 0.2);
bottomLight.position.set(0, -10, 0);
scene.add(bottomLight);
// Animation loop
function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
animate();
// Handle window resize
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// Load mesh function
async function loadMesh(filepath) {
console.log('[GeomPack PBR Viewer] Loading:', filepath);
loading.textContent = 'Loading mesh...';
loading.style.display = 'block';
// Ensure environment is loaded first
if (!envMapLoaded) {
loading.textContent = 'Loading environment...';
await loadEnvironment();
}
// Remove old mesh
if (currentMesh) {
scene.remove(currentMesh);
currentMesh.traverse((child) => {
if (child.geometry) child.geometry.dispose();
if (child.material) {
if (Array.isArray(child.material)) {
child.material.forEach(m => m.dispose());
} else {
child.material.dispose();
}
}
});
currentMesh = null;
}
loading.textContent = 'Loading mesh...';
const loader = new GLTFLoader();
loader.load(
filepath,
(gltf) => {
currentMesh = gltf.scene;
// Center mesh
const box = new THREE.Box3().setFromObject(currentMesh);
const center = box.getCenter(new THREE.Vector3());
currentMesh.position.sub(center);
// Configure materials for PBR
let meshCount = 0;
let hasTextures = false;
currentMesh.traverse((child) => {
if (child.isMesh) {
meshCount++;
// Enable environment map on all materials
if (child.material) {
const materials = Array.isArray(child.material) ? child.material : [child.material];
materials.forEach(mat => {
if (mat.isMeshStandardMaterial || mat.isMeshPhysicalMaterial) {
mat.envMapIntensity = 1.5;
// 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
if (mat.map || mat.metalnessMap || mat.roughnessMap) {
hasTextures = true;
}
console.log('[GeomPack PBR Viewer] Material:', {
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,
hasAlphaMap: hasAlphaMap
});
}
});
}
}
});
// Add to scene
scene.add(currentMesh);
// Adjust camera (restore saved state or auto-position)
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
camera.near = maxDim * 0.001;
camera.far = maxDim * 100;
camera.updateProjectionMatrix();
if (!applyCameraState(_pendingCameraState)) {
const distance = maxDim * 2.0;
camera.position.set(distance * 0.7, distance * 0.5, distance * 0.7);
camera.lookAt(0, 0, 0);
controls.target.set(0, 0, 0);
controls.update();
}
_pendingCameraState = null;
loading.style.display = 'none';
info.textContent = `PBR Viewer | ${meshCount} mesh(es) | Textures: ${hasTextures ? 'Yes' : 'No'}`;
console.log('[GeomPack PBR Viewer] Mesh Loaded successfully');
},
(progress) => {
if (progress.lengthComputable) {
const percent = (progress.loaded / progress.total * 100).toFixed(0);
loading.textContent = `Loading mesh... ${percent}%`;
}
},
(error) => {
console.error('[GeomPack PBR Viewer] Error loading mesh:', error);
loading.textContent = 'Error loading mesh - check console';
loading.style.color = '#ff6666';
}
);
}
// Listen for messages from parent window
window.addEventListener('message', (event) => {
console.log('[GeomPack PBR Viewer] Received message:', event.data);
if (event.data.type === 'LOAD_MESH') {
_pendingCameraState = event.data.cameraState || null;
loadMesh(event.data.filepath);
}
});
// Pre-load environment
loadEnvironment().then(() => {
console.log('[GeomPack PBR Viewer] Ready');
});
</script>
</body>
</html>
+386
View File
@@ -0,0 +1,386 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>GeomPack Mesh Slicer</title>
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #2a2a2a; overflow: hidden; font: 12px monospace; color: #ddd; }
#wrap { display: flex; flex-direction: column; width: 100%; height: 100%; }
#top { position: relative; flex: 1 1 0; min-height: 80px; background: #2a2a2a; }
#toolbar { flex: 0 0 auto; display: flex; flex-direction: column; gap: 4px; padding: 4px 6px; background: #1d1d1d; border-top: 1px solid #444; border-bottom: 1px solid #444; }
#toolbar .row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
#bottom { position: relative; flex: 1 1 0; min-height: 80px; background: #222; cursor: crosshair; }
#section { position: relative; flex: 1 1 0; min-height: 80px; background: #151515; border-top: 1px solid #444; display: none; }
#status { flex: 1 1 auto; color: #9ad; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
button { background: #333; color: #ddd; border: 1px solid #555; border-radius: 4px; padding: 3px 8px; cursor: pointer; font: 11px monospace; }
button.active { background: #2c5d8a; border-color: #4a90c0; }
button:hover { background: #444; }
</style>
<script src="./js/vtk-gltf.js"></script>
</head>
<body>
<div id="wrap">
<div id="top"></div>
<div id="toolbar">
<div class="row">
<span>3d view:</span>
<button id="qpx">+X</button>
<button id="qnx">−X</button>
<button id="qpy">+Y</button>
<button id="qny">−Y</button>
<button id="qpz">+Z</button>
<button id="qnz">−Z</button>
<span id="status">load a mesh, then click 2 points in the bottom view to set the cut</span>
</div>
<div class="row">
<span>cut view:</span>
<button id="axXY" class="active">XY</button>
<button id="axYZ">YZ</button>
<button id="axXZ">XZ</button>
<button id="lock" class="active">🔒 2D lock</button>
<button id="flip">⇄ flip clip</button>
<button id="reset">reset clip</button>
<button id="r2d">▣ render 2D</button>
<label style="color:#bbb;font:11px monospace;">edge <input id="thick" type="number" min="1" max="20" value="2" style="width:44px;background:#333;color:#ddd;border:1px solid #555;border-radius:4px;font:11px monospace;"></label>
</div>
</div>
<div id="bottom"></div>
<div id="section"></div>
</div>
<script>
const vtkFullScreenRenderWindow = vtk.Rendering.Misc.vtkFullScreenRenderWindow;
const vtkInteractorStyleTrackballCamera = vtk.Interaction.Style.vtkInteractorStyleTrackballCamera;
const vtkActor = vtk.Rendering.Core.vtkActor;
const vtkMapper = vtk.Rendering.Core.vtkMapper;
const vtkXMLPolyDataReader = vtk.IO.XML.vtkXMLPolyDataReader;
const vtkPlane = vtk.Common.DataModel.vtkPlane;
const vtkCutter = vtk.Filters.Core.vtkCutter;
const statusEl = document.getElementById('status');
const setStatus = (t) => { statusEl.textContent = t; };
// ---- top: interactive 3D view (shows the clipped mesh) ----
const topWin = vtkFullScreenRenderWindow.newInstance({ background: [0.16, 0.16, 0.16], container: document.getElementById('top') });
const topRen = topWin.getRenderer();
const topRW = topWin.getRenderWindow();
topRW.getInteractor().setInteractorStyle(vtkInteractorStyleTrackballCamera.newInstance());
const topMapper = vtkMapper.newInstance();
const topActor = vtkActor.newInstance();
topActor.setMapper(topMapper);
topActor.getProperty().setColor(0.8, 0.8, 0.82);
topActor.getProperty().setEdgeVisibility(false);
topRen.addActor(topActor);
// ---- bottom: orthographic pick view ----
const bot = vtkFullScreenRenderWindow.newInstance({ background: [0.13, 0.13, 0.13], container: document.getElementById('bottom') });
const botRen = bot.getRenderer();
const botRW = bot.getRenderWindow();
const botMapper = vtkMapper.newInstance();
const botActor = vtkActor.newInstance();
botActor.setMapper(botMapper);
botActor.getProperty().setColor(0.7, 0.7, 0.72);
botRen.addActor(botActor);
// ---- section: optional 2D cross-section (mesh ∩ plane) via vtkCutter ----
const section = vtkFullScreenRenderWindow.newInstance({ background: [0.08, 0.08, 0.08], container: document.getElementById('section') });
const sectionRen = section.getRenderer();
const sectionRW = section.getRenderWindow();
sectionRW.getInteractor().setInteractorStyle(null);
const sectionMapper = vtkMapper.newInstance();
const sectionActor = vtkActor.newInstance();
sectionActor.setMapper(sectionMapper);
sectionActor.getProperty().setColor(1.0, 0.85, 0.2);
sectionActor.getProperty().setLineWidth(2);
sectionRen.addActor(sectionActor);
const cutter = vtkCutter.newInstance();
let render2D = false;
let thickness = 2;
let meshPoly = null;
function resizeAll() { topWin.resize(); bot.resize(); if (render2D) section.resize(); if (typeof redrawMarkers === 'function') redrawMarkers(); }
window.addEventListener('resize', resizeAll);
// axis basis: u = screen-right, v = screen-up, n = view normal (camera dir)
const AXES = {
XY: { n: [0, 0, 1], u: [1, 0, 0], v: [0, 1, 0] },
YZ: { n: [1, 0, 0], u: [0, 1, 0], v: [0, 0, 1] },
XZ: { n: [0, 1, 0], u: [1, 0, 0], v: [0, 0, 1] },
};
let axis = 'XY';
let picks = []; // world points (the two cut endpoints) -- kept persistent
let clipNormalSign = 1;
let lastPlane = null; // {origin, normal}
let bounds = [0, 1, 0, 1, 0, 1];
let locked = true; // true = fixed 2D ortho (pick/drag enabled); false = orbit
let dragging = false;
function center() { return [(bounds[0]+bounds[1])/2, (bounds[2]+bounds[3])/2, (bounds[4]+bounds[5])/2]; }
function diag() { return Math.hypot(bounds[1]-bounds[0], bounds[3]-bounds[2], bounds[5]-bounds[4]) || 1; }
function positionBottomCamera() {
const A = AXES[axis], c = center(), d = diag();
const cam = botRen.getActiveCamera();
cam.setParallelProjection(true);
cam.setFocalPoint(c[0], c[1], c[2]);
cam.setPosition(c[0] + A.n[0]*d, c[1] + A.n[1]*d, c[2] + A.n[2]*d);
cam.setViewUp(A.v[0], A.v[1], A.v[2]);
botRen.resetCamera();
botRW.render();
}
// --- SVG overlay over the bottom view: draggable endpoints + red cut line ---
const SVGNS = 'http://www.w3.org/2000/svg';
const overlay = document.createElementNS(SVGNS, 'svg');
overlay.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;pointer-events:none;';
document.getElementById('bottom').appendChild(overlay);
const cutLine = document.createElementNS(SVGNS, 'line');
cutLine.setAttribute('stroke', '#ff3b30'); cutLine.setAttribute('stroke-width', '2.5');
cutLine.style.display = 'none';
overlay.appendChild(cutLine);
const handles = [0, 1].map((i) => {
// big transparent grab area so near-misses still catch the handle
const hit = document.createElementNS(SVGNS, 'circle');
hit.setAttribute('r', '16'); hit.setAttribute('fill', 'transparent');
hit.style.cssText = 'pointer-events:all;cursor:grab;display:none;';
hit.addEventListener('mousedown', (ev) => { ev.preventDefault(); ev.stopPropagation(); startDrag(i); });
hit.addEventListener('click', (ev) => ev.stopPropagation());
const dot = document.createElementNS(SVGNS, 'circle');
dot.setAttribute('r', '7'); dot.setAttribute('fill', i === 0 ? '#34c759' : '#ff9f0a');
dot.setAttribute('stroke', '#fff'); dot.setAttribute('stroke-width', '2');
dot.style.cssText = 'pointer-events:none;display:none;';
overlay.appendChild(hit); overlay.appendChild(dot);
return { hit, dot };
});
const botInteractor = botRW.getInteractor();
// Lock = no interactor style (no rotation) but interactor stays ENABLED so the
// render loop keeps painting. Unlock swaps in a trackball style.
botInteractor.setInteractorStyle(null);
// forward map: world -> screen px (inverse of clickToWorld; valid only when locked/ortho)
function worldToScreen(world) {
const A = AXES[axis];
const cam = botRen.getActiveCamera();
const fp = cam.getFocalPoint();
const s = cam.getParallelScale();
const rect = document.getElementById('bottom').getBoundingClientRect();
const W = rect.width, H = rect.height, aspect = W / H;
const dw = [world[0]-fp[0], world[1]-fp[1], world[2]-fp[2]];
const ndcx = (dw[0]*A.u[0]+dw[1]*A.u[1]+dw[2]*A.u[2]) / (s*aspect);
const ndcy = (dw[0]*A.v[0]+dw[1]*A.v[1]+dw[2]*A.v[2]) / s;
return { x: (ndcx+1)/2*W, y: (1-ndcy)/2*H };
}
function redrawMarkers() {
const show = locked; // handles/line only track the mesh in the fixed ortho view
for (let i = 0; i < 2; i++) {
const h = handles[i];
if (show && picks[i]) {
const p = worldToScreen(picks[i]);
for (const el of [h.hit, h.dot]) { el.setAttribute('cx', p.x); el.setAttribute('cy', p.y); el.style.display = 'block'; }
} else { h.hit.style.display = 'none'; h.dot.style.display = 'none'; }
}
if (show && picks[0] && picks[1]) {
const a = worldToScreen(picks[0]), b = worldToScreen(picks[1]);
cutLine.setAttribute('x1', a.x); cutLine.setAttribute('y1', a.y);
cutLine.setAttribute('x2', b.x); cutLine.setAttribute('y2', b.y);
cutLine.style.display = 'block';
} else cutLine.style.display = 'none';
}
// click in bottom canvas -> world point on the focal plane (analytic, matches parallel cam)
function clickToWorld(ev) {
const A = AXES[axis];
const rect = document.getElementById('bottom').getBoundingClientRect();
const W = rect.width, H = rect.height;
const px = ev.clientX - rect.left, py = ev.clientY - rect.top;
const ndcx = (px / W) * 2 - 1;
const ndcy = 1 - (py / H) * 2;
const cam = botRen.getActiveCamera();
const fp = cam.getFocalPoint();
const s = cam.getParallelScale(); // half viewport height in world units
const aspect = W / H;
const out = [0, 0, 0];
for (let i = 0; i < 3; i++) {
out[i] = fp[i] + ndcx * s * aspect * A.u[i] + ndcy * s * A.v[i];
}
return out;
}
// Tell the parent frame (preview_mesh_slicer.js) what's currently clipped/shown in the
// top 3D view, so the Python node can reproduce the same cut on the actual mesh data
// (execute() has no other way to see interactive browser-only state like lastPlane).
function postPlaneUpdate() {
if (!lastPlane) { parent.postMessage({ type: 'PLANE_UPDATE', hasPlane: false }, '*'); return; }
const n = lastPlane.normal.map(x => x * clipNormalSign);
parent.postMessage({ type: 'PLANE_UPDATE', hasPlane: true, origin: lastPlane.origin, normal: n }, '*');
}
function applyClip() {
if (!lastPlane) return;
const n = lastPlane.normal.map(x => x * clipNormalSign);
topMapper.removeAllClippingPlanes();
const plane = vtkPlane.newInstance({ origin: lastPlane.origin, normal: n });
topMapper.addClippingPlane(plane);
topRW.render();
updateSection();
postPlaneUpdate();
}
// Quick camera navigation for the TOP 3D view (view-cube style): reposition the
// orbit camera to look along a cardinal axis, centered on the mesh. Does NOT touch
// the clip plane -- that's still only ever set via the "cut view" pick controls below.
function setCameraView(dir) {
const c = center(), d = diag();
const cam = topRen.getActiveCamera();
cam.setFocalPoint(c[0], c[1], c[2]);
cam.setPosition(c[0] + dir[0]*d*2, c[1] + dir[1]*d*2, c[2] + dir[2]*d*2);
// pick an up vector that isn't parallel to the view direction
const up = Math.abs(dir[1]) > 0.9 ? [0, 0, 1] : [0, 1, 0];
cam.setViewUp(up[0], up[1], up[2]);
topRen.resetCamera(); // keeps direction/up set above, just fits distance to bounds
topRW.render();
setStatus(`3d view: looking along [${dir.join(', ')}]`);
}
document.getElementById('qpx').onclick = () => setCameraView([1, 0, 0]);
document.getElementById('qnx').onclick = () => setCameraView([-1, 0, 0]);
document.getElementById('qpy').onclick = () => setCameraView([0, 1, 0]);
document.getElementById('qny').onclick = () => setCameraView([0, -1, 0]);
document.getElementById('qpz').onclick = () => setCameraView([0, 0, 1]);
document.getElementById('qnz').onclick = () => setCameraView([0, 0, -1]);
function updateSection() {
if (!render2D || !lastPlane || !meshPoly) return;
try {
const plane = vtkPlane.newInstance({ origin: lastPlane.origin, normal: lastPlane.normal });
cutter.setCutFunction(plane);
cutter.setInputData(meshPoly);
cutter.update();
sectionMapper.setInputData(cutter.getOutputData());
sectionActor.getProperty().setLineWidth(thickness);
// face-on camera: look along the plane normal
const n = lastPlane.normal, o = lastPlane.origin, d = diag();
const ref = Math.abs(n[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0];
let up = [ n[1]*ref[2]-n[2]*ref[1], n[2]*ref[0]-n[0]*ref[2], n[0]*ref[1]-n[1]*ref[0] ];
const ul = Math.hypot(up[0], up[1], up[2]) || 1; up = up.map(x => x/ul);
const cam = sectionRen.getActiveCamera();
cam.setParallelProjection(true);
cam.setFocalPoint(o[0], o[1], o[2]);
cam.setPosition(o[0]+n[0]*d, o[1]+n[1]*d, o[2]+n[2]*d);
cam.setViewUp(up[0], up[1], up[2]);
sectionRen.resetCamera();
sectionRW.render();
} catch (e) { console.warn('[Slicer] section update failed', e); }
}
function setPlaneFromPicks() {
if (picks.length < 2) return;
const A = AXES[axis];
const [p1, p2] = picks;
const dir = [p2[0]-p1[0], p2[1]-p1[1], p2[2]-p1[2]];
// normal = dir x axis (lies in view plane, perpendicular to the drawn line)
let nrm = [
dir[1]*A.n[2] - dir[2]*A.n[1],
dir[2]*A.n[0] - dir[0]*A.n[2],
dir[0]*A.n[1] - dir[1]*A.n[0],
];
const L = Math.hypot(nrm[0], nrm[1], nrm[2]);
if (L < 1e-9) { setStatus('points too close / colinear — move a handle'); return; }
nrm = nrm.map(x => x / L);
lastPlane = { origin: p1.slice(), normal: nrm };
applyClip();
setStatus(`clip on ${axis}. drag the dots to adjust · ⇄ flip · reset`);
}
function startDrag(i) {
if (!locked) return;
dragging = true;
const move = (e) => { picks[i] = clickToWorld(e); redrawMarkers(); setPlaneFromPicks(); };
const up = () => { dragging = false; window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); };
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
}
document.getElementById('bottom').addEventListener('click', (ev) => {
if (!locked || dragging || !botMapper.getInputData()) return;
const w = clickToWorld(ev);
if (picks.length >= 2) { // cut already set -- don't wipe it on a stray click
setStatus('cut set — drag the dots to adjust, or "reset clip" to start a new one');
return;
}
picks.push(w);
redrawMarkers();
if (picks.length === 2) setPlaneFromPicks();
else setStatus(`point 1 set on ${axis} — click the 2nd point (then drag the dots)`);
});
function setLocked(on) {
locked = on;
const b = document.getElementById('lock');
b.classList.toggle('active', on);
b.textContent = on ? '🔒 2D lock' : '🔓 rotate';
if (on) { positionBottomCamera(); botInteractor.setInteractorStyle(null); document.getElementById('bottom').style.cursor = 'crosshair'; }
else { botInteractor.setInteractorStyle(vtkInteractorStyleTrackballCamera.newInstance()); document.getElementById('bottom').style.cursor = 'grab'; }
botRW.render();
redrawMarkers();
setStatus(on ? `2D locked (${axis}) — click 2 points / drag dots to set the cut`
: 'view unlocked — drag to orbit & inspect; lock to edit the cut');
}
function setAxis(a) {
axis = a; picks = [];
for (const k of ['XY', 'YZ', 'XZ']) document.getElementById('ax' + k).classList.toggle('active', k === a);
setLocked(true); // axis pick implies locked 2D
setStatus(`cut view: ${a}. click 2 points in the bottom view.`);
}
document.getElementById('axXY').onclick = () => setAxis('XY');
document.getElementById('axYZ').onclick = () => setAxis('YZ');
document.getElementById('axXZ').onclick = () => setAxis('XZ');
document.getElementById('lock').onclick = () => setLocked(!locked);
document.getElementById('flip').onclick = () => { clipNormalSign *= -1; applyClip(); };
document.getElementById('reset').onclick = () => { lastPlane = null; picks = []; topMapper.removeAllClippingPlanes(); topRW.render(); redrawMarkers(); setStatus('clip cleared'); postPlaneUpdate(); };
document.getElementById('r2d').onclick = () => {
render2D = !render2D;
document.getElementById('r2d').classList.toggle('active', render2D);
document.getElementById('section').style.display = render2D ? 'block' : 'none';
if (render2D) setTimeout(() => { section.resize(); updateSection(); }, 30);
setStatus(render2D ? 'cross-section view on (mesh ∩ plane)' : 'cross-section view off');
};
document.getElementById('thick').addEventListener('input', (e) => {
thickness = Math.max(1, Number(e.target.value) || 2);
sectionActor.getProperty().setLineWidth(thickness);
sectionRW.render();
});
async function loadMesh(url) {
setStatus('loading mesh…');
const reader = vtkXMLPolyDataReader.newInstance();
const resp = await fetch(url);
if (!resp.ok) { setStatus('load failed: HTTP ' + resp.status); return; }
const ab = await resp.arrayBuffer();
reader.parseAsArrayBuffer(ab);
const poly = reader.getOutputData(0);
meshPoly = poly;
topMapper.setInputData(poly);
topMapper.setScalarVisibility(false);
botMapper.setInputData(poly);
botMapper.setScalarVisibility(false);
bounds = poly.getBounds();
lastPlane = null; clipNormalSign = 1; picks = [];
topMapper.removeAllClippingPlanes();
topRen.resetCamera();
topRW.render();
setLocked(true); // positions the ortho cam + redraws markers
setStatus('mesh loaded — pick cut view (XY/YZ/XZ), click 2 points, drag the dots to adjust.');
postPlaneUpdate(); // a new mesh invalidates any previous plane the parent had cached
}
window.addEventListener('message', (event) => {
const d = event.data || {};
if (d.type === 'LOAD_MESH' && d.filepath) loadMesh(d.filepath);
});
setTimeout(resizeAll, 50);
console.log('[GeomPack Slicer] ready');
</script>
</body>
</html>
+733
View File
@@ -0,0 +1,733 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>GeomPack UV Viewer</title>
<style>
body {
margin: 0;
overflow: hidden;
background: #2a2a2a;
font-family: monospace;
}
#container {
display: flex;
width: 100%;
height: 100vh;
}
.view-panel {
flex: 1;
position: relative;
border: 1px solid #444;
}
.view-label {
position: absolute;
top: 5px;
left: 5px;
color: #888;
font-size: 11px;
z-index: 10;
background: rgba(0,0,0,0.5);
padding: 2px 6px;
border-radius: 3px;
}
canvas {
width: 100%;
height: 100%;
display: block;
}
#loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 14px;
text-align: center;
z-index: 100;
}
#info {
position: absolute;
bottom: 5px;
left: 5px;
color: #aaa;
font-size: 10px;
z-index: 10;
background: rgba(0,0,0,0.7);
padding: 4px 8px;
border-radius: 3px;
}
.highlight-point {
position: absolute;
width: 10px;
height: 10px;
background: #ff4444;
border-radius: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
z-index: 20;
box-shadow: 0 0 8px #ff4444;
}
</style>
</head>
<body>
<div id="container">
<div class="view-panel" id="mesh-panel">
<div class="view-label">3D Mesh</div>
<canvas id="mesh-canvas"></canvas>
</div>
<div class="view-panel" id="uv-panel">
<div class="view-label">UV Layout</div>
<canvas id="uv-canvas"></canvas>
</div>
</div>
<div id="loading">Ready to load mesh</div>
<div id="info"></div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
console.log('[GeomPack UV Viewer] Initializing...');
const loading = document.getElementById('loading');
const infoDiv = document.getElementById('info');
const meshCanvas = document.getElementById('mesh-canvas');
const uvCanvas = document.getElementById('uv-canvas');
const meshPanel = document.getElementById('mesh-panel');
const uvPanel = document.getElementById('uv-panel');
// Configuration
let showChecker = false;
let showWireframe = true;
// 3D Mesh Scene Setup
const meshScene = new THREE.Scene();
meshScene.background = new THREE.Color(0x2a2a2a);
const meshCamera = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
meshCamera.position.set(2, 2, 2);
const meshRenderer = new THREE.WebGLRenderer({ canvas: meshCanvas, antialias: true });
meshRenderer.setPixelRatio(window.devicePixelRatio);
const meshControls = new OrbitControls(meshCamera, meshCanvas);
meshControls.enableDamping = true;
meshControls.dampingFactor = 0.05;
meshControls.zoomSpeed = 3.0;
// Lighting for 3D mesh
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
meshScene.add(ambientLight);
const dirLight1 = new THREE.DirectionalLight(0xffffff, 0.8);
dirLight1.position.set(5, 10, 7);
meshScene.add(dirLight1);
const dirLight2 = new THREE.DirectionalLight(0xffffff, 0.4);
dirLight2.position.set(-5, -5, -7);
meshScene.add(dirLight2);
// Grid and axes for mesh view
const grid = new THREE.GridHelper(10, 10, 0x444444, 0x222222);
meshScene.add(grid);
const axes = new THREE.AxesHelper(1);
meshScene.add(axes);
// 2D UV Scene Setup
const uvScene = new THREE.Scene();
uvScene.background = new THREE.Color(0x1a1a1a);
const uvCamera = new THREE.OrthographicCamera(0, 1, 1, 0, -1, 1);
uvCamera.position.z = 0.5;
const uvRenderer = new THREE.WebGLRenderer({ canvas: uvCanvas, antialias: true });
uvRenderer.setPixelRatio(window.devicePixelRatio);
// UV grid (0-1 space)
const uvGridHelper = new THREE.GridHelper(1, 10, 0x444444, 0x333333);
uvGridHelper.rotation.x = Math.PI / 2;
uvGridHelper.position.set(0.5, 0.5, -0.1);
uvScene.add(uvGridHelper);
// UV bounds box
const uvBoundsGeom = new THREE.BufferGeometry();
const uvBoundsVerts = new Float32Array([
0, 0, 0, 1, 0, 0,
1, 0, 0, 1, 1, 0,
1, 1, 0, 0, 1, 0,
0, 1, 0, 0, 0, 0
]);
uvBoundsGeom.setAttribute('position', new THREE.BufferAttribute(uvBoundsVerts, 3));
const uvBoundsMat = new THREE.LineBasicMaterial({ color: 0x666666 });
const uvBoundsLine = new THREE.LineSegments(uvBoundsGeom, uvBoundsMat);
uvScene.add(uvBoundsLine);
// Current mesh and UV data
let currentMesh = null;
let currentWireframe = null;
let uvLinesMesh = null;
let uvData = null;
let meshVertices = null;
let meshFaces = null;
// Raycaster for picking
const meshRaycaster = new THREE.Raycaster();
const uvRaycaster = new THREE.Raycaster();
// Highlight markers
let meshHighlightSphere = null; // 3D sphere in scene
let uvHighlight = null;
let highlightedPoint3D = null; // Store 3D world position
let highlightedPointUV = null; // Store UV position
// Create highlight sphere for 3D mesh
const highlightGeom = new THREE.SphereGeometry(1, 16, 16);
const highlightMat = new THREE.MeshBasicMaterial({
color: 0xff4444,
transparent: true,
opacity: 0.9,
depthTest: true,
depthWrite: true
});
// Create crosshair lines for highlighting (unused - kept for reference)
// function createCrosshair() {
// const lineGeom = new THREE.BufferGeometry();
// const positions = new Float32Array([
// -2, 0, 0, 2, 0, 0, // X axis (red)
// 0, -2, 0, 0, 2, 0, // Y axis (green)
// 0, 0, -2, 0, 0, 2 // Z axis (blue)
// ]);
// lineGeom.setAttribute('position', new THREE.BufferAttribute(positions, 3));
//
// // Create different colors for each axis
// const colors = new Float32Array([
// 1, 0, 0, 1, 0, 0, // X axis - red
// 0, 1, 0, 0, 1, 0, // Y axis - green
// 0, 0, 1, 0, 0, 1 // Z axis - blue
// ]);
// lineGeom.setAttribute('color', new THREE.BufferAttribute(colors, 3));
//
// const lineMat = new THREE.LineBasicMaterial({
// vertexColors: true,
// linewidth: 2,
// depthTest: true,
// depthWrite: true,
// transparent: true,
// opacity: 0.8
// });
//
// return new THREE.LineSegments(lineGeom, lineMat);
// }
// Update highlight positions (call in animation loop)
function updateHighlightPositions() {
// Sphere position is already set, no need to update
}
// Animation loop
function animate() {
requestAnimationFrame(animate);
meshControls.update();
updateHighlightPositions();
meshRenderer.render(meshScene, meshCamera);
uvRenderer.render(uvScene, uvCamera);
}
animate();
// Handle window resize
function updateSizes() {
const meshRect = meshPanel.getBoundingClientRect();
const uvRect = uvPanel.getBoundingClientRect();
meshCamera.aspect = meshRect.width / meshRect.height;
meshCamera.updateProjectionMatrix();
meshRenderer.setSize(meshRect.width, meshRect.height);
// Update UV camera to maintain aspect ratio
const uvAspect = uvRect.width / uvRect.height;
if (uvAspect > 1) {
uvCamera.left = -0.1;
uvCamera.right = 1.1;
uvCamera.top = 0.6 + 0.5 / uvAspect;
uvCamera.bottom = 0.4 - 0.5 / uvAspect;
} else {
uvCamera.left = 0.5 - 0.5 * uvAspect - 0.1;
uvCamera.right = 0.5 + 0.5 * uvAspect + 0.1;
uvCamera.top = 1.1;
uvCamera.bottom = -0.1;
}
uvCamera.updateProjectionMatrix();
uvRenderer.setSize(uvRect.width, uvRect.height);
}
window.addEventListener('resize', updateSizes);
setTimeout(updateSizes, 100);
// Create checker texture
function createCheckerTexture(size = 512, squares = 16) {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
const squareSize = size / squares;
for (let i = 0; i < squares; i++) {
for (let j = 0; j < squares; j++) {
ctx.fillStyle = (i + j) % 2 === 0 ? '#888888' : '#444444';
ctx.fillRect(i * squareSize, j * squareSize, squareSize, squareSize);
}
}
const texture = new THREE.CanvasTexture(canvas);
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
return texture;
}
// Load mesh
function loadMesh(filepath, uvDataPath) {
console.log('[GeomPack UV Viewer] Loading mesh:', filepath);
loading.textContent = 'Loading mesh...';
loading.style.display = 'block';
// Remove old mesh
if (currentMesh) {
meshScene.remove(currentMesh);
currentMesh = null;
}
if (currentWireframe) {
meshScene.remove(currentWireframe);
currentWireframe = null;
}
if (uvLinesMesh) {
uvScene.remove(uvLinesMesh);
uvLinesMesh = null;
}
const isGLB = filepath.includes('.glb') || filepath.includes('.gltf');
const loader = isGLB ? new GLTFLoader() : new OBJLoader();
loader.load(
filepath,
(result) => {
currentMesh = isGLB ? result.scene : result;
// Center mesh
const box = new THREE.Box3().setFromObject(currentMesh);
const center = box.getCenter(new THREE.Vector3());
currentMesh.position.sub(center);
// Store mesh geometry for picking
currentMesh.traverse((child) => {
if (child.isMesh) {
meshVertices = child.geometry.attributes.position.array;
if (child.geometry.index) {
meshFaces = child.geometry.index.array;
}
// Apply material
if (showChecker) {
child.material = new THREE.MeshStandardMaterial({
map: createCheckerTexture(),
metalness: 0.1,
roughness: 0.9,
side: THREE.DoubleSide
});
} else {
child.material = new THREE.MeshStandardMaterial({
color: 0x88aacc,
metalness: 0.3,
roughness: 0.7,
side: THREE.DoubleSide
});
}
// Add wireframe overlay as child of mesh (inherits transforms)
if (showWireframe) {
const wireframeGeom = new THREE.WireframeGeometry(child.geometry);
const wireframeMat = new THREE.LineBasicMaterial({
color: 0x000000,
opacity: 0.3,
transparent: true
});
currentWireframe = new THREE.LineSegments(wireframeGeom, wireframeMat);
// Add as child of the mesh child so it inherits position/rotation
child.add(currentWireframe);
}
}
});
meshScene.add(currentMesh);
// Adjust camera
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const distance = maxDim * 2.5;
meshCamera.position.set(distance, distance, distance);
meshCamera.lookAt(0, 0, 0);
meshControls.target.set(0, 0, 0);
meshControls.update();
loading.textContent = 'Loading UV data...';
// Load UV data
if (uvDataPath) {
fetch(uvDataPath)
.then(response => response.json())
.then(data => {
uvData = data;
createUVLayout(data);
loading.style.display = 'none';
updateInfo();
console.log('[GeomPack UV Viewer] UV data loaded:', data.uvs.length, 'UVs');
})
.catch(err => {
console.error('[GeomPack UV Viewer] Error loading UV data:', err);
loading.textContent = 'No UV data available';
setTimeout(() => { loading.style.display = 'none'; }, 2000);
});
} else {
loading.textContent = 'No UV data';
setTimeout(() => { loading.style.display = 'none'; }, 2000);
}
console.log('[GeomPack UV Viewer] Mesh Loaded successfully');
},
(progress) => {
if (progress.lengthComputable) {
const percent = (progress.loaded / progress.total * 100).toFixed(0);
loading.textContent = `Loading mesh... ${percent}%`;
}
},
(error) => {
console.error('[GeomPack UV Viewer] Error loading mesh:', error);
loading.textContent = 'Error loading mesh';
loading.style.color = '#ff6666';
}
);
}
// Create UV layout wireframe
function createUVLayout(data) {
const uvs = data.uvs;
const faces = data.faces;
// Create line segments for UV triangles
const positions = [];
for (const face of faces) {
const uv0 = uvs[face[0]];
const uv1 = uvs[face[1]];
const uv2 = uvs[face[2]];
// Edge 0-1
positions.push(uv0[0], uv0[1], 0);
positions.push(uv1[0], uv1[1], 0);
// Edge 1-2
positions.push(uv1[0], uv1[1], 0);
positions.push(uv2[0], uv2[1], 0);
// Edge 2-0
positions.push(uv2[0], uv2[1], 0);
positions.push(uv0[0], uv0[1], 0);
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
const material = new THREE.LineBasicMaterial({
color: 0x44aaff,
opacity: 0.8,
transparent: true
});
uvLinesMesh = new THREE.LineSegments(geometry, material);
uvScene.add(uvLinesMesh);
}
// Update info display
function updateInfo() {
let info = '';
if (uvData) {
info = `UVs: ${uvData.uvs.length} | Faces: ${uvData.faces.length}`;
}
infoDiv.textContent = info;
}
// Highlight corresponding points on click
function highlightPoint(point3D, pointUV) {
// Remove old highlights
if (meshHighlightSphere) {
meshScene.remove(meshHighlightSphere);
meshHighlightSphere = null;
}
if (uvHighlight) {
uvPanel.removeChild(uvHighlight);
uvHighlight = null;
}
// Store positions for continuous updates
highlightedPoint3D = point3D ? point3D.clone() : null;
highlightedPointUV = pointUV ? pointUV.clone() : null;
// Create 3D mesh highlight sphere
if (point3D) {
meshHighlightSphere = new THREE.Mesh(highlightGeom, highlightMat);
meshHighlightSphere.position.copy(point3D);
// Scale sphere based on mesh size
if (currentMesh) {
const box = new THREE.Box3().setFromObject(currentMesh);
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const scale = maxDim * 0.03; // 3% of mesh size (slightly larger for better visibility)
meshHighlightSphere.scale.setScalar(scale);
}
// Ensure sphere always renders on top
meshHighlightSphere.renderOrder = 999;
meshScene.add(meshHighlightSphere);
console.log('[GeomPack UV Viewer] Sphere placed at:', point3D.x, point3D.y, point3D.z, 'scale:', meshHighlightSphere.scale.x);
}
// Create UV highlight
if (pointUV) {
const uvRect = uvCanvas.getBoundingClientRect();
// Convert UV coords to screen coords
const ndcX = (pointUV.x - uvCamera.left) / (uvCamera.right - uvCamera.left);
const ndcY = (pointUV.y - uvCamera.bottom) / (uvCamera.top - uvCamera.bottom);
const x = ndcX * uvRect.width;
const y = (1 - ndcY) * uvRect.height;
uvHighlight = document.createElement('div');
uvHighlight.className = 'highlight-point';
uvHighlight.style.left = x + 'px';
uvHighlight.style.top = y + 'px';
uvPanel.appendChild(uvHighlight);
}
}
// Mesh canvas click detection (using mousedown/mouseup to avoid OrbitControls interference)
let meshMouseDownPos = null;
let meshMouseDownTime = 0;
meshCanvas.addEventListener('mousedown', (event) => {
meshMouseDownPos = new THREE.Vector2(event.clientX, event.clientY);
meshMouseDownTime = Date.now();
console.log('[GeomPack UV Viewer DEBUG] Mesh mousedown at:', event.clientX, event.clientY);
});
meshCanvas.addEventListener('mouseup', (event) => {
console.log('[GeomPack UV Viewer DEBUG] Mesh mouseup - currentMesh:', !!currentMesh, 'uvData:', !!uvData, 'mouseDownPos:', !!meshMouseDownPos);
if (!currentMesh || !uvData || !meshMouseDownPos) return;
const mouseUpPos = new THREE.Vector2(event.clientX, event.clientY);
const dragDistance = meshMouseDownPos.distanceTo(mouseUpPos);
const dragTime = Date.now() - meshMouseDownTime;
console.log('[GeomPack UV Viewer DEBUG] Drag distance:', dragDistance.toFixed(2), 'px, Time:', dragTime, 'ms');
// Only treat as click if mouse moved less than 5 pixels and took less than 300ms
if (dragDistance < 5 && dragTime < 300) {
console.log('[GeomPack UV Viewer DEBUG] Click threshold met, raycasting...');
const rect = meshCanvas.getBoundingClientRect();
const mouse = new THREE.Vector2(
((event.clientX - rect.left) / rect.width) * 2 - 1,
-((event.clientY - rect.top) / rect.height) * 2 + 1
);
meshRaycaster.setFromCamera(mouse, meshCamera);
const intersects = meshRaycaster.intersectObject(currentMesh, true);
console.log('[GeomPack UV Viewer DEBUG] Intersections found:', intersects.length);
if (intersects.length > 0) {
const hit = intersects[0];
const hitMesh = hit.object;
const faceIndex = hit.faceIndex;
console.log('[GeomPack UV Viewer DEBUG] Hit face index:', faceIndex);
// Get the 3 vertex positions from the hit face
const geometry = hitMesh.geometry;
const position = geometry.attributes.position;
const index = geometry.index;
if (index && faceIndex * 3 + 2 < index.count) {
const i0 = index.array[faceIndex * 3];
const i1 = index.array[faceIndex * 3 + 1];
const i2 = index.array[faceIndex * 3 + 2];
// Get vertex positions in local space
const v0 = new THREE.Vector3(position.getX(i0), position.getY(i0), position.getZ(i0));
const v1 = new THREE.Vector3(position.getX(i1), position.getY(i1), position.getZ(i1));
const v2 = new THREE.Vector3(position.getX(i2), position.getY(i2), position.getZ(i2));
console.log('[GeomPack UV Viewer DEBUG] Hit face vertices:', v0.toArray(), v1.toArray(), v2.toArray());
// Find matching face in uvData by comparing vertex positions
let matchedFaceIdx = -1;
const tolerance = 0.0001;
currentMesh.traverse((child) => {
if (child.isMesh && child.geometry && matchedFaceIdx === -1) {
const childPos = child.geometry.attributes.position;
// Search through all faces to find matching vertices
for (let fIdx = 0; fIdx < uvData.faces.length; fIdx++) {
const face = uvData.faces[fIdx];
const fv0 = new THREE.Vector3(childPos.getX(face[0]), childPos.getY(face[0]), childPos.getZ(face[0]));
const fv1 = new THREE.Vector3(childPos.getX(face[1]), childPos.getY(face[1]), childPos.getZ(face[1]));
const fv2 = new THREE.Vector3(childPos.getX(face[2]), childPos.getY(face[2]), childPos.getZ(face[2]));
// Check if vertices match (in any order)
if ((v0.distanceTo(fv0) < tolerance && v1.distanceTo(fv1) < tolerance && v2.distanceTo(fv2) < tolerance) ||
(v0.distanceTo(fv0) < tolerance && v1.distanceTo(fv2) < tolerance && v2.distanceTo(fv1) < tolerance) ||
(v0.distanceTo(fv1) < tolerance && v1.distanceTo(fv0) < tolerance && v2.distanceTo(fv2) < tolerance) ||
(v0.distanceTo(fv1) < tolerance && v1.distanceTo(fv2) < tolerance && v2.distanceTo(fv0) < tolerance) ||
(v0.distanceTo(fv2) < tolerance && v1.distanceTo(fv0) < tolerance && v2.distanceTo(fv1) < tolerance) ||
(v0.distanceTo(fv2) < tolerance && v1.distanceTo(fv1) < tolerance && v2.distanceTo(fv0) < tolerance)) {
matchedFaceIdx = fIdx;
break;
}
}
}
});
if (matchedFaceIdx >= 0) {
const face = uvData.faces[matchedFaceIdx];
console.log('[GeomPack UV Viewer DEBUG] Matched to uvData face index:', matchedFaceIdx);
// Interpolate UV coordinates
const uv0 = uvData.uvs[face[0]];
const uv1 = uvData.uvs[face[1]];
const uv2 = uvData.uvs[face[2]];
// Simple center of triangle for now
const uvPoint = new THREE.Vector2(
(uv0[0] + uv1[0] + uv2[0]) / 3,
(uv0[1] + uv1[1] + uv2[1]) / 3
);
highlightPoint(hit.point, uvPoint);
console.log('[GeomPack UV Viewer] Clicked face:', matchedFaceIdx, 'UV:', uvPoint);
} else {
console.log('[GeomPack UV Viewer DEBUG] No matching face found in uvData');
}
} else {
console.log('[GeomPack UV Viewer DEBUG] Invalid face index or geometry');
}
} else {
console.log('[GeomPack UV Viewer DEBUG] No intersections - missed the mesh');
}
} else {
console.log('[GeomPack UV Viewer DEBUG] Click threshold NOT met (drag or too slow)');
}
meshMouseDownPos = null;
});
// UV canvas click handler
uvCanvas.addEventListener('click', (event) => {
if (!currentMesh || !uvData) return;
const rect = uvCanvas.getBoundingClientRect();
const x = (event.clientX - rect.left) / rect.width;
const y = (event.clientY - rect.top) / rect.height;
// Convert screen coords to UV space
const uvX = uvCamera.left + x * (uvCamera.right - uvCamera.left);
const uvY = uvCamera.top - y * (uvCamera.top - uvCamera.bottom);
// Find nearest face in UV space
let nearestFace = -1;
let nearestDist = Infinity;
for (let i = 0; i < uvData.faces.length; i++) {
const face = uvData.faces[i];
const uv0 = uvData.uvs[face[0]];
const uv1 = uvData.uvs[face[1]];
const uv2 = uvData.uvs[face[2]];
// Check if point is inside triangle (simplified - just use center distance)
const centerX = (uv0[0] + uv1[0] + uv2[0]) / 3;
const centerY = (uv0[1] + uv1[1] + uv2[1]) / 3;
const dist = Math.sqrt((uvX - centerX) ** 2 + (uvY - centerY) ** 2);
if (dist < nearestDist) {
nearestDist = dist;
nearestFace = i;
}
}
if (nearestFace >= 0 && nearestDist < 0.1) {
// Get 3D position of face center
const face = uvData.faces[nearestFace];
const uv0 = uvData.uvs[face[0]];
const uv1 = uvData.uvs[face[1]];
const uv2 = uvData.uvs[face[2]];
const uvPoint = new THREE.Vector2(
(uv0[0] + uv1[0] + uv2[0]) / 3,
(uv0[1] + uv1[1] + uv2[1]) / 3
);
// Get 3D face center from mesh
let point3D = null;
currentMesh.traverse((child) => {
if (child.isMesh && child.geometry) {
const pos = child.geometry.attributes.position;
const idx = child.geometry.index;
if (idx && nearestFace * 3 + 2 < idx.count) {
const i0 = idx.array[nearestFace * 3];
const i1 = idx.array[nearestFace * 3 + 1];
const i2 = idx.array[nearestFace * 3 + 2];
const v0 = new THREE.Vector3(pos.getX(i0), pos.getY(i0), pos.getZ(i0));
const v1 = new THREE.Vector3(pos.getX(i1), pos.getY(i1), pos.getZ(i1));
const v2 = new THREE.Vector3(pos.getX(i2), pos.getY(i2), pos.getZ(i2));
point3D = new THREE.Vector3()
.addVectors(v0, v1)
.add(v2)
.divideScalar(3);
// Transform from local object space to world space
child.localToWorld(point3D);
}
}
});
highlightPoint(point3D, uvPoint);
console.log('[GeomPack UV Viewer] Clicked UV:', uvX.toFixed(3), uvY.toFixed(3), 'Face:', nearestFace);
}
});
// Listen for messages from parent window
window.addEventListener('message', (event) => {
console.log('[GeomPack UV Viewer] Received message:', event.data);
if (event.data.type === 'LOAD_MESH_UV') {
const { meshPath, uvDataPath, checker, wireframe } = event.data;
showChecker = checker || false;
showWireframe = wireframe !== false;
loadMesh(meshPath, uvDataPath);
}
});
console.log('[GeomPack UV Viewer] Ready');
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+461
View File
@@ -0,0 +1,461 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>GeomPack VTK.js Textured Viewer (Unified)</title>
<link rel="stylesheet" href="./js/viewer/ui/styles.css">
<style>
/* Minimal overrides for textured viewer */
#infoPanel {
position: absolute;
top: 10px;
left: 10px;
background: rgba(0, 0, 0, 0.7);
padding: 8px 12px;
border-radius: 4px;
color: #ccc;
font-size: 11px;
z-index: 100;
}
#infoPanel div {
margin: 2px 0;
}
.info-label { color: #888; }
.info-value { color: #4fc3f7; }
.info-warning { color: #ffb74d; }
</style>
</head>
<body>
<div id="viewerContainer">
<div id="container"></div>
<div id="loading" class="loading">
<div class="spinner"></div>
<div class="loading-text">Ready to load textured mesh</div>
</div>
<!-- Info panel for texture/material status -->
<div id="infoPanel" style="display: none;">
<div><span class="info-label">Visual:</span> <span id="infoVisual" class="info-value">-</span></div>
<div><span class="info-label">Texture:</span> <span id="infoTexture" class="info-value">-</span></div>
<div><span class="info-label">Materials:</span> <span id="infoMaterial" class="info-value">-</span></div>
<div id="warningRow" style="display: none;"><span class="info-warning">PBR requires WebGPU</span></div>
</div>
</div>
<div id="controls">
<!-- Settings button -->
<button id="settingsButton" class="settings-button" title="Appearance Settings">[Settings]</button>
<span class="control-separator"></span>
<label class="control-label">
<input type="checkbox" id="showEdges"> Edges
</label>
<span class="control-separator"></span>
<!-- Camera view buttons -->
<button class="view-button" data-view="+X">+X</button>
<button class="view-button" data-view="-X">-X</button>
<button class="view-button" data-view="+Y">+Y</button>
<button class="view-button" data-view="-Y">-Y</button>
<button class="view-button" data-view="+Z">+Z</button>
<button class="view-button" data-view="-Z">-Z</button>
<span class="control-separator"></span>
<button id="screenshot">Screenshot</button>
<button id="saveMesh" class="save-button">Save GLB</button>
</div>
<!-- Settings overlay and panel -->
<div id="settingsOverlay" class="settings-overlay"></div>
<div id="settingsPanel" class="settings-panel">
<h3>Appearance Settings</h3>
<div class="setting-row">
<label>Opacity:</label>
<input type="number" id="opacity" min="0" max="1" step="0.1" value="1.0">
</div>
<div class="setting-row">
<label>Render Mode:</label>
<select id="representationSettings">
<option value="2">Surface</option>
<option value="1">Wireframe</option>
<option value="0">Points</option>
</select>
</div>
<div class="setting-row">
<label>
<input type="checkbox" id="parallelProjection"> Parallel Projection
</label>
</div>
<div class="button-row">
<button id="applySettings">Apply</button>
<button id="cancelSettings">Cancel</button>
</div>
</div>
<!-- Load VTK.js bundle with GLTFImporter support -->
<script src="./js/vtk-gltf.js"></script>
<!-- Load unified viewer bundle -->
<script src="./js/viewer-bundle-vtk.js"></script>
<script>
// Use the unified viewer module
const {
CameraController,
ActorManager,
TextureManager,
ScreenshotCapture,
AxisIndicator,
loadMesh,
detectFormat,
sendMeshLoaded,
sendError,
extractFilename,
getCenter,
getMaxDimension,
isValidBounds,
rgbToString
} = window.GeomPackViewer;
// DOM elements
const loading = document.getElementById('loading');
const loadingText = loading.querySelector('.loading-text');
const container = document.getElementById('container');
const infoPanel = document.getElementById('infoPanel');
// VTK setup
const vtkRenderWindow = vtk.Rendering.Core.vtkRenderWindow;
const vtkRenderer = vtk.Rendering.Core.vtkRenderer;
const vtkOpenGLRenderWindow = vtk.Rendering.OpenGL.vtkRenderWindow;
const vtkRenderWindowInteractor = vtk.Rendering.Core.vtkRenderWindowInteractor;
const vtkInteractorStyleTrackballCamera = vtk.Interaction.Style.vtkInteractorStyleTrackballCamera;
// Create render window
const renderWindow = vtkRenderWindow.newInstance();
const renderer = vtkRenderer.newInstance();
renderer.setBackground(0.16, 0.16, 0.16);
renderer.setTwoSidedLighting(true);
renderer.setUseShadows(false);
renderWindow.addRenderer(renderer);
// Create OpenGL view
const openGLRenderWindow = vtkOpenGLRenderWindow.newInstance();
openGLRenderWindow.setContainer(container);
renderWindow.addView(openGLRenderWindow);
// Resize to container
const { width, height } = container.getBoundingClientRect();
openGLRenderWindow.setSize(width, height);
// Create interactor
const interactor = vtkRenderWindowInteractor.newInstance();
interactor.setView(openGLRenderWindow);
interactor.initialize();
interactor.bindEvents(container);
const interactorStyle = vtkInteractorStyleTrackballCamera.newInstance();
interactor.setInteractorStyle(interactorStyle);
// Initialize modules
const cameraController = new CameraController(renderer, renderWindow, {
distanceMultiplier: 2.0 // Closer for texture detail
});
const actorManager = new ActorManager(renderWindow);
const textureManager = new TextureManager(vtk, renderWindow);
const screenshotCapture = new ScreenshotCapture(renderWindow);
// Create axis indicator
const axisIndicator = new AxisIndicator(vtk, interactor, {
enabled: true,
corner: 'BOTTOM_LEFT'
});
// State
let currentActors = [];
let meshBounds = null;
let currentFilename = null;
// Check GLTF support
const gltfSupported = vtk._gltfAvailable && vtk.IO.Geometry.vtkGLTFImporter !== null;
// Show loading
function showLoading(show, text = 'Loading textured mesh...') {
loading.style.display = show ? 'flex' : 'none';
loadingText.textContent = text;
}
// Send camera state to parent node widget
let _cameraWheelTimer = null;
function sendCameraState() {
const cam = renderer.getActiveCamera();
window.parent.postMessage({
type: 'WIDGET_UPDATE',
widget: 'camera_state',
value: JSON.stringify({
position: cam.getPosition(),
focalPoint: cam.getFocalPoint(),
viewUp: cam.getViewUp()
})
}, '*');
}
function applyCameraState(cameraState) {
if (!cameraState) return false;
try {
const cs = typeof cameraState === 'string' ? JSON.parse(cameraState) : cameraState;
if (!cs.position || !cs.focalPoint || !cs.viewUp) return false;
const cam = renderer.getActiveCamera();
cam.setPosition(...cs.position);
cam.setFocalPoint(...cs.focalPoint);
cam.setViewUp(...cs.viewUp);
renderer.resetCameraClippingRange();
return true;
} catch(e) { return false; }
}
interactor.onEndAnimation(() => sendCameraState());
container.addEventListener('wheel', () => {
clearTimeout(_cameraWheelTimer);
_cameraWheelTimer = setTimeout(sendCameraState, 150);
});
let _pendingCameraState = null;
// Load mesh function
async function loadTexturedMesh(filepath, metadata = {}) {
showLoading(true, 'Loading textured mesh...');
// Extract filename
const urlParams = new URLSearchParams(filepath.split('?')[1] || '');
currentFilename = urlParams.get('filename') || filepath.split('/').pop().split('?')[0];
// Update info panel
if (metadata.visual_kind) {
document.getElementById('infoVisual').textContent = metadata.visual_kind;
}
if (metadata.has_texture !== undefined) {
document.getElementById('infoTexture').textContent = metadata.has_texture ? 'Yes' : 'No';
}
if (metadata.has_material !== undefined) {
document.getElementById('infoMaterial').textContent = metadata.has_material ? 'Yes' : 'No';
}
infoPanel.style.display = 'block';
// Show warning if textures but no GLTF support
if (!gltfSupported && metadata.has_texture) {
document.getElementById('warningRow').style.display = 'block';
}
try {
// Clear previous actors
currentActors.forEach(actor => renderer.removeActor(actor));
currentActors = [];
// Load mesh using unified loader
const result = await loadMesh(filepath, vtk, {
renderer: renderer,
withTextures: true,
twoSidedLighting: true
});
currentActors = result.actors;
// Add actors to renderer (if not already added by GLTF loader)
result.actors.forEach(actor => {
if (!renderer.getActors().includes(actor)) {
renderer.addActor(actor);
}
});
// Apply texture fixes only for textured meshes (not vertex-colored ones)
if (result.hasTexture) {
textureManager.applyTextureFixesMultiple(result.actors);
textureManager.configureRenderer(renderer);
} else if (result.hasVertexColors) {
// For vertex-colored meshes, just configure renderer
textureManager.configureRenderer(renderer);
}
// Calculate bounds
if (result.actors.length > 0) {
meshBounds = result.actors[0].getBounds();
for (let i = 1; i < result.actors.length; i++) {
const b = result.actors[i].getBounds();
meshBounds = [
Math.min(meshBounds[0], b[0]),
Math.max(meshBounds[1], b[1]),
Math.min(meshBounds[2], b[2]),
Math.max(meshBounds[3], b[3]),
Math.min(meshBounds[4], b[4]),
Math.max(meshBounds[5], b[5])
];
}
}
// Position camera (restore saved state or auto-position)
if (!applyCameraState(_pendingCameraState)) {
renderer.resetCamera();
}
renderWindow.render();
// Second render for proper texture display
setTimeout(() => {
if (!_pendingCameraState) renderer.resetCamera();
_pendingCameraState = null;
renderWindow.render();
}, 100);
showLoading(false);
// Notify parent
sendMeshLoaded({
filename: currentFilename,
format: result.format,
hasTexture: result.hasTexture || false,
actors: result.actors.length
});
return true;
} catch (error) {
console.error('[GeomPack VTK Textured] Load failed:', error);
showLoading(true, 'Failed to load mesh: ' + error.message);
sendError(error.message);
return false;
}
}
// Camera view function
function setCameraView(direction) {
if (!meshBounds) return;
cameraController.setCameraView(direction, meshBounds);
renderWindow.render();
}
// Event listeners - Camera views
document.querySelectorAll('.view-button').forEach(button => {
button.addEventListener('click', () => {
setCameraView(button.dataset.view);
});
});
// Edges toggle
document.getElementById('showEdges').addEventListener('change', (e) => {
currentActors.forEach(actor => {
actor.getProperty().setEdgeVisibility(e.target.checked);
});
renderWindow.render();
window.parent.postMessage({ type: 'WIDGET_UPDATE', widget: 'show_edges', value: e.target.checked }, '*');
});
// Screenshot
document.getElementById('screenshot').addEventListener('click', async () => {
renderWindow.render();
await screenshotCapture.captureAndDownload(`screenshot_${Date.now()}.png`);
});
// Save mesh
document.getElementById('saveMesh').addEventListener('click', () => {
if (currentFilename) {
const link = document.createElement('a');
link.href = `/view/${currentFilename}`;
link.download = currentFilename;
link.click();
}
});
// Settings panel
const settingsButton = document.getElementById('settingsButton');
const settingsPanel = document.getElementById('settingsPanel');
const settingsOverlay = document.getElementById('settingsOverlay');
const applySettings = document.getElementById('applySettings');
const cancelSettings = document.getElementById('cancelSettings');
settingsButton.addEventListener('click', () => {
settingsPanel.classList.add('visible');
settingsOverlay.classList.add('visible');
});
cancelSettings.addEventListener('click', () => {
settingsPanel.classList.remove('visible');
settingsOverlay.classList.remove('visible');
});
settingsOverlay.addEventListener('click', () => {
settingsPanel.classList.remove('visible');
settingsOverlay.classList.remove('visible');
});
applySettings.addEventListener('click', () => {
const opacity = parseFloat(document.getElementById('opacity').value);
const representation = parseInt(document.getElementById('representationSettings').value);
const parallel = document.getElementById('parallelProjection').checked;
currentActors.forEach(actor => {
const property = actor.getProperty();
property.setOpacity(opacity);
property.setRepresentation(representation);
});
cameraController.setParallelProjection(parallel);
renderWindow.render();
settingsPanel.classList.remove('visible');
settingsOverlay.classList.remove('visible');
});
// Resize handling
const resizeObserver = new ResizeObserver(() => {
const { width, height } = container.getBoundingClientRect();
if (width > 0 && height > 0) {
openGLRenderWindow.setSize(width, height);
renderWindow.render();
}
});
resizeObserver.observe(container);
// Expose for parent widget
window.loadMesh = loadTexturedMesh;
window.gltfSupported = gltfSupported;
// Listen for postMessage
window.addEventListener('message', async (event) => {
if (event.data.type === 'LOAD_MESH' && event.data.filepath) {
const filepath = event.data.filepath;
const metadata = event.data.metadata || {};
const showEdges = event.data.showEdges || false;
_pendingCameraState = event.data.cameraState || null;
const success = await loadTexturedMesh(filepath, metadata);
if (success) {
// Apply showEdges state from parent widget
const edgesCheckbox = document.getElementById('showEdges');
edgesCheckbox.checked = showEdges;
currentActors.forEach(actor => {
actor.getProperty().setEdgeVisibility(showEdges);
});
renderWindow.render();
}
if (window.parent && window.parent !== window) {
window.parent.postMessage({
type: success ? 'MESH_LOADED' : 'MESH_ERROR',
error: success ? null : 'Failed to load mesh'
}, '*');
}
}
});
// Initial status
loadingText.textContent = gltfSupported ?
'Ready to load textured mesh (GLB/GLTF)' :
'Ready - GLTFImporter loading...';
console.log('[GeomPack VTK Textured Viewer] Loaded successfully');
</script>
</body>
</html>