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:
@@ -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 };
|
||||
@@ -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");
|
||||
@@ -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 };
|
||||
@@ -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"
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
@@ -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") {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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`);
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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'
|
||||
};
|
||||
@@ -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'
|
||||
};
|
||||
@@ -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'
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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">▶</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 = "⏸";
|
||||
clock.start();
|
||||
}
|
||||
|
||||
function pause() {
|
||||
isPlaying = false;
|
||||
playBtn.innerHTML = "▶";
|
||||
}
|
||||
|
||||
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
@@ -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
Reference in New Issue
Block a user