Isolation-clean frontend: delete dead JS, guard listeners, leave shared DOM

- Delete 7 dead-squat widgets (registered UI only for other packs' nodes:
  UniRig/SAM3D/Mocap/FBXPreview/BVH/pointcloud) + their 6 orphaned viewer
  HTMLs + the entire unused js/viewer tree (21 files, imported by nothing;
  only ui/styles.css survives) + unused utils barrel.
- Rename namespace geompack -> geometrypack (= DisplayName, lowercased);
  all 17 surviving extensions now geometrypack.*.
- Add event.source provenance guards to every message listener (2 inline,
  2 factory handlers via new iframe param).
- Stop touching document.body: upload file input stays detached (click()
  works detached in modern browsers); folder autocomplete dropdown becomes
  a manual popover inside the widget's own DOM (top layer escapes canvas
  transforms without shared-DOM appends).

Passes comfy-test's javascript isolation lint with 0 errors.
This commit is contained in:
PozzettiAndrea
2026-08-13 14:31:51 +02:00
parent 9ebf36b571
commit feb273ad0a
54 changed files with 52 additions and 11515 deletions
-258
View File
@@ -1,258 +0,0 @@
/**
* 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
@@ -1,219 +0,0 @@
/**
* 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
@@ -1,161 +0,0 @@
/**
* 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
@@ -1,10 +0,0 @@
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
@@ -1,315 +0,0 @@
/**
* 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 };
+4 -1
View File
@@ -15,7 +15,7 @@ const EXTENSION_FOLDER = (() => {
console.log("[GeomPack Gaussian] Loading extension...");
app.registerExtension({
name: "geompack.gaussianpreview",
name: "geometrypack.gaussianpreview",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "GeomPackPreviewGaussian") {
@@ -101,6 +101,9 @@ app.registerExtension({
// Listen for messages from iframe
window.addEventListener('message', async (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;
// Handle screenshot messages
if (event.data.type === 'SCREENSHOT' && event.data.image) {
try {
+12 -7
View File
@@ -15,7 +15,7 @@ function pathStem(value) {
}
app.registerExtension({
name: "geompack.loadmeshbatch.path",
name: "geometrypack.loadmeshbatch.path",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "GeomPackLoadMeshBatch") return;
console.log(`${TAG} registering for GeomPackLoadMeshBatch`);
@@ -48,16 +48,21 @@ app.registerExtension({
});
widget.computeSize = (w) => [w, 30];
// Floating dropdown attached to body (avoids canvas clipping/transform).
// Floating dropdown as a manual POPOVER inside our own widget DOM:
// showPopover() renders it in the browser's top layer, which
// escapes the canvas transform/clipping WITHOUT appending anything
// to the shared document.body (isolation: we never touch DOM we
// don't own). UA popover styles are reset (inset/margin/padding).
const menu = document.createElement("div");
menu.style.cssText = "position:fixed;z-index:10000;max-height:240px;overflow-y:auto;" +
menu.popover = "manual";
menu.style.cssText = "position:fixed;inset:auto;margin:0;padding:0;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);
"box-shadow:0 4px 14px rgba(0,0,0,0.55);font:12px monospace;";
wrap.appendChild(menu);
let items = [];
let lastDir = null;
const hide = () => { menu.style.display = "none"; };
const hide = () => { if (menu.matches(":popover-open")) menu.hidePopover(); };
const place = () => {
const b = input.getBoundingClientRect();
menu.style.left = b.left + "px";
@@ -84,8 +89,8 @@ app.registerExtension({
});
menu.appendChild(row);
}
if (!menu.matches(":popover-open")) menu.showPopover();
place();
menu.style.display = "block";
};
const fetchDir = async (force) => {
const [dir, rem] = pathStem(input.value);
+5 -3
View File
@@ -67,7 +67,7 @@ function selectValue(node, val) {
}
app.registerExtension({
name: "geompack.loadmesh.upload",
name: "geometrypack.loadmesh.upload",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "GeomPackLoadMesh") return;
console.log(`${TAG} registering for GeomPackLoadMesh`);
@@ -142,8 +142,10 @@ app.registerExtension({
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
// Deliberately NOT attached to the DOM: input.click() opens the
// picker from a detached element in all modern browsers, and
// staying detached keeps us out of the shared document entirely.
node._gpFileInput = input;
node.addWidget("button", "⬆ upload / drop mesh", null, () => { console.log(`${TAG} upload button clicked`); input.click(); });
// inline View 3D
+1 -1
View File
@@ -15,7 +15,7 @@ const EXTENSION_FOLDER = (() => {
console.log("[GeomPack] Loading mesh preview extension...");
app.registerExtension({
name: "geompack.meshpreview",
name: "geometrypack.meshpreview",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "GeomPackPreviewMesh") {
+2 -2
View File
@@ -11,7 +11,7 @@ import { buildDualMeshInfoHTML, formatExtents } from "./utils/formatting.js";
import { createViewerManager, createErrorHandler, buildViewUrl, createLoadDualMeshMessage } from "./utils/postMessage.js";
app.registerExtension({
name: "geompack.meshpreview.dual",
name: "geometrypack.meshpreview.dual",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "GeomPackPreviewMeshDual") {
@@ -68,7 +68,7 @@ app.registerExtension({
const viewerManager = createViewerManager(iframe, "[GeomPack Dual]");
// Listen for error messages
window.addEventListener('message', createErrorHandler(infoPanel, "[GeomPack Dual]"));
window.addEventListener('message', createErrorHandler(infoPanel, "[GeomPack Dual]", iframe));
// Set initial node size
this.setSize([768, 680]);
-325
View File
@@ -1,325 +0,0 @@
/**
* 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
}
});
+1 -1
View File
@@ -15,7 +15,7 @@ const EXTENSION_FOLDER = (() => {
console.log('[GeomPack Multi JS] Loading mesh_preview_multi.js extension');
app.registerExtension({
name: "geompack.meshpreview.multi",
name: "geometrypack.meshpreview.multi",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "GeomPackPreviewMeshMulti") {
+1 -1
View File
@@ -15,7 +15,7 @@ const EXTENSION_FOLDER = (() => {
console.log("[GeomPack] Loading UV mesh preview extension...");
app.registerExtension({
name: "geompack.meshpreviewuv",
name: "geometrypack.meshpreviewuv",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "GeomPackPreviewMeshUV") {
+3 -3
View File
@@ -11,7 +11,7 @@ import { createScreenshotHandler } from "./utils/screenshot.js";
import { createViewerManager, createErrorHandler, buildViewUrl } from "./utils/postMessage.js";
app.registerExtension({
name: "geompack.meshpreview.vtk",
name: "geometrypack.meshpreview.vtk",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "GeomPackPreviewMeshVTK") {
@@ -76,8 +76,8 @@ app.registerExtension({
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]"));
window.addEventListener('message', createScreenshotHandler('vtk-screenshot', iframe));
window.addEventListener('message', createErrorHandler(infoPanel, "[GeomPack VTK]", iframe));
// Set initial node size
this.setSize([512, 640]);
+1 -1
View File
@@ -13,7 +13,7 @@ const EXTENSION_FOLDER = (() => {
})();
app.registerExtension({
name: "geompack.meshpreview.vtk.batch",
name: "geometrypack.meshpreview.vtk.batch",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "GeomPackPreviewMeshVTKBatch") {
+4 -1
View File
@@ -15,7 +15,7 @@ const EXTENSION_FOLDER = (() => {
console.log("[GeomPack] Loading VTK.js textured mesh preview extension...");
app.registerExtension({
name: "geompack.meshpreview.vtk.textured",
name: "geometrypack.meshpreview.vtk.textured",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "GeomPackPreviewMeshVTKWithTexture") {
@@ -83,6 +83,9 @@ app.registerExtension({
// Listen for messages from iframe
window.addEventListener('message', async (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;
// Handle screenshot messages
if (event.data.type === 'SCREENSHOT' && event.data.image) {
console.log('[GeomPack VTK Textured] Received screenshot from iframe');
-112
View File
@@ -1,112 +0,0 @@
/**
* 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 });
}
}
}
};
}
}
});
+2 -2
View File
@@ -14,13 +14,13 @@ function toggleWidget(w, show) {
w.type = w.__gpOrig.type;
w.computeSize = w.__gpOrig.computeSize;
} else {
w.type = "geompack_hidden"; // unknown type -> not drawn
w.type = "geometrypack_hidden"; // unknown type -> not drawn
w.computeSize = () => [0, -4]; // collapse (cancels the 4px row gap)
}
}
app.registerExtension({
name: "geompack.previewmeshbatchrender.ui",
name: "geometrypack.previewmeshbatchrender.ui",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "GeomPackPreviewMeshBatchRender") return;
console.log(`${TAG} registering for GeomPackPreviewMeshBatchRender`);
+1 -1
View File
@@ -26,7 +26,7 @@ function viewUrl(name) {
}
app.registerExtension({
name: "geompack.previewmeshboundaries",
name: "geometrypack.previewmeshboundaries",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "GeomPackPreviewMeshBoundaries") return;
console.log(`${TAG} registering for GeomPackPreviewMeshBoundaries`);
+1 -1
View File
@@ -15,7 +15,7 @@ const EXTENSION_FOLDER = (() => {
console.log('[GeomPack Multi Batch JS] Loading preview_mesh_multi_batch.js extension');
app.registerExtension({
name: "geompack.meshpreview.multibatch",
name: "geometrypack.meshpreview.multibatch",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "GeomPackPreviewMeshMultiBatch") {
+1 -1
View File
@@ -24,7 +24,7 @@ function viewUrl(name) {
}
app.registerExtension({
name: "geompack.previewmeshslicer",
name: "geometrypack.previewmeshslicer",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "GeomPackPreviewMeshSlicer") return;
console.log(`${TAG} registering for GeomPackPreviewMeshSlicer`);
+1 -1
View File
@@ -92,7 +92,7 @@ const TOGGLE_HEIGHT = 28;
const DEFAULT_CONTENT_HEIGHT = 60;
app.registerExtension({
name: "geompack.text_report",
name: "geometrypack.text_report",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (!TEXT_REPORT_NODES.includes(nodeData.name)) return;
+1 -1
View File
@@ -15,7 +15,7 @@ const EXTENSION_FOLDER = (() => {
})();
app.registerExtension({
name: "geompack.ultimate_mesh_inspection",
name: "geometrypack.ultimate_mesh_inspection",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "GeomPackUltimateMeshInspection") return;
-11
View File
@@ -1,11 +0,0 @@
/**
* 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';
+5 -1
View File
@@ -182,10 +182,14 @@ export function createViewerManager(iframe, logPrefix = "[Viewer]") {
* Create an error message handler for iframe errors
* @param {HTMLElement} infoPanel - The info panel to display errors
* @param {string} logPrefix - Logging prefix
* @param {HTMLIFrameElement} iframe - Only accept messages from this iframe
* @returns {Function} Event handler function
*/
export function createErrorHandler(infoPanel, logPrefix = "[Viewer]") {
export function createErrorHandler(infoPanel, logPrefix = "[Viewer]", iframe = null) {
return (event) => {
// Provenance guard: without it this fires for EVERY pack's iframe
// messages in the shared page, not just ours.
if (iframe && event.source !== iframe.contentWindow) return;
if (event.data.type === 'MESH_ERROR' && event.data.error) {
console.error(`${logPrefix} Error from viewer:`, event.data.error);
if (infoPanel) {
+5 -1
View File
@@ -55,11 +55,15 @@ export async function uploadScreenshot(dataUrl, prefix = 'screenshot') {
/**
* Create a message handler for screenshot events from iframe
* @param {string} prefix - Filename prefix for screenshots
* @param {HTMLIFrameElement} iframe - Only accept messages from this iframe
* @param {Function} onError - Error callback
* @returns {Function} Event handler function
*/
export function createScreenshotHandler(prefix = 'screenshot', onError = console.error) {
export function createScreenshotHandler(prefix = 'screenshot', iframe = null, onError = console.error) {
return async (event) => {
// Provenance guard: without it this fires for EVERY pack's iframe
// messages in the shared page, not just ours.
if (iframe && event.source !== iframe.contentWindow) return;
if (event.data.type === 'SCREENSHOT' && event.data.image) {
try {
await uploadScreenshot(event.data.image, prefix);
-276
View File
@@ -1,276 +0,0 @@
/**
* 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
@@ -1,657 +0,0 @@
/**
* 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;
@@ -1,271 +0,0 @@
/**
* 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;
@@ -1,242 +0,0 @@
/**
* 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;
@@ -1,352 +0,0 @@
/**
* 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;
@@ -1,97 +0,0 @@
/**
* 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;
@@ -1,213 +0,0 @@
/**
* 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
@@ -1,199 +0,0 @@
/**
* 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
@@ -1,299 +0,0 @@
/**
* 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'
};
@@ -1,228 +0,0 @@
/**
* 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
@@ -1,63 +0,0 @@
/**
* 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
@@ -1,60 +0,0 @@
/**
* 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
@@ -1,116 +0,0 @@
/**
* 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
@@ -1,398 +0,0 @@
/**
* 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
@@ -1,383 +0,0 @@
/**
* 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;
-94
View File
@@ -1,94 +0,0 @@
/**
* 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
@@ -1,59 +0,0 @@
/**
* 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
};
@@ -1,100 +0,0 @@
/**
* 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
@@ -1,551 +0,0 @@
/**
* 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;
@@ -1,241 +0,0 @@
/**
* 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;
@@ -1,161 +0,0 @@
/**
* 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;
+1 -1
View File
@@ -10,7 +10,7 @@
import { app } from "../../../scripts/app.js";
app.registerExtension({
name: "geompack.warp_mesh_field_select",
name: "geometrypack.warp_mesh_field_select",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "GeomPackWarpMesh") return;
-277
View File
@@ -1,277 +0,0 @@
<!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
@@ -1,354 +0,0 @@
<!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
-316
View File
@@ -1,316 +0,0 @@
<!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